Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7e6a57dc3 | ||
|
|
d648c7743a | ||
|
|
00555eadd7 | ||
|
|
da045fa77f | ||
|
|
e241ff9a31 | ||
|
|
5fa4be3cac | ||
|
|
8122267f2b | ||
|
|
0e29236dff | ||
|
|
aec22e32b7 | ||
|
|
5a2d460075 | ||
|
|
1e65852f2c | ||
|
|
5a80e6f76f | ||
|
|
415564f00f | ||
|
|
8cc9e29ab3 | ||
|
|
3fbd6ad75f | ||
|
|
5dc158f3a1 | ||
|
|
531779106e | ||
|
|
14bb297dbc | ||
|
|
1cb1be5ee8 | ||
|
|
799ffa0770 | ||
|
|
bb2c1f218f | ||
|
|
4e6261561e | ||
|
|
56c1ce90c3 | ||
|
|
0bc660b8d1 | ||
|
|
cbcce5e225 | ||
|
|
761feeedae | ||
|
|
b663d51678 | ||
|
|
14fde86f68 | ||
|
|
76f61e77c0 | ||
|
|
49d904bed7 | ||
|
|
10a52c2dde | ||
|
|
b474acac0a | ||
|
|
1c4dfc39e4 | ||
|
|
ca6d9805b2 | ||
|
|
aa7fe3c2d3 | ||
|
|
65c0410181 | ||
|
|
b88b26e6dd | ||
|
|
c4b3168e31 | ||
|
|
d86c0560d0 | ||
|
|
1ca2ca4466 | ||
|
|
98d63c0d44 |
+11
@@ -13,6 +13,17 @@ socket, by design. Everything else runs anywhere, which is what the CI job names
|
||||
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
|
||||
that does not.
|
||||
|
||||
## `bats` is a declared dependency here, not part of the image
|
||||
|
||||
It is in `.dejima/dependencies`, like any other project's test runner, rather
|
||||
than baked into the base image for every project that never runs bats. So on a
|
||||
sandbox whose image predates that approval, `bats` is simply absent — the fix
|
||||
is the ordinary one the briefing describes (`dejima-project approve`, then
|
||||
relaunch so the derived image is rebuilt), not installing it in-session.
|
||||
|
||||
If it is missing and you cannot get a rebuild, say the suites were not run
|
||||
rather than reporting a change as tested.
|
||||
|
||||
## Run the suite for what you changed, not all of them
|
||||
|
||||
The suites are independent and cover different areas. Running all of them for
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
docs.gitea.com
|
||||
deb.debian.org
|
||||
download.docker.com
|
||||
github.com
|
||||
api.github.com
|
||||
objects.githubusercontent.com
|
||||
release-assets.githubusercontent.com
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
apt:tmux
|
||||
apt:bats
|
||||
|
||||
+304
-13
@@ -17,8 +17,34 @@
|
||||
# covering every unrelated area at once.
|
||||
name: tests
|
||||
|
||||
# pull_request_target, not pull_request, and the difference is the whole
|
||||
# security posture of this file.
|
||||
#
|
||||
# Under `pull_request` the workflow is read from the HEAD of the pull request -
|
||||
# so the branch under test decides what runs and, critically, `runs-on:`. A
|
||||
# branch can point its jobs at any runner its repository can reach, which
|
||||
# means every control the runner provides (no Docker socket, deny-by-default
|
||||
# egress) is opt-out by whoever wrote the branch.
|
||||
#
|
||||
# Under `pull_request_target` Gitea reads the workflow from the BASE branch,
|
||||
# and explicitly discards any pull_request_target workflow found on the head
|
||||
# commit - so a branch can neither edit this file's copy nor introduce its
|
||||
# own. What runs here, and where, is whatever `main` says.
|
||||
#
|
||||
# The cost is that the checkout no longer defaults to the code under test: the
|
||||
# run's ref is the base branch. Every job that means to test the branch has to
|
||||
# say so, which is why the checkouts below carry an explicit ref. A bare
|
||||
# checkout here would test `main` on every pull request and pass regardless of
|
||||
# the change - green checks that mean nothing.
|
||||
#
|
||||
# This does NOT make the repository safe on its own. A branch can still add a
|
||||
# workflow of its own with `on: pull_request`, and that runs from the head
|
||||
# with whatever `runs-on:` it likes. The only thing stopping that is which
|
||||
# runners the repository can reach at all - see docs/ci.md, "A label is not a
|
||||
# boundary". What this buys is that the intended CI is trustworthy, and that
|
||||
# escaping it requires adding a file rather than editing one.
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
|
||||
# No `concurrency:` block. Gitea does not implement one - the word appears
|
||||
# nowhere in its actions models or services as of 1.25 - so writing it here
|
||||
@@ -29,7 +55,21 @@ on:
|
||||
|
||||
jobs:
|
||||
sandbox:
|
||||
runs-on: ubuntu-latest
|
||||
# A label carried only by a runner configured for this - see ci/ and
|
||||
# docs/ci.md. Not a generic one, even though these suites genuinely do run
|
||||
# anywhere a shell does, because "runs anywhere" is a statement about what
|
||||
# the job needs and not about what it should be handed.
|
||||
#
|
||||
# Whether a job container gets the Docker socket is a property of the
|
||||
# runner's config.yaml (container.docker_host), not of anything this file
|
||||
# can ask for or refuse. So a job that needs nothing still receives
|
||||
# whatever its runner grants everything it runs, and naming a specific
|
||||
# label is the only way this file gets a say in which runner that is.
|
||||
#
|
||||
# If no runner carries this label, these jobs queue. That is the intended
|
||||
# failure: it is a better one than a green run on a host that grants
|
||||
# everything.
|
||||
runs-on: dejima-ci
|
||||
strategy:
|
||||
# One suite per job. fail-fast off because these cover unrelated areas -
|
||||
# cancelling the other six on the first failure would hide whether a
|
||||
@@ -51,11 +91,65 @@ 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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
# 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.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
# As an ordinary user, not root. One test leaves a directory mode 500 and
|
||||
# asserts that the CLI's rm -rf refuses it and says so; root walks
|
||||
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
|
||||
@@ -72,18 +166,20 @@ jobs:
|
||||
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
|
||||
|
||||
host-docker:
|
||||
# Not ubuntu-latest, unlike the job above. This one only works on a runner
|
||||
# configured for it - host network namespace, the Docker socket, and the
|
||||
# shared volume below - and none of that is anything the workflow can ask
|
||||
# for. On a generic runner it fails the preflight check outright.
|
||||
# A different label from the job above, and the only one in this file that
|
||||
# asks for anything privileged. This job needs the host network namespace,
|
||||
# the Docker socket and the shared volume below, none of which a workflow
|
||||
# can request - they are granted by the runner or not at all, so on a
|
||||
# runner without them it fails the preflight check outright.
|
||||
#
|
||||
# ubuntu-latest is a label every runner carries, so which one picked this
|
||||
# up was a coin flip, and a re-run that happened to land on an unconfigured
|
||||
# runner failed on a repo that had not changed. A label only the configured
|
||||
# runner has makes that unrepresentable rather than merely diagnosable: no
|
||||
# runner matching it means this job queues instead of running somewhere it
|
||||
# cannot work. The sandbox job stays on ubuntu-latest, because it genuinely
|
||||
# does run anywhere.
|
||||
# A label carried by exactly one configured runner makes landing somewhere
|
||||
# unusable unrepresentable rather than merely diagnosable: no runner
|
||||
# matching it means this queues instead of running somewhere it cannot
|
||||
# work. A generic label would make which runner picked it up a coin flip.
|
||||
#
|
||||
# The runner behind this label can be taken by anything it runs (see
|
||||
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
|
||||
# for that and holds nothing else.
|
||||
runs-on: dejima-runtime
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
@@ -117,6 +213,55 @@ 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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -239,6 +384,8 @@ jobs:
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# The build dominates this job, and inside setup_file it is a `docker
|
||||
# build -q` that prints nothing at all - so tens of minutes of ordinary
|
||||
@@ -291,3 +438,147 @@ jobs:
|
||||
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
|
||||
# of the socket, which is all any of those mounts need.
|
||||
TMPDIR: /tmp/dejima-ci
|
||||
|
||||
# The runner's own configuration, asserted from inside a job on it. Separate
|
||||
# jobs rather than steps on the two above, because the point is that these
|
||||
# are two different runners and either can be wrong on its own - one red
|
||||
# check per tier says which.
|
||||
#
|
||||
# These fail on how the RUNNER is configured, not on anything in a pull
|
||||
# request. A red check here means the machine needs attention; it does not
|
||||
# mean the branch does.
|
||||
runner-env-sandbox:
|
||||
runs-on: dejima-ci
|
||||
name: runner-env (sandbox)
|
||||
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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
# No `ref:` here, deliberately - unlike the suites above. These assert on
|
||||
# the RUNNER, not on the change, so the assertions should come from the
|
||||
# base branch rather than from the branch they are meant to hold for.
|
||||
# Under pull_request_target that is what a bare checkout gives, which
|
||||
# means a branch can neither weaken these tests nor delete them.
|
||||
- uses: actions/checkout@v4
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
# Declared, not detected: landing on the wrong runner should fail
|
||||
# rather than quietly select a different set of assertions.
|
||||
DEJIMA_CI_TIER: sandbox
|
||||
|
||||
runner-env-host-docker:
|
||||
runs-on: dejima-runtime
|
||||
name: runner-env (host-docker)
|
||||
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
|
||||
# allowlist, not the package - and ci-runner.bats, which would say so
|
||||
# precisely, cannot run yet because installing it is what just failed.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# 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.
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this same run resolved fine, the runners are" >&2
|
||||
echo "configured differently: check that EVERY runner answering this" >&2
|
||||
echo "label has --dns=<gateway> in container.options and the same" >&2
|
||||
echo "container.network. A runner missing it fails only the jobs that" >&2
|
||||
echo "happen to land on it, which looks intermittent." >&2
|
||||
echo "" >&2
|
||||
echo "If every job failed, check on the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
# Base branch, same as the job above and for the same reason.
|
||||
- uses: actions/checkout@v4
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
DEJIMA_CI_TIER: host-docker
|
||||
|
||||
@@ -23,6 +23,14 @@ Around that core is most of what running an agent unattended actually takes:
|
||||
open pull requests and issues against Gitea or Forgejo and pull review
|
||||
feedback back in, on a token scoped for exactly that. Merging stays a
|
||||
human decision, made outside the sandbox.
|
||||
- **The CI exposure, stated up front.** Push access is code execution on
|
||||
whatever host your CI runs on: a session opens a pull request, the workflow
|
||||
fires, and the runner executes that commit before you have read a line of
|
||||
it. The container bounds what a session reaches directly — not what it can
|
||||
arrange to have run elsewhere. That work happens on the runner:
|
||||
[docs/ci.md](docs/ci.md) for what it takes, and
|
||||
[dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
|
||||
for a tool that does it.
|
||||
- **Logging.** Every DNS lookup and rejected connection, shipped to Loki —
|
||||
and, opt-in, the session transcript alongside them.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
@@ -410,39 +418,20 @@ By default the container's entry point is bare `claude` (always running with
|
||||
`docker/claude-wrapper.sh`), so a fresh clone of this repo works with no
|
||||
editor setup at all.
|
||||
|
||||
Typical loop: point it at a project, ask Claude to implement something and
|
||||
open a PR (`gitea-pr` is on `PATH`); from the host, `gitea-pr comments <PR#>`
|
||||
to pull review feedback, feed it back in, repeat. Merging is yours — see
|
||||
`merge-guard.sh` below. `gitea-issue` is on `PATH` too, for the work
|
||||
that gets deferred rather than done: `list`, `view`, `create`, `comment`,
|
||||
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
|
||||
of a footnote on an unrelated PR.
|
||||
Typical loop: point it at a project, ask for a change, and let the session
|
||||
work. It commits to a feature branch, pushes it, and opens a pull request —
|
||||
then it stops. You review and merge that PR yourself, wherever you normally
|
||||
read them. Merging is the one step the sandbox is not allowed to take, and
|
||||
`merge-guard.sh` blocks it from the inside.
|
||||
|
||||
`gitea-actions` closes the other half of that loop. A sandbox cannot run the
|
||||
tests that need a Docker daemon, so CI is where they run, and without a way
|
||||
to read a run the only route from a red build back into the session is the
|
||||
operator pasting the log in by hand. `gitea-actions runs`, `jobs <run>` and
|
||||
`logs [run]` do that instead — with no run given, the newest one for `HEAD`,
|
||||
and by default only the jobs that failed. It reads and never writes.
|
||||
That boundary is the shape of the loop rather than a limit on it: every
|
||||
session ends at something you read before it lands, and nothing reaches your
|
||||
default branch without you.
|
||||
|
||||
Because the session stops at the PR and you merge afterwards, the sandbox
|
||||
context tells Claude to put `Fixes #N` in the PR body rather than to close
|
||||
issues by hand once they land — the merge is the last step in that sequence
|
||||
that still happens, and it happens when the agent is gone. `Refs #N` is the
|
||||
deliberate opposite, for work that touches a ticket without finishing it.
|
||||
|
||||
Both take `--body-file FILE` (or `-` for stdin) as well as `--body`. Prefer it
|
||||
for anything with an apostrophe, a backtick or a code fence in it — a body
|
||||
passed on a command line has to survive shell quoting first.
|
||||
|
||||
Inside the sandbox there's a second reason, and it argues for a real file over
|
||||
a heredoc. `deny-dangerous-bash.sh` matches patterns anywhere in the Bash
|
||||
command string, and it has no way to tell an instruction from prose that
|
||||
merely mentions one — so a PR or issue body that discusses restarting a host,
|
||||
or quotes a destructive command it is warning about, gets the whole call
|
||||
blocked. Writing the body to a file first keeps that text out of the command
|
||||
string entirely. A heredoc doesn't: the body is still part of the command the
|
||||
hook reads, so it solves the quoting problem but not this one.
|
||||
How far the session goes is the `[core] workflow` setting — `worktree-pr`
|
||||
pushes and opens the PR described above, `branch-push` stops at the push, and
|
||||
`local-only` commits and pushes nowhere. See
|
||||
[Provider axes](#provider-axes-forge-workflow-harness).
|
||||
|
||||
### Using a different editor/application
|
||||
|
||||
@@ -812,9 +801,45 @@ A project can declare what it needs in two committed files:
|
||||
|
||||
```
|
||||
.dejima/allowed-domains # one domain per line
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line
|
||||
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
|
||||
# tarball:<name>@<sha256>:<url> release archive
|
||||
```
|
||||
|
||||
### Tarball dependencies
|
||||
|
||||
For a toolchain no package ecosystem ships in a version you can use — a
|
||||
current Go, say, where Debian's is several releases behind — a dependency can
|
||||
name a release archive instead:
|
||||
|
||||
```sh
|
||||
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
|
||||
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
|
||||
shapes without the spec needing layout options: a Go archive has everything
|
||||
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
|
||||
level puts `bin/` in the same place for both. Compression is detected from the
|
||||
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
|
||||
|
||||
**The digest is mandatory**, and it is checked before anything is unpacked.
|
||||
Without one you would be approving a *name* whose contents the far end can
|
||||
change afterwards, which isn't a decision anyone can make once. With one, what
|
||||
you approved is a specific archive and nothing else can take its place. Two
|
||||
consequences worth knowing:
|
||||
|
||||
- **A tarball dependency names one architecture**, because a digest does.
|
||||
Unlike a package name, it can't resolve per-host, so a mixed-architecture
|
||||
fleet needs a spec per project rather than one global one.
|
||||
- **Upgrading is an approval, not a version bump.** A new release is a new
|
||||
digest, so it goes through `dejima-dep add` and `dejima-project approve`
|
||||
again — which is the point, not friction to route around.
|
||||
|
||||
The URL's character set is deliberately narrower than URLs allow: the spec is
|
||||
interpolated into a `RUN` line that runs as root at build time, so anything
|
||||
shell-active is refused outright rather than quoted around, and only `https`
|
||||
is accepted.
|
||||
|
||||
**Declaring grants nothing.** The project directory is bind-mounted
|
||||
read-write, so a compromised session could write these files - they are read
|
||||
only to compare against reality and suggest a command. Approvals live
|
||||
@@ -1049,14 +1074,24 @@ already run, and it does not take a modified workflow file to get there: a
|
||||
workflow that runs a test suite runs every file in it.
|
||||
|
||||
The sandbox bounds what a session can reach directly. It does not bound what
|
||||
it can arrange to have run elsewhere. Read this one before you point a
|
||||
sandbox at a project whose CI you administer - and if you would rather not
|
||||
take it on, `[core] forge = none` with `[core] workflow = local-only` declines
|
||||
the push grant and the code execution with it.
|
||||
it can arrange to have run elsewhere.
|
||||
|
||||
**→ [docs/ci.md](docs/ci.md)** - why no approval gate is offered, hardening a
|
||||
runner, and making CI's egress allowlist a version-controlled artifact read
|
||||
from `main`.
|
||||
**This holds for projects with no CI, too** - a branch can add the first
|
||||
workflow, so what matters is which runners the repository can *reach*, not
|
||||
what it runs today. That is a property of the forge rather than the project,
|
||||
and it is worth one question per project before you start. On hosted runners
|
||||
the answer is reassuring; with self-hosted runners in reach it is the thing to
|
||||
look at. And if you would rather not take it on at all,
|
||||
`[core] forge = none` with `[core] workflow = local-only` declines the push
|
||||
grant and the code execution with it.
|
||||
|
||||
**→ [docs/ci.md](docs/ci.md)** - the *why*: no approval gate is offered and
|
||||
what that leaves, hardening a runner, and making CI's egress allowlist a
|
||||
version-controlled artifact read from `main`.
|
||||
|
||||
**→ [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)** - the *how*: a self-contained tool that
|
||||
hardens a CI runner, installed on the CI box and nowhere near a dejima host.
|
||||
Nothing in this repository needs to be copied onto a runner.
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -1155,6 +1190,7 @@ likely to collide with a real container you have running.
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
|
||||
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
|
||||
| `ci/` | Worked runner, resolver and firewall examples for the setup `docs/ci.md` describes; installed by nothing, and belongs on a CI machine rather than a dejima one |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point — it resolves the project path,
|
||||
|
||||
+61
-10
@@ -21,6 +21,11 @@ COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-color-lib.sh"
|
||||
# fd 2: everything this script says for itself goes to stderr, so the
|
||||
# session's own stdout stays exactly what the container wrote.
|
||||
color_init 2
|
||||
|
||||
# Flags come first, then the optional workspace path, then "--" and
|
||||
# everything after that reaches the container command untouched - including a
|
||||
@@ -1095,6 +1100,25 @@ if remote_enabled; then
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
# Verify what was just created, because everything downstream assumes it.
|
||||
# A leaf left behind root-owned by an earlier container restart (see
|
||||
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
|
||||
# existing directory regardless of who owns it, so without this the mount
|
||||
# goes ahead and the failure surfaces later as EACCES in a hook.
|
||||
#
|
||||
# A warning rather than a refusal. Remote access is opt-in, and the rest
|
||||
# of the session works without it - blocking a launch over undeliverable
|
||||
# notifications would cost more than it saves. The neighbouring state-dir
|
||||
# check does refuse, but that one is about a symlink handing a session an
|
||||
# attacker-chosen path; this one is only about the feature working.
|
||||
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
|
||||
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
|
||||
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
|
||||
echo "from a container that was restarted rather than relaunched; removing it and" >&2
|
||||
echo "starting again clears it." >&2
|
||||
fi
|
||||
|
||||
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
|
||||
# sandbox wasn't started from tmux: the session is still registered, just
|
||||
# listed as not-attachable, rather than silently omitted.
|
||||
@@ -1254,16 +1278,43 @@ audit_reports="$AUDIT_DIR/$audit_slug"
|
||||
# every ordinary launch.
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
if [ -n "$pending_crit" ]; then
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
|
||||
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
|
||||
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' [c] continue [q] quit: ' >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
|
||||
elif [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
# Re-read after an inline `ack`, so approving everything here clears the
|
||||
# warning instead of asking again about reports that no longer carry a
|
||||
# CRITICAL. Looping rather than asking once: `ack` walks ONE report per
|
||||
# run (see select_report), so a workspace with several needs several
|
||||
# passes, and sending the operator back to their shell between each is
|
||||
# the friction this option exists to remove (#65).
|
||||
while [ -n "$pending_crit" ]; do
|
||||
n="$(printf '%s\n' "$pending_crit" | wc -l)"
|
||||
echo "" >&2
|
||||
printf ' dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
|
||||
"$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
|
||||
printf '%s\n' "$pending_crit" \
|
||||
| sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^# from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
|
||||
printf ' Review with: %sdejima-audit ack "%s"%s\n' \
|
||||
"$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
|
||||
if [ -t 0 ]; then
|
||||
printf ' %s[a]%s review now %s[c]%s continue %s[q]%s quit: ' \
|
||||
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
|
||||
read -r reply || reply=q
|
||||
case "$reply" in
|
||||
q|Q) echo "aborted." >&2; exit 1 ;;
|
||||
a|A)
|
||||
# Run it here rather than telling the operator to run it
|
||||
# in another terminal: this is the moment they are
|
||||
# looking at the finding, and the container has not
|
||||
# started, so nothing is waiting on the answer.
|
||||
"$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
|
||||
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
break
|
||||
done
|
||||
fi
|
||||
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
|
||||
if [ "$(config_get_bool audit strict)" = "1" ]; then
|
||||
echo " audit.strict is on and stdin is not a tty - refusing to start." >&2
|
||||
exit 1
|
||||
else
|
||||
|
||||
+12
-2
@@ -20,6 +20,12 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
# For ipset_for_domain: the live add below writes into a running container's
|
||||
# ipsets, and which set a name belongs in is the container's rule, not this
|
||||
# script's. Sourced rather than restated so the two cannot drift - the same
|
||||
# reason init-firewall.sh and dns-proxy.sh share this file.
|
||||
# shellcheck source=../docker/fixed-domains.sh
|
||||
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
|
||||
@@ -133,9 +139,13 @@ if [ "$session_wanted" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# allowed-domains carries no port restriction, so a GitHub address added
|
||||
# there is reachable on 22 as well - which is the read-only guarantee gone,
|
||||
# from a command whose whole output says it granted one domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain)"
|
||||
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
|
||||
echo "Allowing $ip ($domain -> $target_set)"
|
||||
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
+230
-44
@@ -10,6 +10,11 @@ set -uo pipefail
|
||||
|
||||
# shellcheck source=dejima-provider-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
# shellcheck source=dejima-color-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# Defined empty here so every interpolation below is safe before a surface
|
||||
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
|
||||
color_init 1
|
||||
config_validate || exit 1
|
||||
|
||||
cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"
|
||||
@@ -562,6 +567,28 @@ baseline_suppresses() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether `ack` has already walked this report to its end. Recorded in the
|
||||
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
|
||||
# the file is already the per-workspace record of what the operator has
|
||||
# decided, already parsed line-by-line, and already tolerant of verbs it does
|
||||
# not know (baseline_suppresses skips them), so a report written by an older
|
||||
# version reads exactly as it did.
|
||||
#
|
||||
# Only findings that CANNOT be acknowledged are governed by this. An ackable
|
||||
# one is governed by its own entry, so `skip` still means "ask me again" for
|
||||
# it, which is the whole difference between skip and ignore.
|
||||
baseline_reviewed() {
|
||||
local want="$1" bf verb rest
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
[ -f "$bf" ] || return 1
|
||||
while read -r verb rest; do
|
||||
case "$verb" in
|
||||
reviewed) [ "$rest" = "$want" ] && return 0 ;;
|
||||
esac
|
||||
done < "$bf"
|
||||
return 1
|
||||
}
|
||||
|
||||
# git config keys whose values run a command on the next git invocation, or
|
||||
# (include.path/includeif.*.path) indirect to another config file that could
|
||||
# set one - key names are case-insensitive in git, so match on a lowercased
|
||||
@@ -915,16 +942,27 @@ gitconfig_count_for_key() {
|
||||
# it cannot be mistaken for the audit's own output, and capped. Sanitizing
|
||||
# line by line rather than whole-file keeps a planted NUL or newline run from
|
||||
# collapsing separate lines into one.
|
||||
# $2 = "diff" to colour +/- lines. Colour is applied to the audit's own
|
||||
# rendering of a line, never mixed into the line's text: the content here is
|
||||
# workspace-controlled, and sanitize() has already removed the bytes that
|
||||
# could forge one of these sequences.
|
||||
show_capped() {
|
||||
local f="$1" n=0 total line
|
||||
local f="$1" mode="${2:-plain}" n=0 total line clean
|
||||
total="$(wc -l < "$f" 2>/dev/null)" || total=0
|
||||
while IFS= read -r line; do
|
||||
n=$((n + 1))
|
||||
[ "$n" -le "$DISPLAY_LINE_CAP" ] || break
|
||||
printf ' %s\n' "$(sanitize "$line")"
|
||||
clean="$(sanitize "$line")"
|
||||
if [ "$mode" = diff ]; then
|
||||
case "$clean" in
|
||||
+*) printf ' %s%s%s\n' "$C_ADD" "$clean" "$C_RESET"; continue ;;
|
||||
-*) printf ' %s%s%s\n' "$C_DEL" "$clean" "$C_RESET"; continue ;;
|
||||
esac
|
||||
fi
|
||||
printf ' %s\n' "$clean"
|
||||
done < "$f"
|
||||
if [ "$total" -gt "$DISPLAY_LINE_CAP" ]; then
|
||||
printf ' ... +%s more line(s)\n' "$(( total - DISPLAY_LINE_CAP ))"
|
||||
printf ' %s... +%s more line(s)%s\n' "$C_DIM" "$(( total - DISPLAY_LINE_CAP ))" "$C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -949,22 +987,27 @@ render_finding() {
|
||||
local id="$1" sc="$2" key tmp
|
||||
key="$(id_key "$id")"
|
||||
|
||||
# Dim on the audit's own framing, plain on the content between it: the
|
||||
# complaint this answers is that the two ran together in one colour with
|
||||
# nothing to say where the tool stopped speaking and the workspace
|
||||
# started (#65).
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.diff" ]; then
|
||||
echo " --- what changed while the session ran ---"
|
||||
show_capped "$sc/$key.diff"
|
||||
printf ' %s--- what changed while the session ran ---%s\n' "$C_DIM" "$C_RESET"
|
||||
show_capped "$sc/$key.diff" diff
|
||||
source_hint "$id"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.removed" ]; then
|
||||
echo " --- this surface was removed; its pre-session content was not retained ---"
|
||||
printf ' %s--- this surface was removed; its pre-session content was not retained ---%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sc" ] && [ -f "$sc/$key.withheld" ]; then
|
||||
echo " --- no diff retained: $(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)") ---"
|
||||
printf ' %s--- no diff retained: %s ---%s\n' "$C_DIM" \
|
||||
"$(sanitize "$(head -n1 "$sc/$key.withheld" 2>/dev/null)")" "$C_RESET"
|
||||
elif [ -n "$sc" ] && [ -f "$sc/$key.new" ]; then
|
||||
echo " --- new surface; full current content ---"
|
||||
printf ' %s--- new surface; full current content ---%s\n' "$C_DIM" "$C_RESET"
|
||||
else
|
||||
echo " --- no pre-session record; current content ---"
|
||||
printf ' %s--- no pre-session record; current content ---%s\n' "$C_DIM" "$C_RESET"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)" || return 0
|
||||
@@ -1091,14 +1134,29 @@ case "$cmd" in
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
crit+=("COMMAND-EXECUTION git alias: $body")
|
||||
finding_ids+=("gitconfig:$key") ;;
|
||||
*) warn+=("changed git config: $body") ;;
|
||||
*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
warn+=("changed git config: $body")
|
||||
finding_ids+=("gitconfig:$key") ;;
|
||||
esac
|
||||
else
|
||||
# No id recorded, for the same reason CROSS-PROJECT gets none:
|
||||
# `ack` has no arm for a plain "changed git config" line, so a
|
||||
# diff retained for one would be content at rest that nothing
|
||||
# ever reads. The report body already carries the new value.
|
||||
# Suppressible and id-bearing like the CRITICAL arms above.
|
||||
# Both were previously neither, which left this the one tier
|
||||
# of finding with no way to curate it: keys some tools
|
||||
# rewrite on every run (branch.<name>.merge on each push,
|
||||
# submodule.*) re-fired every session and pushed real
|
||||
# WARNINGs out of the capped list (#45).
|
||||
#
|
||||
# Safe by construction rather than by care taken here:
|
||||
# id_severity already calls an exec key or any alias CRITICAL
|
||||
# whatever wrote the entry, and baseline_suppresses refuses a
|
||||
# `spam` entry against a CRITICAL surface at READ time - so a
|
||||
# key that later joins is_exec_key stops being suppressed by
|
||||
# an existing entry, with nothing to revisit.
|
||||
gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
|
||||
baseline_suppresses "$gid" "$gsha" && continue
|
||||
warn+=("changed git config: $body")
|
||||
finding_ids+=("gitconfig:$key")
|
||||
fi
|
||||
done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
|
||||
<(sed -n '/^### gitconfig/,/^### paths/p' "$new" | grep -vE '^### |^worktree:$') )
|
||||
@@ -1259,12 +1317,15 @@ case "$cmd" in
|
||||
# Ascending severity: the operator reads bottom-up from their shell
|
||||
# prompt, so whatever prints LAST is what actually gets seen. INFO
|
||||
# collapses to a count - it exists to be counted, not read.
|
||||
# stderr is this block's stream, so the palette is decided for that
|
||||
# one: redirecting the summary to a file must not put escapes in it.
|
||||
color_init 2
|
||||
{
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
printf '%s================================================================%s\n' "$C_DIM" "$C_RESET"
|
||||
echo " dejima: host-persistence surfaces changed in"
|
||||
echo " $(sanitize "$ws") while this session ran"
|
||||
echo "----------------------------------------------------------------"
|
||||
printf ' %s%s%s while this session ran\n' "$C_PATH" "$(sanitize "$ws")" "$C_RESET"
|
||||
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
|
||||
# Named, never hashed. Writing to a writable mount is the entire
|
||||
# point of configuring one, so a finding per write would fire on
|
||||
# every legitimate use and train the reader to skim - the exact
|
||||
@@ -1278,13 +1339,13 @@ case "$cmd" in
|
||||
echo " ${#info[@]} routine file change(s) - not listed"
|
||||
fi
|
||||
if [ "${#warn[@]}" -gt 0 ]; then
|
||||
printf ' - %s\n' "${warn[@]:0:$WARN_CAP}"
|
||||
printf " $C_WARN-$C_RESET %s\n" "${warn[@]:0:$WARN_CAP}"
|
||||
if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
|
||||
echo " + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
|
||||
fi
|
||||
fi
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
printf ' ! %s\n' "${crit[@]:0:$CRIT_CAP}"
|
||||
printf " $C_CRIT!$C_RESET %s\n" "${crit[@]:0:$CRIT_CAP}"
|
||||
if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
|
||||
echo " !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
|
||||
echo " !! ${#crit[@]} critical findings in one session is ABNORMAL."
|
||||
@@ -1294,12 +1355,14 @@ case "$cmd" in
|
||||
# room to keep evidence is a finding about the session, not a
|
||||
# note about the store.
|
||||
[ "${#budget_note[@]}" -gt 0 ] && printf '%s\n' "${budget_note[@]}"
|
||||
echo "----------------------------------------------------------------"
|
||||
printf '%s----------------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
|
||||
if [ "${#crit[@]}" -gt 0 ]; then
|
||||
echo " Review BEFORE the next 'git' or 'claude' in that repo."
|
||||
echo " dejima: ${#crit[@]} CRITICAL - code that will run on your host."
|
||||
printf ' dejima: %s%s CRITICAL%s - code that will run on your host.\n' \
|
||||
"$C_CRIT" "${#crit[@]}" "$C_RESET"
|
||||
else
|
||||
echo " dejima: no critical findings; ${#warn[@]} change(s) to review."
|
||||
printf ' dejima: %sno critical findings%s; %s change(s) to review.\n' \
|
||||
"$C_OK" "$C_RESET" "${#warn[@]}"
|
||||
fi
|
||||
} >&2
|
||||
exit 0
|
||||
@@ -1317,6 +1380,7 @@ case "$cmd" in
|
||||
exit 0
|
||||
;;
|
||||
ack)
|
||||
color_init 1
|
||||
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
|
||||
bf="$BASELINE_DIR/$(ws_slug "$ws")"
|
||||
report="$(select_report "$report_dir")" \
|
||||
@@ -1339,6 +1403,16 @@ case "$cmd" in
|
||||
# (or a non-path CRITICAL, which can never be "approved" - see
|
||||
# ackable below) must keep its suffix and stay eviction-protected.
|
||||
crit_seen=0; crit_acked=0
|
||||
# Findings passed over because the baseline already covers them, or
|
||||
# because this report has been walked before - counted so a run that
|
||||
# displays nothing can say why instead of looking broken.
|
||||
already=0
|
||||
# Whether this report carries any finding that cannot be
|
||||
# acknowledged, and whether a previous run already walked it to the
|
||||
# end. Together they decide if a `reviewed` line gets written below.
|
||||
unackable_shown=0
|
||||
report_reviewed=0
|
||||
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
|
||||
# The loop below reads report lines on its own stdin (`< "$report"`),
|
||||
# so operator keystrokes are read from fd 3 - a copy of the script's
|
||||
# original stdin taken before that redirection - rather than
|
||||
@@ -1399,22 +1473,51 @@ case "$cmd" in
|
||||
# to stat and hash and for id_severity to classify. Only the
|
||||
# id-bearing portion is decoded - never the namespace prefix
|
||||
# this code adds itself.
|
||||
ackable=0; id=""; rid=""
|
||||
#
|
||||
# $lbl and $surface are for DISPLAY only: the fixed label this
|
||||
# arm matched, and the workspace-named thing inside the body that
|
||||
# the finding is about. Splitting them is what lets the surface
|
||||
# be coloured without colouring the audit's own words around it,
|
||||
# and they are recomputed here rather than derived from $id
|
||||
# because a gitconfig body carries `key=value` while its id is
|
||||
# the key alone.
|
||||
ackable=0; id=""; rid=""; lbl=""; surface=""
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
ackable=1; id="${body#HOST-CODE-EXECUTION workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
|
||||
id="${body#HOST-CODE-EXECUTION workspace file: }"
|
||||
surface="$id"; rid="$(dec_id "$id")" ;;
|
||||
"changed workspace file: "*)
|
||||
ackable=1; id="${body#changed workspace file: }"
|
||||
rid="$(dec_id "$id")" ;;
|
||||
"COMMAND-EXECUTION git config: "*|"COMMAND-EXECUTION git alias: "*)
|
||||
ackable=1; cfgline="${body#*: }"; ekey="${cfgline%%=*}"
|
||||
ackable=1; lbl="changed workspace file: "
|
||||
id="${body#changed workspace file: }"
|
||||
surface="$id"; rid="$(dec_id "$id")" ;;
|
||||
"COMMAND-EXECUTION git config: "*)
|
||||
ackable=1; lbl="COMMAND-EXECUTION git config: "
|
||||
cfgline="${body#COMMAND-EXECUTION git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"COMMAND-EXECUTION git alias: "*)
|
||||
ackable=1; lbl="COMMAND-EXECUTION git alias: "
|
||||
cfgline="${body#COMMAND-EXECUTION git alias: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
# The WARNING form of the two above. Its own arm rather than
|
||||
# another alternative on theirs, because the label it strips
|
||||
# differs and the strip must be of that exact label (#45).
|
||||
"changed git config: "*)
|
||||
ackable=1; lbl="changed git config: "
|
||||
cfgline="${body#changed git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
|
||||
ackable=1; esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
|
||||
surface="$esl"
|
||||
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
|
||||
"TCB (sandbox self) modified: "*)
|
||||
ackable=1; etcb="${body#TCB (sandbox self) modified: }"
|
||||
ackable=1; lbl="TCB (sandbox self) modified: "
|
||||
etcb="${body#TCB (sandbox self) modified: }"
|
||||
surface="$etcb"
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
esac
|
||||
@@ -1424,29 +1527,84 @@ case "$cmd" in
|
||||
# must only ever advance when a real, non-empty id was derived.
|
||||
[ -n "$id" ] || ackable=0
|
||||
[ -n "$rid" ] || ackable=0
|
||||
# Already decided: the baseline holds an approval pinning this
|
||||
# exact content, or an ignore for the surface outright. `compare`
|
||||
# filters these out when it WRITES a report, but a report written
|
||||
# before that entry existed still lists them - and two reports
|
||||
# can list the same finding, so approving it in the first left
|
||||
# the second re-presenting it on every later run, with the
|
||||
# approval sitting in the baseline the whole time. That is what
|
||||
# made repeated `ack` runs show the same CRITICALs forever (#65).
|
||||
#
|
||||
# Counted as covered rather than merely hidden: a CRITICAL whose
|
||||
# content is already approved must let its report shed
|
||||
# -CRITICAL.txt, or the startup warning nags about a report whose
|
||||
# every finding has been dealt with.
|
||||
if [ "$ackable" -eq 1 ] && baseline_suppresses "$rid" "$(current_sha_for_id "$rid")"; then
|
||||
already=$((already + 1))
|
||||
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
|
||||
continue
|
||||
fi
|
||||
# An un-ackable finding in a report already walked to the end.
|
||||
# There is nothing to record for one, so without this it would
|
||||
# re-prompt on every run forever, gating the exit from `ack` on a
|
||||
# keystroke that can never change anything (#65).
|
||||
if [ "$ackable" -ne 1 ] && [ "$report_reviewed" -eq 1 ]; then
|
||||
already=$((already + 1))
|
||||
continue
|
||||
fi
|
||||
[ "$ackable" -eq 1 ] || unackable_shown=1
|
||||
echo ""
|
||||
echo " [$tier] $body"
|
||||
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' [c] skip [q] quit (this finding cannot be acknowledged): '
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' [a] approve this exact content [c] skip [q] quit: '
|
||||
# The tier carries the colour, and the surface the finding is
|
||||
# about is picked out from the label around it - those are the
|
||||
# two things an operator scans a wall of these for.
|
||||
if [ -n "$surface" ]; then
|
||||
tail_rest="${body#"$lbl$surface"}"
|
||||
printf ' %s[%s]%s %s%s%s%s%s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" \
|
||||
"$lbl" "$C_PATH" "$surface" "$C_RESET" "$tail_rest"
|
||||
else
|
||||
printf ' [a] approve [s] always ignore [c] skip [q] quit: '
|
||||
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
|
||||
fi
|
||||
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
|
||||
# Each option says what it does to FUTURE findings, because that
|
||||
# is the whole difference between the two that record something
|
||||
# and it was not visible: approve pins this exact content and
|
||||
# re-fires on any other, ignore blankets the surface whatever it
|
||||
# later contains. The WARNING prompt used to say a bare
|
||||
# "approve", which read as the same thing as "always ignore".
|
||||
#
|
||||
# "file" is wrong for a git config key, and naming the wrong kind
|
||||
# of thing in the sentence that offers a permanent mute is worth
|
||||
# one case.
|
||||
case "$rid" in
|
||||
gitconfig:*) ign_noun="key" ;;
|
||||
*) ign_noun="file" ;;
|
||||
esac
|
||||
k_a="$C_KEY[a]$C_RESET"; k_i="$C_KEY[i]$C_RESET"
|
||||
k_s="$C_KEY[s]$C_RESET"; k_q="$C_KEY[q]$C_RESET"
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_s" "$k_q"
|
||||
else
|
||||
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
|
||||
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
|
||||
fi
|
||||
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
|
||||
case "$key" in
|
||||
q|$'\e'|'') aborted=1; break ;;
|
||||
s)
|
||||
i)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
elif [ "$tier" = CRITICAL ]; then
|
||||
echo " cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."
|
||||
echo " cannot ignore a CRITICAL surface from now on - approve the exact content or skip."
|
||||
else
|
||||
pending+=("spam $id")
|
||||
sc_done+=("$rid")
|
||||
fi
|
||||
;;
|
||||
s) ;;
|
||||
a)
|
||||
if [ "$ackable" -ne 1 ]; then
|
||||
echo " cannot acknowledge: this finding cannot be acknowledged."
|
||||
@@ -1476,12 +1634,22 @@ case "$cmd" in
|
||||
esac
|
||||
done < "$report"
|
||||
|
||||
if [ "$aborted" -eq 1 ] || [ "${#pending[@]}" -eq 0 ]; then
|
||||
# An abort discards everything, the reviewed marker included: the
|
||||
# operator did not finish the report, so it has not been reviewed.
|
||||
if [ "$aborted" -eq 1 ]; then
|
||||
echo ""; echo "dejima audit: nothing recorded."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
printf '%s\n' "${pending[@]}" >> "$bf"
|
||||
# Written once the report has been walked to its end, and only when
|
||||
# it actually showed something that cannot be acknowledged - there is
|
||||
# nothing else to stop those from re-prompting forever.
|
||||
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
|
||||
pending+=("reviewed $(basename "$report")")
|
||||
fi
|
||||
if [ "${#pending[@]}" -gt 0 ]; then
|
||||
mkdir -p "$BASELINE_DIR"
|
||||
printf '%s\n' "${pending[@]}" >> "$bf"
|
||||
fi
|
||||
for rid in ${sc_done[@]+"${sc_done[@]}"}; do
|
||||
rm -f "$sc/$(id_key "$rid")".diff "$sc/$(id_key "$rid")".new \
|
||||
"$sc/$(id_key "$rid")".removed "$sc/$(id_key "$rid")".withheld
|
||||
@@ -1490,12 +1658,30 @@ case "$cmd" in
|
||||
# Only rename off -CRITICAL.txt (making the report evictable by
|
||||
# rotation) when every CRITICAL finding this report carried was
|
||||
# actually approved - not just when something was approved.
|
||||
# Reached even when this run recorded nothing new: a report whose
|
||||
# CRITICALs were all approved through some EARLIER report listing the
|
||||
# same finding is fully dealt with, and has to stop being counted as
|
||||
# unacknowledged or the startup warning nags about it forever.
|
||||
if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
|
||||
case "$report" in
|
||||
*-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""; echo "dejima audit: recorded ${#pending[@]} acknowledgement(s) in $bf"
|
||||
echo ""
|
||||
# Distinguish the three ways a run can end with nothing new, because
|
||||
# "nothing recorded" for all of them is what made an already-handled
|
||||
# report look like a broken tool.
|
||||
acks=0
|
||||
for p in ${pending[@]+"${pending[@]}"}; do
|
||||
case "$p" in reviewed\ *) ;; *) acks=$((acks + 1)) ;; esac
|
||||
done
|
||||
if [ "$acks" -gt 0 ]; then
|
||||
echo "dejima audit: recorded $acks acknowledgement(s) in $bf"
|
||||
elif [ "$already" -gt 0 ]; then
|
||||
echo "dejima audit: nothing new - $already finding(s) already dealt with."
|
||||
else
|
||||
echo "dejima audit: nothing recorded."
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Terminal colour, shared by bin/dejima and bin/dejima-audit.
|
||||
#
|
||||
# Shared rather than duplicated because the two are one surface to the
|
||||
# operator: the launcher's "unacknowledged CRITICAL" warning and the audit
|
||||
# review it sends them to must agree about what red means, or the colour is
|
||||
# decoration instead of information.
|
||||
#
|
||||
# Nothing here decides WHAT to highlight - that belongs with the text. It
|
||||
# decides only whether colour is safe to emit at all, and provides the names.
|
||||
#
|
||||
# Off unless the stream being written to is a terminal, so a redirected
|
||||
# report or a piped test never receives escape sequences: the audit's output
|
||||
# is read back by tests and, in the reports it writes, by future runs.
|
||||
# NO_COLOR (any value, https://no-color.org) and TERM=dumb both turn it off
|
||||
# as well.
|
||||
#
|
||||
# The variables are always DEFINED, empty when colour is off, so callers
|
||||
# interpolate them unconditionally and no call site needs an if.
|
||||
color_init() {
|
||||
local fd="${1:-1}"
|
||||
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
|
||||
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
|
||||
[ -t "$fd" ] || return 0
|
||||
[ -z "${NO_COLOR+x}" ] || return 0
|
||||
case "${TERM:-}" in ''|dumb) return 0 ;; esac
|
||||
C_RESET=$'\033[0m'
|
||||
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
|
||||
C_WARN=$'\033[33m' # yellow
|
||||
C_INFO=$'\033[36m' # cyan
|
||||
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
|
||||
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
|
||||
C_DIM=$'\033[2m' # dim - the audit's own framing around content
|
||||
C_ADD=$'\033[32m' # green - a diff's added lines
|
||||
C_DEL=$'\033[31m' # red - a diff's removed lines
|
||||
C_OK=$'\033[32m' # green
|
||||
}
|
||||
|
||||
# The colour for a tier name, so the mapping lives in one place rather than
|
||||
# at each site that prints one.
|
||||
tier_color() {
|
||||
case "$1" in
|
||||
CRITICAL) printf '%s' "$C_CRIT" ;;
|
||||
WARNING) printf '%s' "$C_WARN" ;;
|
||||
*) printf '%s' "$C_INFO" ;;
|
||||
esac
|
||||
}
|
||||
+4
-2
@@ -11,7 +11,9 @@
|
||||
# dejima-dep add --global cargo:cargo-nextest
|
||||
# dejima-dep list
|
||||
#
|
||||
# A spec is <apt|npm|pip|cargo>:<name>. The dependencies actually installed
|
||||
# A spec is <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url> for a
|
||||
# release archive no package ecosystem ships in a usable version (a current Go
|
||||
# toolchain, say). The dependencies actually installed
|
||||
# for a project are the union of its global and project approvals.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -85,7 +87,7 @@ case "$action" in
|
||||
;;
|
||||
add)
|
||||
[ -n "$spec" ] || usage
|
||||
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>" >&2; exit 1; }
|
||||
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>, or tarball:<name>@<sha256>:<url>" >&2; exit 1; }
|
||||
if [ "$global" -eq 1 ]; then
|
||||
store="$(ensure_global_store)"
|
||||
approve_entry "$store" dependencies "$spec"
|
||||
|
||||
@@ -38,7 +38,49 @@ DEJIMA_MAX_DECLARED=200
|
||||
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
|
||||
# cargo's own signature/lockfile checks.
|
||||
valid_dep_spec() {
|
||||
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
|
||||
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] && return 0
|
||||
valid_tarball_spec "$1"
|
||||
}
|
||||
|
||||
# tarball:<name>@<sha256>:<url> - a release archive unpacked into the image,
|
||||
# for a toolchain no package ecosystem ships in a usable version.
|
||||
#
|
||||
# The digest is mandatory and is what makes this an approval at all. Without
|
||||
# it the operator would be approving a *name* whose contents the other end can
|
||||
# change afterwards, which is not a decision anyone can make once; with it,
|
||||
# what was approved is a specific archive and nothing else can take its place.
|
||||
# The generated build verifies it before unpacking, not after.
|
||||
#
|
||||
# The character classes are narrower than the URL grammar allows, for the same
|
||||
# reason as above: this string is interpolated into a Dockerfile RUN line that
|
||||
# runs as root. Everything shell-active is excluded rather than quoted around
|
||||
# - no $, backtick, quote, backslash, whitespace, or ;|&<>() - so there is no
|
||||
# quoting to get wrong later. https only: the digest already provides
|
||||
# integrity, but there is no reason to also accept a plaintext fetch.
|
||||
DEJIMA_MAX_TARBALL_URL=512
|
||||
valid_tarball_spec() {
|
||||
local spec="$1" rest name sha url
|
||||
[[ "$spec" == tarball:* ]] || return 1
|
||||
rest="${spec#tarball:}"
|
||||
|
||||
name="${rest%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
# Becomes a directory under /opt/dejima; a name that walks out of it, or
|
||||
# that is a relative-path token, is not a name.
|
||||
case "$name" in .|..) return 1 ;; esac
|
||||
|
||||
rest="${rest#"$name"}"
|
||||
[[ "$rest" == @* ]] || return 1
|
||||
rest="${rest#@}"
|
||||
|
||||
sha="${rest%%:*}"
|
||||
[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 1
|
||||
|
||||
url="${rest#"$sha"}"
|
||||
[[ "$url" == :* ]] || return 1
|
||||
url="${url#:}"
|
||||
[ ${#url} -le "$DEJIMA_MAX_TARBALL_URL" ] || return 1
|
||||
[[ "$url" =~ ^https://[A-Za-z0-9._~/?=+%-]+$ ]] || return 1
|
||||
}
|
||||
|
||||
# read_declared <file> <validator-fn>
|
||||
@@ -414,6 +456,40 @@ unapprove_entry() {
|
||||
mv "$tmp" "$store/$kind"
|
||||
}
|
||||
|
||||
# One RUN per tarball dependency, as root, before the drop to the sandbox
|
||||
# user. Each unpacks into its own directory under /opt/dejima and puts that
|
||||
# directory's bin/ on PATH.
|
||||
#
|
||||
# --strip-components=1 is what makes a single convention cover the real cases:
|
||||
# a Go archive has everything under go/, an nvim release under
|
||||
# nvim-linux-<arch>/, and stripping one level puts bin/ in the same place for
|
||||
# both. So the spec needs no destination or layout fields, and there is
|
||||
# nothing per-dependency for an operator to get wrong.
|
||||
#
|
||||
# The digest is checked BEFORE unpacking. A tar that is extracted first and
|
||||
# verified after has already written whatever it contained.
|
||||
#
|
||||
# -xaf rather than -xzf: compression is detected from the archive, so xz and
|
||||
# zstd releases work without the spec having to say which it is.
|
||||
_emit_tarball_layers() {
|
||||
local spec rest name sha url
|
||||
for spec in "$@"; do
|
||||
[ -n "$spec" ] || continue
|
||||
rest="${spec#tarball:}"
|
||||
name="${rest%%@*}"
|
||||
rest="${rest#"$name"@}"
|
||||
sha="${rest%%:*}"
|
||||
url="${rest#"$sha":}"
|
||||
printf 'RUN mkdir -p /opt/dejima/%s && \\\n' "$name"
|
||||
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
|
||||
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
|
||||
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
|
||||
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
|
||||
printf ' rm /tmp/%s.tar\n' "$name"
|
||||
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
|
||||
done
|
||||
}
|
||||
|
||||
# Prints a Dockerfile that layers approved packages onto the shared base
|
||||
# image. Only validated tokens are interpolated (approved_entries refuses
|
||||
# anything else), so a RUN line here can't become a root-context injection.
|
||||
@@ -424,6 +500,7 @@ unapprove_entry() {
|
||||
# init-firewall.sh locks egress down.
|
||||
generate_derived_dockerfile() {
|
||||
local base="$1" store="$2" entries apt npm pip cargo spec name
|
||||
local -a tarballs=()
|
||||
entries="$(approved_entries "$store" dependencies)" \
|
||||
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
|
||||
|
||||
@@ -432,10 +509,11 @@ generate_derived_dockerfile() {
|
||||
[ -n "$spec" ] || continue
|
||||
name="${spec#*:}"
|
||||
case "$spec" in
|
||||
apt:*) apt="$apt $name" ;;
|
||||
npm:*) npm="$npm $name" ;;
|
||||
pip:*) pip="$pip $name" ;;
|
||||
cargo:*) cargo="$cargo $name" ;;
|
||||
apt:*) apt="$apt $name" ;;
|
||||
npm:*) npm="$npm $name" ;;
|
||||
pip:*) pip="$pip $name" ;;
|
||||
cargo:*) cargo="$cargo $name" ;;
|
||||
tarball:*) tarballs+=("$spec") ;;
|
||||
esac
|
||||
done <<< "$entries"
|
||||
|
||||
@@ -443,6 +521,7 @@ generate_derived_dockerfile() {
|
||||
printf 'USER root\n'
|
||||
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
|
||||
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
|
||||
_emit_tarball_layers "${tarballs[@]+"${tarballs[@]}"}"
|
||||
printf 'USER ${SANDBOX_USER}\n'
|
||||
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
|
||||
if [ -n "$cargo" ]; then
|
||||
|
||||
+17
-43
@@ -2,7 +2,14 @@
|
||||
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
|
||||
# substantially extended and reworked for this project's own sandboxing model
|
||||
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
|
||||
# drop, nvim/Go/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
|
||||
# drop, nvim/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
|
||||
#
|
||||
# What belongs in here is what the sandbox itself needs, plus the editor and
|
||||
# browser tooling that has no route through the dependency mechanism. Language
|
||||
# toolchains and per-project utilities do have one - `dejima-dep`, including
|
||||
# tarball: for a release archive - so they are declared by the projects that
|
||||
# want them rather than carried by every sandbox on the host. Anything added
|
||||
# below is added for every project, forever, with no review anywhere.
|
||||
FROM node:22
|
||||
|
||||
ARG TZ
|
||||
@@ -30,7 +37,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
@@ -45,28 +51,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
nano \
|
||||
vim \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
xz-utils \
|
||||
bats \
|
||||
cowsay \
|
||||
fortune-mod \
|
||||
fortunes-min \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
|
||||
# PATH by default - nvim start-screen plugins commonly shell out to them by
|
||||
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
|
||||
# picked up by every process unconditionally, including nvim's non-login
|
||||
# subprocess spawns, which wouldn't source /etc/profile.d.
|
||||
ENV PATH=/usr/games:$PATH
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
@@ -97,13 +90,6 @@ RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
@@ -130,27 +116,15 @@ RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-ti
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
# Install the latest stable Go toolchain. go.dev's own release JSON gives
|
||||
# both the download URL and a SHA256 per file, verified below since this
|
||||
# runs as root - same pattern as gosu above. `go build`/`go mod download`
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime, and those are
|
||||
# NOT built into the allowlist - a Go project approves them itself, one
|
||||
# `dejima-allow --global <domain>` each, or a .dejima/allowed-domains line.
|
||||
# Shipping the toolchain is not the same decision as granting every sandbox on
|
||||
# the host reach to its module proxy.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
# No Go toolchain here. A Go project declares one:
|
||||
#
|
||||
# dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
|
||||
#
|
||||
# and gets a pinned toolchain in the version it actually wants, rather than
|
||||
# whatever this image was built with. Shipping a language runtime is not the
|
||||
# same decision as every sandbox on the host carrying one - and the module
|
||||
# proxy it needs (proxy.golang.org, sum.golang.org) is an approval too, for
|
||||
# the same reason.
|
||||
|
||||
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
|
||||
# Playwright, installed here since it needs apt/root - the actual
|
||||
|
||||
@@ -252,6 +252,41 @@ fi
|
||||
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
|
||||
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
# Remote access is opt-in and off by default, so this whole block is skipped
|
||||
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
|
||||
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
|
||||
# says nothing.
|
||||
#
|
||||
# Set-but-unusable is different, and it is worth a line at startup because of
|
||||
# how it otherwise presents. The marker directory is a bind mount whose source
|
||||
# bin/dejima creates per launch and removes on exit, so restarting the
|
||||
# container - `docker start`, or a restart policy - rather than relaunching
|
||||
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
|
||||
# not to refuse: it creates one, root-owned. The directory then exists, so the
|
||||
# notify hook's own `[ -d ]` guard passes and every notification fails with
|
||||
# EACCES instead; the session is also absent from `dejima-attach`, because its
|
||||
# registry entry went with the same directory. Three unrelated-looking symptoms
|
||||
# and no obvious moment they started.
|
||||
#
|
||||
# Reported, not repaired. Chowning this would restore notifications while the
|
||||
# session stayed missing from the registry - which bin/dejima writes host-side
|
||||
# and never mounts here - so a session would look fixed while half of the
|
||||
# feature was still broken. Relaunching is the actual remedy, and it is one the
|
||||
# message can name.
|
||||
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
|
||||
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
|
||||
echo "Notifications will not be delivered for this session." >&2
|
||||
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
|
||||
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
|
||||
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
|
||||
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
|
||||
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
|
||||
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
|
||||
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test harness hook (test/): everything above this point is the real
|
||||
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
|
||||
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
|
||||
|
||||
@@ -46,3 +46,33 @@ GITHUB_DNS_SUFFIXES=(
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
|
||||
# Which ipset an allowlisted name's addresses belong in.
|
||||
#
|
||||
# GitHub's are restricted to port 443 by init-firewall.sh, and that
|
||||
# restriction is the read-only guarantee - there is no transport left for the
|
||||
# forwarded ssh-agent's key to push with. allowed-domains carries no port
|
||||
# restriction at all, so an address that reaches it is reachable on 22 as
|
||||
# well, and a GitHub address that reaches it silently undoes the whole
|
||||
# arrangement.
|
||||
#
|
||||
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
|
||||
# ipset= line, so GitHub names go to github-domains however they were
|
||||
# allowlisted. Every other path has to ask, and two did not - the
|
||||
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
|
||||
# both added unconditionally to allowed-domains. A project with github.com in
|
||||
# its .dejima/allowed-domains therefore opened port 22 to whichever address
|
||||
# GitHub returned at container start, for as long as that answer stayed
|
||||
# cached. It presented as an intermittent startup failure, because the
|
||||
# port-22 self-check re-resolves the name and only sees the hole when the
|
||||
# second answer matches the first.
|
||||
ipset_for_domain() {
|
||||
local domain="$1" suffix
|
||||
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
|
||||
printf 'github-domains'
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf 'allowed-domains'
|
||||
}
|
||||
|
||||
+74
-6
@@ -76,10 +76,13 @@ else
|
||||
echo "No Docker DNS rules were present to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. DNS resolution goes entirely through Docker's embedded
|
||||
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
|
||||
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
|
||||
# would just be an unrestricted-destination exfil channel.
|
||||
# Allow localhost. Every DNS query this container makes starts here: to
|
||||
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
|
||||
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
|
||||
# after that, where the embedded resolver forwards an external name to the
|
||||
# host's nameservers - see the owner-matched rule further down, which is
|
||||
# there because that hop can leave this namespace. Still no "UDP/53 to
|
||||
# anywhere" rule, which would be an unrestricted-destination exfil channel.
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
@@ -269,13 +272,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Not always allowed-domains: that set carries no port restriction,
|
||||
# so a GitHub address landing in it is reachable on 22 as well and the
|
||||
# read-only guarantee is gone. See ipset_for_domain.
|
||||
target_set=$(ipset_for_domain "$domain")
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "Adding $ip for $domain"
|
||||
ipset add allowed-domains "$ip" 2>/dev/null || true
|
||||
echo "Adding $ip for $domain (-> $target_set)"
|
||||
ipset add "$target_set" "$ip" 2>/dev/null || true
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
fi
|
||||
@@ -305,6 +312,40 @@ iptables -P OUTPUT DROP
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Docker's embedded resolver does not answer external names itself - it
|
||||
# forwards them to the host's nameservers, and on current Docker it does that
|
||||
# from inside THIS network namespace. Those queries therefore traverse the
|
||||
# chain below like any other packet, and nothing here can match them: the
|
||||
# host's resolver is neither in an ipset nor the bridge gateway. They reach
|
||||
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
|
||||
# dies in a self-check that says only that a host was unreachable.
|
||||
#
|
||||
# The comment on the loopback rule above used to claim DNS "stays on
|
||||
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
|
||||
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
|
||||
# property of any host's configuration and cannot be assumed either way.
|
||||
#
|
||||
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
|
||||
# exists to close. The forwarding socket belongs to dockerd, which is root;
|
||||
# the session runs as `node` and there is no sudo in the image, so a
|
||||
# compromised session still cannot address a resolver directly and still has
|
||||
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
|
||||
# is DNS from processes that are already root in a container holding
|
||||
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
|
||||
# nothing that was not already available.
|
||||
#
|
||||
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
|
||||
# should leave a container that says why (the DNS self-check below), not one
|
||||
# that aborts here with an iptables usage error.
|
||||
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
|
||||
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|
||||
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
|
||||
else
|
||||
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
|
||||
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
|
||||
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
|
||||
fi
|
||||
|
||||
# Then allow only specific outbound traffic to allowed domains. This isn't
|
||||
# port-restricted, so it also covers git-over-ssh (port 22) to the
|
||||
# configured forge's hosts, which are already in the ipset above - no
|
||||
@@ -340,6 +381,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
|
||||
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||
|
||||
echo "Firewall rules applied. Self-checking..."
|
||||
|
||||
# DNS before either connectivity check, because both of those resolve a name
|
||||
# before they connect and neither can tell you that resolution is what failed.
|
||||
# The negative control is the trap: with DNS dead it fails for the wrong
|
||||
# reason and prints "example.com is unreachable, as it should be", so a
|
||||
# container with no working DNS at all reports a passing check and then dies
|
||||
# on the positive control naming a host rather than the cause. Reading that
|
||||
# output backwards took an afternoon; it should take one line.
|
||||
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
|
||||
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
|
||||
echo >&2
|
||||
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
|
||||
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
|
||||
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
|
||||
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
|
||||
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
|
||||
echo >&2
|
||||
echo " dig said: ${dns_probe:-(empty answer)}" >&2
|
||||
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
|
||||
iptables -L OUTPUT -vn 2>/dev/null \
|
||||
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
|
||||
echo " dnsmasq's last words:" >&2
|
||||
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "self-check ok - DNS resolves an allowlisted name"
|
||||
|
||||
# --max-time as well as --connect-timeout on both self-checks: a connect that
|
||||
# succeeds and then stalls is not covered by --connect-timeout, and while these
|
||||
# two sit inside `if` (so set -e cannot take the script down the way it could
|
||||
|
||||
+216
-63
@@ -6,6 +6,10 @@
|
||||
CI runs.** That is the whole of it, and everything below is either why it
|
||||
cannot be gated or what to do about it instead.
|
||||
|
||||
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
|
||||
> is a self-contained tool that does it — installed on the CI box, never on a
|
||||
> dejima host, and it does not assume you have read this.
|
||||
|
||||
The chain needs no operator action at any step. A session writes code; the
|
||||
push guard permits a feature branch, which is what it exists to allow; the
|
||||
session opens a pull request, which its briefing tells it to do without asking
|
||||
@@ -24,6 +28,15 @@ itself. The sandbox bounds what a session can reach directly. It does not
|
||||
bound what it can arrange to have run elsewhere, and CI is the shortest path
|
||||
elsewhere.
|
||||
|
||||
**And it applies to projects with no CI at all.** A branch can add the first
|
||||
workflow, so what matters is not what a repository's workflows do today but
|
||||
which runners its branches can reach — which is a property of the forge, not
|
||||
of the project. If you read one section of this document before pointing a
|
||||
sandbox at something, make it
|
||||
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
|
||||
it is a question to answer per project, and for a project on hosted runners
|
||||
the answer is reassuring.
|
||||
|
||||
## What a run actually adds
|
||||
|
||||
The code CI executes is code the session already had. Relative to the sandbox,
|
||||
@@ -85,6 +98,20 @@ this the untrusted party can edit the thing that is supposed to constrain it.
|
||||
|
||||
This costs nothing and it is the single highest-value item in this document.
|
||||
|
||||
**Branch protection needs a second identity to protect anything.** By default
|
||||
a session authenticates as you, so a merge whitelist, a push whitelist and an
|
||||
approval requirement all admit the sandbox exactly as they admit you — and
|
||||
required approvals are unusable outright, since a forge will not let a pull
|
||||
request's author approve it and the session opens them as you. Giving the
|
||||
sandbox its own forge account is what turns those settings from decoration
|
||||
into enforcement, and it is configuration rather than a missing feature:
|
||||
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
|
||||
|
||||
Note that it does not help with the rest of this document. A write-capable
|
||||
account still pushes feature branches, which is what makes CI run code, and
|
||||
the token the forge injects into a job is a third principal subject to none of
|
||||
those settings.
|
||||
|
||||
## The runner host
|
||||
|
||||
Treat it as what it is: a machine that exists to run untrusted code, because
|
||||
@@ -98,10 +125,13 @@ that is what it does on every pull request.
|
||||
and start receiving jobs, including their secrets.
|
||||
- **Rebuilt from a snapshot on a schedule**, so that persistence on it has a
|
||||
bounded lifetime rather than an indefinite one.
|
||||
- **Register runners at repository scope**, not instance scope, so an
|
||||
unrelated repository cannot schedule work onto a runner you hardened for
|
||||
this one. Gitea's own FAQ describes gitea.com doing this for its public
|
||||
runners.
|
||||
- **Register runners at repository or organization scope**, not instance
|
||||
scope. This matters in both directions, and the second is the one that bites:
|
||||
an unrelated repository cannot schedule work onto a runner you hardened for
|
||||
this one, *and* this repository cannot schedule work onto a runner nobody
|
||||
hardened. An instance-level runner is reachable from every repository on the
|
||||
instance — see "A label is not a boundary" below. Gitea's own FAQ describes
|
||||
gitea.com doing this for its public runners.
|
||||
|
||||
Two runners on one VM give you label tiering, not host separation: a job that
|
||||
cannot get the Docker socket still shares a kernel and a network with a runner
|
||||
@@ -141,20 +171,162 @@ rather than leaving at their defaults are `docker_host: "-"`, an empty
|
||||
`valid_volumes` (the default, but worth stating so an edit has to be
|
||||
deliberate), `privileged: false`, a fixed `network` (below), `capacity: 1`,
|
||||
`cache.enabled: false`, and a label nothing else carries.
|
||||
dejima-ci-hardener ships both configs, with the reasoning inline.
|
||||
|
||||
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
|
||||
runner often exists precisely to build images, and taking its socket away
|
||||
breaks it. Register a second runner with a label of its own, and name that
|
||||
label in the workflow. Jobs then cannot land anywhere else — and if no runner
|
||||
carries the label yet, they queue rather than running somewhere that grants
|
||||
everything, which is a better failure than a green run on the wrong host.
|
||||
label in the workflow — and if no runner carries the label yet, the job queues
|
||||
rather than running somewhere unprepared, which is a better failure than a
|
||||
green run on the wrong host.
|
||||
|
||||
On capabilities: `--cap-drop=ALL` is the reflex and it breaks `apt-get`, which
|
||||
needs `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID` and `SETGID` to unpack a
|
||||
package. Drop what the job demonstrably does not need instead of dropping
|
||||
everything and discovering the list by failure, and pair whatever you drop
|
||||
with `--security-opt=no-new-privileges`, which costs nothing and does not
|
||||
depend on getting the list right.
|
||||
### A label is not a boundary
|
||||
|
||||
That last paragraph describes routing, not containment, and the difference
|
||||
matters enough to state on its own.
|
||||
|
||||
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
|
||||
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
|
||||
on the runner you prepared; it does nothing against a branch that would rather
|
||||
run somewhere else. A session that changes `runs-on: dejima-ci` to
|
||||
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
|
||||
every control on this page — no allowlist, no socket restriction, no firewall
|
||||
— provided some runner answering `ubuntu-latest` will take the job.
|
||||
|
||||
Whether one will is a property of **where your runners are registered**, which
|
||||
is the actual boundary:
|
||||
|
||||
| Registered at | Which repositories it serves |
|
||||
| --- | --- |
|
||||
| Instance | **every repository on the instance** |
|
||||
| Organization | that organization's repositories |
|
||||
| Repository | that repository |
|
||||
|
||||
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
|
||||
at registration scope as the control instead: gitea.com registers its runners
|
||||
for one organization "so our runners will not execute jobs from other
|
||||
repositories."
|
||||
|
||||
So the property to hold onto is this: **a repository's CI security posture is
|
||||
that of the weakest runner it can reach, not the one its workflow names.** An
|
||||
instance-level general-purpose runner is reachable from every repository on
|
||||
that instance, including any that takes agent-authored branches.
|
||||
|
||||
### Pinning the workflow with `pull_request_target`
|
||||
|
||||
Half of this is fixable, and worth fixing: **`runs-on` does not have to come
|
||||
from the branch.**
|
||||
|
||||
Under `on: pull_request` the workflow is read from the head of the pull
|
||||
request, so the branch decides what runs and where. Under
|
||||
`on: pull_request_target` Gitea reads it from the **base branch**, and
|
||||
explicitly discards any `pull_request_target` workflow found on the head
|
||||
commit — a branch can neither edit the base copy nor introduce its own. What
|
||||
runs, and on which runner, is whatever `main` says.
|
||||
|
||||
**Adopting it takes two merges, not one.** The trigger only takes effect once
|
||||
it is on the base branch, because that is where it is read from — and a
|
||||
`pull_request_target` workflow on a pull request's head is explicitly ignored.
|
||||
So the change that introduces it cannot be tested by it: with only
|
||||
`pull_request_target` on the branch and only `pull_request` on `main`, the
|
||||
head's copy is skipped, the base has nothing to contribute, and **no checks
|
||||
run at all** — which looks like a broken workflow rather than a bootstrap
|
||||
problem.
|
||||
|
||||
Carry both triggers through the first merge, then remove `pull_request` in a
|
||||
follow-up. The first change is tested by its own copy from the head, as
|
||||
today; the follow-up is tested by the copy now on `main`. In between, pull
|
||||
requests get both an untrusted run from the head and a trusted one from the
|
||||
base — noisy, and for the untrusted half no worse than the status quo, so
|
||||
keep the window short.
|
||||
|
||||
Two more consequences to handle rather than discover:
|
||||
|
||||
- **The checkout no longer defaults to the code under test.** The run's ref is
|
||||
the base branch, so a bare `actions/checkout` fetches `main` and the suite
|
||||
passes without ever seeing the change. Jobs that test the branch must name
|
||||
it: `ref: ${{ github.event.pull_request.head.sha }}`. Jobs that assert on
|
||||
the *runner* should keep the bare checkout, so their assertions come from
|
||||
`main` and a branch can neither weaken nor delete them.
|
||||
- **The job runs with the base repository's token and secrets**, because the
|
||||
forge treats it as trusted. On a forge where a same-repo `pull_request` run
|
||||
already gets a permissive token this changes nothing, but it is why the
|
||||
pattern carries warnings elsewhere: give such a run no secret you would not
|
||||
hand the branch.
|
||||
|
||||
This does not make the repository safe on its own. A branch can still add a
|
||||
workflow of its own with `on: pull_request`, which runs from the head with
|
||||
whatever `runs-on` it likes. What it buys is that the *intended* CI is
|
||||
trustworthy, and that escaping it means adding a file rather than editing
|
||||
one — a much louder diff.
|
||||
|
||||
### Closing the rest: what a repository can reach
|
||||
|
||||
The rule underneath everything here:
|
||||
|
||||
> **Every runner reachable from a repository that receives agent-authored
|
||||
> branches must be one you would accept an attacker running code on.**
|
||||
|
||||
Note what that does *not* say. It says nothing about what the repository's
|
||||
workflows currently do, because a branch can add the first one. **A project
|
||||
with no CI at all is still exposed**, on a forge where its branches can reach
|
||||
a runner — and that is the case people do not expect, because there is nothing
|
||||
in the repository to look at.
|
||||
|
||||
So before pointing a sandbox at a project, ask one question: *what runners can
|
||||
this repository's branches reach?*
|
||||
|
||||
**If the answer is "hosted runners only"** — GitHub-hosted and nothing
|
||||
self-hosted attached — this is the mildest case, and it is worth saying so
|
||||
rather than alarming people. The runner is disposable and is not your machine,
|
||||
so "execution on a host you own" largely evaporates: there is no LAN behind
|
||||
it and nothing of yours on it. What remains is the third item, credentials:
|
||||
give pull-request runs no secrets they do not need and a read-only token
|
||||
(`permissions: contents: read`), and do not attach a self-hosted runner to
|
||||
that repository.
|
||||
|
||||
**If self-hosted runners are reachable**, that is the sharp case, and there are
|
||||
three ways to hold the rule — none of which require moving or renaming
|
||||
anything.
|
||||
|
||||
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
|
||||
Actions unit means no workflow runs for that repository at all, so no runner is
|
||||
reachable and a branch cannot add the first workflow. Changing it back needs
|
||||
*admin* on the repository — Gitea's API puts repository settings behind
|
||||
`reqAdmin()` — so a bot account with Write cannot undo it. For a project that
|
||||
was not using CI anyway this costs nothing and closes the vector outright. It
|
||||
is the first thing to reach for and the most commonly overlooked.
|
||||
|
||||
**2. Scope the dangerous runner down, rather than moving the repository out.**
|
||||
The instinct is to move agent-developed repositories somewhere with only
|
||||
hardened runners. For a public project that means breaking its name, its
|
||||
links and its ownership, which nobody should be asked to do.
|
||||
|
||||
Invert it. A runner can belong to an **owner** — a user as well as an
|
||||
organization — and then serves that owner's repositories; Gitea's runner model
|
||||
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
|
||||
level (`OwnerID` set), repository level (`RepoID` set). So:
|
||||
|
||||
- Make the *hardened* runner the owner-level default, so every repository gets
|
||||
it.
|
||||
- Register the *permissive* runner — the one with the Docker socket, or with
|
||||
unrestricted egress — at **repository** scope, for only the repositories
|
||||
that genuinely need it.
|
||||
|
||||
Most repositories need a runner that can run tests, not one that can build
|
||||
images. Scoping the dangerous capability to the few that need it is usually a
|
||||
handful of registrations, and nothing moves or is renamed.
|
||||
|
||||
**3. Do not grant push.** `[core] forge = none` with
|
||||
`[core] workflow = local-only`. The session commits locally and stops; you
|
||||
review the diff and push it yourself. No branch, no workflow, no runner, and
|
||||
the vector is closed completely rather than bounded. The cost is one push
|
||||
after the review you were going to do anyway — and it is the only option here
|
||||
that does not depend on getting a forge's configuration right.
|
||||
|
||||
If none of the three is available for a given project, that is worth knowing
|
||||
plainly: pointing a sandbox at it means accepting that a compromised session
|
||||
can run code on whatever that repository can reach.
|
||||
|
||||
## Egress
|
||||
|
||||
@@ -185,76 +357,49 @@ image pulls and action fetches happen from the host rather than from the job.
|
||||
For a suite that installs a few packages and checks out a repository, the job
|
||||
needs the forge and a package mirror, and nothing else.
|
||||
|
||||
### A fixed network for jobs
|
||||
### A fixed network for jobs, a resolver, and both firewall chains
|
||||
|
||||
Point `container.network` at a network you created, with a named bridge, so
|
||||
that the interface name, subnet and gateway are stable enough to write rules
|
||||
against. Left empty, `act_runner` creates a network per job and there is no
|
||||
stable interface to match on.
|
||||
|
||||
```sh
|
||||
docker network create \
|
||||
--opt com.docker.network.bridge.name=dejima-ci0 \
|
||||
--subnet 203.0.113.0/24 \
|
||||
dejima-ci
|
||||
```
|
||||
|
||||
(That subnet is a documentation range, per RFC 5737. Substitute a private
|
||||
range that does not collide with anything you route to.)
|
||||
stable interface to match on. Give it a **private** subnet: `stop-dns-rebind`
|
||||
below rejects answers in the private ranges, so a reserved-but-not-private
|
||||
bridge subnet is one that check silently does not cover.
|
||||
|
||||
With `runner.capacity: 1` one job occupies that network at a time. Above one,
|
||||
concurrent jobs share a broadcast domain and can reach each other's service
|
||||
containers, which is worth knowing before raising it.
|
||||
|
||||
### DNS and the firewall
|
||||
|
||||
The shape is the same one dejima uses inside its own container
|
||||
(`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one layer out:
|
||||
allowlist the *names* and let the answers populate the set of allowed
|
||||
*addresses*.
|
||||
The DNS and firewall shape is the same one dejima uses inside its own
|
||||
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
|
||||
layer out: allowlist the *names* and let the answers populate the set of
|
||||
allowed *addresses*.
|
||||
|
||||
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
|
||||
per allowed name, every other name gets `REFUSED` — not resolved-then-blocked,
|
||||
refused, which closes off encoding data into query names to a domain you never
|
||||
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
|
||||
set, so a destination that rotates its edge addresses stays reachable without
|
||||
a resolver cron chasing it.
|
||||
a resolver cron chasing it. `rebind-domain-ok` exempts a self-hosted forge,
|
||||
which legitimately resolves to a private address.
|
||||
|
||||
```
|
||||
no-resolv
|
||||
server=/deb.debian.org/198.51.100.53
|
||||
ipset=/#/ci-allow
|
||||
rebind-domain-ok=/gitea.example.com/
|
||||
```
|
||||
The runner pins its job containers at that resolver - per-runner rather than
|
||||
daemon-wide, because a daemon-wide setting also reaches containers a
|
||||
privileged job creates over the Docker socket, which have firewalls of their
|
||||
own and no route to it. Then two chains filter: `DOCKER-USER` for everything
|
||||
the job forwards off the bridge, and `INPUT` for the resolver itself, because
|
||||
the gateway address belongs to the host.
|
||||
|
||||
`rebind-domain-ok` matters for a self-hosted forge: `stop-dns-rebind` rejects
|
||||
private answers, and an internal forge legitimately has one.
|
||||
|
||||
Point the daemon at that resolver so containers inherit it:
|
||||
|
||||
```json
|
||||
{ "dns": ["203.0.113.1"] }
|
||||
```
|
||||
|
||||
Then filter, remembering both chains:
|
||||
|
||||
```sh
|
||||
# Job egress: everything forwarded off the bridge passes here first.
|
||||
iptables -I DOCKER-USER -i dejima-ci0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A DOCKER-USER -i dejima-ci0 -m set --match-set ci-allow dst -p tcp -m multiport --dports 80,443 -j ACCEPT
|
||||
iptables -A DOCKER-USER -i dejima-ci0 -j REJECT
|
||||
|
||||
# The resolver lives on the host, so it is INPUT, not FORWARD.
|
||||
iptables -A INPUT -i dejima-ci0 -p udp --dport 53 -j ACCEPT
|
||||
iptables -A INPUT -i dejima-ci0 -p tcp --dport 53 -j ACCEPT
|
||||
iptables -A INPUT -i dejima-ci0 -j REJECT
|
||||
```
|
||||
|
||||
Block IPv6 outright unless you have built the same allowlist for it. An
|
||||
Block IPv6 outright unless you have built the same allowlist for it — an
|
||||
address family with no allowlist is not a smaller hole than the one you just
|
||||
closed.
|
||||
|
||||
### The allowlist comes from `main`, not from the checkout
|
||||
**The working configuration for all of this is in [`ci/`](../ci/), and
|
||||
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
|
||||
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
|
||||
the one with the ordering constraints and the substitution notes attached.
|
||||
|
||||
## The allowlist comes from `main`, not from the checkout
|
||||
|
||||
A dejima project already carries a version-controlled statement of where it is
|
||||
allowed to reach: `.dejima/allowed-domains`. Use it — read from
|
||||
@@ -280,6 +425,10 @@ a fetch that errors should leave the previous allowlist in place, never fall
|
||||
back to allowing everything — an allowlist builder is exactly the kind of
|
||||
script whose failure mode is easy to get backwards, because the broken version
|
||||
looks like a working one right up until it matters.
|
||||
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
|
||||
that costs you here: a repository with no declaration is a 404 and ordinary,
|
||||
while a 401 or a 500 means this run does not know what the allowlist is and
|
||||
must not render one.
|
||||
|
||||
Two things to be deliberate about:
|
||||
|
||||
@@ -361,6 +510,10 @@ can still add a workflow of its own.
|
||||
- **Code still runs.** Everything here bounds *where* and *with what*, not
|
||||
*whether*. There is no point in the flow at which a human reads the diff
|
||||
before execution.
|
||||
- **A repository with no workflows is not therefore safe.** A branch can add
|
||||
one, so the exposure follows from which runners the repository can reach,
|
||||
not from what it currently runs. Disabling the Actions unit is what makes
|
||||
"this project has no CI" into a property rather than a coincidence.
|
||||
- **An allowed destination is still an exfiltration channel** through its own
|
||||
request surface — a query string, a lookup, an issue body. Domain
|
||||
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
|
||||
|
||||
@@ -47,6 +47,96 @@ push grant declines the code execution with it — `[core] forge = none` with
|
||||
This applies to any project developed in a dejima sandbox, not only to this
|
||||
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
|
||||
|
||||
## To your forge, the sandbox is you
|
||||
|
||||
By default a session authenticates with your credentials: your SSH key through
|
||||
the forwarded agent, and a token generated under your account. A push, a pull
|
||||
request, a comment or a merge made from inside the sandbox is therefore
|
||||
**indistinguishable at the forge from one you made yourself** — not similar to
|
||||
it, identical.
|
||||
|
||||
The consequence is worth stating flatly, because it is easy to configure
|
||||
around without noticing: **every control your forge offers that keys on
|
||||
identity is a guard rather than a boundary.** Branch protection's merge
|
||||
whitelist, its push whitelist, required approvals, and the audit log all
|
||||
answer the question "who did this", and there is only one answer available.
|
||||
Putting yourself on a merge whitelist puts the sandbox on it too.
|
||||
|
||||
Three specific consequences:
|
||||
|
||||
- **Required approvals are not merely weak here, they are unusable.** A forge
|
||||
will not let a pull request's author approve it, and the sandbox opens pull
|
||||
requests as you — so requiring one approval means nobody can give it.
|
||||
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
|
||||
session pushed this" are the same recorded event. This is the same shape as
|
||||
the exit-time audit's time-window caveat above: a record of what happened,
|
||||
not of who caused it.
|
||||
- **It compounds the SSH agent's reach.** The agent already signs for any repo
|
||||
your key can reach (below); the identity is what makes the resulting action
|
||||
land as yours everywhere it reaches.
|
||||
|
||||
### Give the sandbox its own identity
|
||||
|
||||
This is configuration, not a missing feature. Create a dedicated forge account
|
||||
with **write** access to the repositories a session should work on, and point
|
||||
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 the README.
|
||||
|
||||
With the two identities separated, the same settings stop being decorative:
|
||||
|
||||
| Setting | What it becomes |
|
||||
| --- | --- |
|
||||
| Merge whitelist: you | The session genuinely cannot merge |
|
||||
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
|
||||
| Require approvals ≥ 1 | Usable — the session authors, you approve |
|
||||
|
||||
That last row is the one that turns "the operator merges, the agent does not"
|
||||
from a convention into something the forge enforces.
|
||||
|
||||
**Verify which credential a push actually uses.** Nothing here enforces one:
|
||||
`[core] ssh_key` is a second, independent credential path (below), so if your
|
||||
own agent is *also* forwarded, a push may still be signed by your personal
|
||||
key and the separate identity becomes decorative for exactly the operation it
|
||||
was meant to constrain. Make a test push and read what the forge attributes
|
||||
it to, rather than assuming.
|
||||
|
||||
**Write access, and specifically not ownership.** Add the account as a
|
||||
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.
|
||||
|
||||
One consequence is worth knowing before you meet it, because it presents as a
|
||||
misconfiguration and the obvious fix is the wrong one. Parts of Gitea's API
|
||||
are gated on *ownership* rather than on permission: `GET
|
||||
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
|
||||
the owner of the repo"` no matter which scopes the token carries. Read that
|
||||
way it looks like a scope error, and the reflex is to widen the token — which
|
||||
cannot work, since no scope grants ownership, and whose only real remedy is
|
||||
handing the sandbox your own token. That would undo this whole section.
|
||||
|
||||
`gitea-actions` therefore reads CI through `actions/tasks`, the commit's
|
||||
statuses and the run view's log route, none of which are owner-gated. If some
|
||||
future need lands on an owner-only endpoint, leaving that capability
|
||||
unavailable is the better trade than promoting the account.
|
||||
|
||||
Two things a separate identity does **not** do:
|
||||
|
||||
- It does not reduce what a compromised session can do inside the container,
|
||||
or to the mounted project. It changes what that session can do *to your
|
||||
forge*, and who the forge thinks did it.
|
||||
- It does not touch CI. A write-capable account can still push a feature
|
||||
branch, and pushing a branch is what makes CI execute code — see the
|
||||
section above. It also has no bearing on the token the forge injects into a
|
||||
CI job, which is a third principal again, subject to none of the settings in
|
||||
that table. **→ [docs/ci.md](ci.md)**
|
||||
|
||||
## Provider trust
|
||||
|
||||
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
|
||||
|
||||
+110
-43
@@ -10,6 +10,24 @@
|
||||
# Its reason to exist is a sandboxed agent's: CI is where the tests that
|
||||
# cannot run in the sandbox actually run, and without this the only way to
|
||||
# see why one failed is for the operator to paste the log in by hand.
|
||||
#
|
||||
# Nothing here touches /repos/{owner}/{repo}/actions/runs, or the job and
|
||||
# log endpoints reached through it, however natural they look. Gitea gates
|
||||
# that group behind reqOwner(): a token belonging to a dedicated non-owner
|
||||
# account - which is what a sandbox should be given - is refused with "user
|
||||
# should be the owner of the repo", and no scope grants ownership, so
|
||||
# widening the token cannot fix it. The three sources used instead need only
|
||||
# read access to the repo:
|
||||
#
|
||||
# actions/tasks one record per job, carrying its run number,
|
||||
# head_sha, event and result
|
||||
# commits/<sha>/statuses the same jobs as commit statuses, and the only
|
||||
# place a job's index is visible
|
||||
# the web log route the run view's own endpoint, which takes the
|
||||
# same token header as the API
|
||||
#
|
||||
# The job index matters because it is what addresses a log, and it is not a
|
||||
# field anywhere - only the tail of a status's target_url.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
@@ -99,57 +117,81 @@ function gitea_owner_repo() {
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
# The API addresses runs by an internal id; every URL a human has shows the
|
||||
# per-repo run number instead, and the two are unrelated. Everything below
|
||||
# takes the number and resolves it here, against one page of the run list.
|
||||
#
|
||||
# With no number, HEAD's commit: a run list is not what someone who just
|
||||
# pushed is asking for. Matched on head_sha rather than on branch because a
|
||||
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
|
||||
# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
|
||||
# costs as many records as it has jobs, and `limit` is ignored unless `page`
|
||||
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
|
||||
# workflow; a lookup that falls off the end says which it was.
|
||||
function fetch_tasks() {
|
||||
api_or_die "list runs" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
|
||||
tasks_json="$json"
|
||||
}
|
||||
|
||||
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
|
||||
# is not what someone who just pushed is asking for. Matched on head_sha
|
||||
# rather than on branch because a pull_request run reports no branch at all -
|
||||
# its ref is refs/pull/N/head.
|
||||
function resolve_run() {
|
||||
local want="${1:-}" sha
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
|
||||
fetch_tasks
|
||||
if [ -n "$want" ]; then
|
||||
run_id=$(echo "$json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
|
||||
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
|
||||
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
|
||||
if [ -z "$run_sha" ]; then
|
||||
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_number="$want"
|
||||
else
|
||||
sha=$(git rev-parse HEAD 2>/dev/null) || {
|
||||
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
|
||||
exit 1
|
||||
}
|
||||
run_id=$(echo "$json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
|
||||
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
|
||||
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
|
||||
if [ -z "$run_number" ]; then
|
||||
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_sha="$sha"
|
||||
fi
|
||||
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
|
||||
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
|
||||
}
|
||||
|
||||
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
|
||||
# tail of the job's html_url, not a field of its own, and the list does not
|
||||
# arrive in that order.
|
||||
# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
|
||||
# place a job's index is visible to a token that does not own the repo.
|
||||
#
|
||||
# A status is written more than once per job (queued, then finished) and the
|
||||
# list arrives newest first, so the newest per context wins. `context` is
|
||||
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
|
||||
# display, and matching keeps the whole string so either form works. Filtered
|
||||
# by run number as well as by commit, since a re-run puts a second run's
|
||||
# statuses on the same commit.
|
||||
function fetch_jobs() {
|
||||
api_or_die "list jobs for run $run_number" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/runs/${run_id}/jobs"
|
||||
jobs_json=$(echo "$json" | jq '[.jobs[] | {
|
||||
index: (.html_url | split("/") | last | tonumber),
|
||||
id, name,
|
||||
result: (if .status == "completed" then .conclusion else .status end)
|
||||
}] | sort_by(.index)')
|
||||
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses?page=1&limit=100"
|
||||
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
|
||||
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
|
||||
| group_by(.context) | map(first)
|
||||
| map({
|
||||
index: (.target_url | split("/") | last | tonumber),
|
||||
name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
|
||||
context,
|
||||
result: .status
|
||||
})
|
||||
| sort_by(.index)')
|
||||
if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
|
||||
echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
|
||||
echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
|
||||
echo "commit has none for that run - so there is nothing to address a log by." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function print_job_log() {
|
||||
local id="$1" name="$2" result="$3"
|
||||
local index="$1" name="$2" result="$3"
|
||||
echo "=== $name ($result) ==="
|
||||
api_or_die "fetch logs for job $name" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
|
||||
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
|
||||
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
|
||||
echo "$json"
|
||||
else
|
||||
@@ -161,7 +203,7 @@ function print_job_log() {
|
||||
echo
|
||||
}
|
||||
|
||||
RUN_SCAN=50
|
||||
TASK_SCAN=600
|
||||
KEEP_TIMESTAMPS=0
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
@@ -179,6 +221,15 @@ case "$subcommand" in
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
# Logs come from the run view's own endpoint rather than the API, so
|
||||
# the web root is needed as well. Derived from GITEA_API because that
|
||||
# is the only address the caller configures.
|
||||
GITEA_WEB="${GITEA_API%/api/v1}"
|
||||
if [ "$GITEA_WEB" = "$GITEA_API" ]; then
|
||||
echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
|
||||
echo "cannot be derived from it - logs are read from a non-API route." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
@@ -195,14 +246,28 @@ case "$subcommand" in
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
|
||||
echo "$json" | jq -r '.workflow_runs[] | [
|
||||
(.run_number | tostring),
|
||||
(if .status == "completed" then .conclusion else .status end),
|
||||
.event,
|
||||
(.head_sha[0:8]),
|
||||
.display_title
|
||||
] | @tsv' | column -t -s $'\t'
|
||||
fetch_tasks
|
||||
# One record per job, so a run appears once for each job it has.
|
||||
# Folded here into a row per run, whose result is the worst of them:
|
||||
# a run with one failed job is a failed run, and reporting it as
|
||||
# seven successes and a failure is not what the caller asked.
|
||||
echo "$tasks_json" | jq -r --argjson limit "$limit" '
|
||||
[.workflow_runs[]] | group_by(.run_number)
|
||||
| map({
|
||||
run: .[0].run_number,
|
||||
event: .[0].event,
|
||||
sha: (.[0].head_sha[0:8]),
|
||||
title: .[0].display_title,
|
||||
result: (if any(.[]; .status == "failure") then "failure"
|
||||
elif any(.[]; .status == "cancelled") then "cancelled"
|
||||
elif any(.[]; .status != "success") then "in_progress"
|
||||
else "success" end)
|
||||
})
|
||||
| sort_by(.run) | reverse | .[0:$limit] | .[]
|
||||
| [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
|
||||
while IFS=$'\t' read -r run result event sha title; do
|
||||
printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
|
||||
done
|
||||
;;
|
||||
jobs)
|
||||
run_arg=""
|
||||
@@ -212,7 +277,9 @@ case "$subcommand" in
|
||||
fetch_jobs
|
||||
echo "run $run_number:"
|
||||
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
|
||||
column -t -s $'\t'
|
||||
while IFS=$'\t' read -r index result name; do
|
||||
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
|
||||
done
|
||||
;;
|
||||
logs)
|
||||
run_arg=""
|
||||
@@ -237,7 +304,7 @@ case "$subcommand" in
|
||||
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
|
||||
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
|
||||
else
|
||||
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
|
||||
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.context | contains($n))]')
|
||||
fi
|
||||
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
|
||||
echo "No job matching '$job_want' in run $run_number:" >&2
|
||||
@@ -254,8 +321,8 @@ case "$subcommand" in
|
||||
fi
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r id name result; do
|
||||
print_job_log "$id" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
|
||||
while IFS=$'\t' read -r index name result; do
|
||||
print_job_log "$index" "$name" "$result"
|
||||
done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -13,6 +13,7 @@ function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
@@ -153,7 +154,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
list|view|create|edit|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -223,6 +224,35 @@ ${body}"
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-issue edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
+106
-75
@@ -5,12 +5,16 @@ GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
|
||||
echo "the two is not given is left as it is."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -53,6 +57,23 @@ function gitea_api() {
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx. Copied from gitea-issue, which grew
|
||||
# it first for the same reason: every call site here did this by hand, and a
|
||||
# fifth hand-rolled copy is where one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
@@ -86,6 +107,43 @@ function gitea_owner_repo() {
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected a PR number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, `parsed_title` from --title and
|
||||
# `parsed_base` from --base, rejecting --body and --body-file together.
|
||||
# Shared by create and edit so the two cannot drift into disagreeing about
|
||||
# which one wins - the same reason gitea-issue shares one between its own
|
||||
# two body-taking subcommands.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
parsed_base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) parsed_base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
@@ -95,7 +153,7 @@ subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
create|comments|merge)
|
||||
create|edit|comments|merge)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
@@ -110,28 +168,9 @@ esac
|
||||
|
||||
case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
parse_body_args "$@"
|
||||
title="$parsed_title"
|
||||
base="$parsed_base"
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
@@ -147,14 +186,7 @@ ${body}"
|
||||
base="${base:-main}"
|
||||
fi
|
||||
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
|
||||
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
|
||||
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
|
||||
if [ -n "$existing_url" ]; then
|
||||
@@ -165,18 +197,44 @@ ${body}"
|
||||
title="$current_branch"
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to create PR (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
'{head: $head, base: $base, title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
edit)
|
||||
require_number "${1:-}"
|
||||
pr_number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -n "$parsed_base" ]; then
|
||||
echo "gitea-pr edit cannot change the base branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
|
||||
echo "gitea-pr edit requires --title, --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only the fields actually given are sent: an omitted key leaves that
|
||||
# field alone, so editing a title cannot blank a body by accident.
|
||||
payload='{}'
|
||||
if [ -n "$parsed_title" ]; then
|
||||
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
# The same marker create writes. It describes where the body came
|
||||
# from rather than the event that wrote it, so an edited body
|
||||
# keeps the disclosure instead of quietly shedding it.
|
||||
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
|
||||
|
||||
$body" '. + {body: $b}')
|
||||
fi
|
||||
|
||||
api_or_die "edit PR #${pr_number}" -X PATCH \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
|
||||
-H "Content-Type: application/json" -d "$payload"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comments)
|
||||
@@ -187,39 +245,20 @@ ${body}"
|
||||
fi
|
||||
|
||||
echo "=== Issue comments ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch issue comments" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Reviews ==="
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
reviews_json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch reviews (HTTP $http_code):" >&2
|
||||
echo "$reviews_json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
|
||||
reviews_json="$json"
|
||||
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Inline review comments ==="
|
||||
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
|
||||
for review_id in $review_ids; do
|
||||
response=$(gitea_api \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "fetch comments for review ${review_id}" \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
|
||||
done
|
||||
;;
|
||||
@@ -230,17 +269,9 @@ ${body}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "merge"}')
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
api_or_die "merge PR #${pr_number}" -X POST \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" -d '{"Do": "merge"}'
|
||||
echo "Merged PR #${pr_number}"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -133,6 +133,21 @@ if [ -f "$decl_dir/dependencies" ]; then
|
||||
# a leading dash (e.g. "--allow-unauthenticated") must not validate
|
||||
# as a name, or this hook would echo into the agent's context an
|
||||
# entry the project's own approve/status commands treat as rejected.
|
||||
#
|
||||
# A tarball spec carries a digest and a URL rather than a bare name,
|
||||
# so it is matched separately and answered by whether its directory
|
||||
# is there. Reporting one as missing has to print the whole spec, and
|
||||
# that is deliberate: the digest is the part that makes the operator's
|
||||
# approval mean something, so a truncated form would be asking them to
|
||||
# approve something they cannot see.
|
||||
if [[ "$spec" == tarball:* ]]; then
|
||||
name="${spec#tarball:}"; name="${name%%@*}"
|
||||
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || continue
|
||||
[ -d "/opt/dejima/$name" ] && continue
|
||||
missing_deps_n=$((missing_deps_n + 1))
|
||||
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
|
||||
continue
|
||||
fi
|
||||
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
|
||||
kind="${spec%%:*}"
|
||||
name="${spec#*:}"
|
||||
|
||||
+133
-10
@@ -557,7 +557,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
|
||||
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
|
||||
snap; git -C "$WS" config user.name changed
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
|
||||
# *.txt, not every entry: a finding with retained content also leaves a
|
||||
# <report>.d sidecar beside it, which rotation counts as part of the
|
||||
# report it belongs to rather than as another report.
|
||||
run bash -c "ls '$AUDITDIR/$slug'/*.txt | wc -l"
|
||||
[ "$output" -le 10 ]
|
||||
}
|
||||
|
||||
@@ -723,15 +726,125 @@ REPORT
|
||||
echo 'export X=1' > "$WS/.envrc"
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@test "a finding the baseline already covers is not presented again" {
|
||||
# #65's headline bug. The approval was always written correctly; `ack`
|
||||
# just never consulted the baseline when deciding what to SHOW, so every
|
||||
# later run re-presented the same CRITICAL and appended a duplicate
|
||||
# entry. Reported as "approvals are not being recorded", which is what it
|
||||
# looks like from the outside.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *".envrc"* ]]
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
# And no second entry for the same decision.
|
||||
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "a report whose CRITICALs were approved through an earlier report stops nagging" {
|
||||
# Two reports can list the same finding. Approving it in the first left
|
||||
# the second permanently -CRITICAL.txt, so bin/dejima warned about a
|
||||
# report whose every finding had been dealt with, and `ack` re-presented
|
||||
# it forever. The rename now runs on a pass that records nothing new.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
# A second report listing the same, still-unapproved finding.
|
||||
cp "$AUDITDIR/$slug"/*-CRITICAL.txt "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"recorded 1 acknowledgement"* ]]
|
||||
# The remaining report is walked with nothing left to decide, and sheds
|
||||
# its suffix rather than staying unacknowledged forever.
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"already dealt with"* ]]
|
||||
run bash -c "ls '$AUDITDIR/$slug'"
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "a finding that cannot be acknowledged is presented once, not on every run" {
|
||||
# There is nothing to record for one, so without a marker it re-prompted
|
||||
# forever and `ack` could never reach a state of having nothing to ask.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
grep -q '^reviewed ' "$BASEDIR/$slug"
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "quitting a report does not mark it reviewed" {
|
||||
# The marker means "walked to the end". An abort discards everything
|
||||
# else, and must discard this too or one q would silence the report.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
|
||||
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
|
||||
|
||||
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"CROSS-PROJECT"* ]]
|
||||
}
|
||||
|
||||
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
|
||||
# Neither `ack` nor `compare` handled this tier: no arm derived an id for
|
||||
# it, and the two branches that emit it never consulted the baseline. It
|
||||
# was the one class of finding with no way to curate it at all.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed-in-session; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'i\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^spam gitconfig:user.name' "$BASEDIR/$slug"
|
||||
# ...and the surface stays quiet whatever it changes to next, which is
|
||||
# the point for a key some tool rewrites on every run.
|
||||
snap; git -C "$WS" config user.name changed-again
|
||||
run cmp_
|
||||
[[ "$output" != *"user.name"* ]]
|
||||
}
|
||||
|
||||
@test "an approved WARNING git config value re-fires when the value changes" {
|
||||
# The other verb: `safe` pins the exact value, so a different one is a
|
||||
# new decision rather than covered by the old one.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name first; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
grep -q '^safe gitconfig:user.name ' "$BASEDIR/$slug"
|
||||
snap; git -C "$WS" config user.name second
|
||||
run cmp_
|
||||
[[ "$output" == *"changed git config: user.name=second"* ]]
|
||||
}
|
||||
|
||||
@test "ack emits no colour when its output is not a terminal" {
|
||||
# Reports and piped output are read back by machines - the audit's own
|
||||
# next run, and every test here. An escape sequence in either is a bug,
|
||||
# so the palette is decided from the stream rather than from a flag.
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
run cmp_
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
}
|
||||
|
||||
@test "ack records a safe entry and clears the CRITICAL report marker" {
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
|
||||
@@ -741,12 +854,22 @@ REPORT
|
||||
[[ "$output" != *"-CRITICAL.txt"* ]]
|
||||
}
|
||||
|
||||
@test "ack does not write an unmatchable entry for a non-path finding" {
|
||||
@test "ack does not write an unmatchable entry for a finding with no id" {
|
||||
# Was written against a changed git config, which #45 made ackable. The
|
||||
# invariant is about findings that have no id to record at all, so it now
|
||||
# uses the one class that still has none: a cross-project write, whose
|
||||
# subject is another project's slug rather than anything this audit can
|
||||
# hash and pin.
|
||||
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
|
||||
mkdir -p "$PROJDIR/-some-other-project"
|
||||
echo x > "$PROJDIR/-some-other-project/file1"
|
||||
snap
|
||||
echo y > "$PROJDIR/-some-other-project/file2"
|
||||
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
|
||||
cmp_ >/dev/null 2>&1
|
||||
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot"* ]]
|
||||
[ ! -f "$BASEDIR/$slug" ]
|
||||
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
|
||||
}
|
||||
|
||||
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
|
||||
@@ -978,11 +1101,11 @@ REPORT
|
||||
git -C "$WS" config core.hooksPath /tmp/evil
|
||||
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
|
||||
cmp_ >/dev/null 2>&1
|
||||
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# 'i' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
|
||||
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
|
||||
# negative grep below can't pass vacuously against a missing file.
|
||||
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
|
||||
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
|
||||
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
|
||||
[ -f "$BASEDIR/$slug" ]
|
||||
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# Asserts on the RUNNER, not on this repository. Every other suite here tests
|
||||
# dejima's code; this one tests the machine executing it, because the controls
|
||||
# in docs/ci.md live in a runner's config.yaml and a host's firewall - set up
|
||||
# by dejima-ci-hardener, on a machine this repository never touches, so no
|
||||
# amount of reviewing this repository can check them.
|
||||
#
|
||||
# It exists because both failures that matter look exactly like success from
|
||||
# inside a job. Nothing in a job's log mentions whether the Docker socket was
|
||||
# mounted into it, and a job that can reach an off-list resolver still passes
|
||||
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
|
||||
# they show up as nothing at all.
|
||||
#
|
||||
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
|
||||
# rather than sniffed, so landing on the wrong runner is a failure rather than
|
||||
# a different set of assertions quietly being chosen. Unset means this is not
|
||||
# a CI runner and there is nothing here to assert.
|
||||
#
|
||||
# Its values are the names of the jobs whose environment they verify -
|
||||
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
|
||||
# looking at a red `runner-env (host-docker)` should not have to work out
|
||||
# which job that corresponds to.
|
||||
|
||||
setup() {
|
||||
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
|
||||
case "$DEJIMA_CI_TIER" in
|
||||
sandbox|host-docker) ;;
|
||||
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# An off-list destination that would answer if it were reachable. This matters
|
||||
# more than it looks: pointing these at an address that never answers - a
|
||||
# documentation range, say - would pass in an environment with no network at
|
||||
# all, which is the exact failure this suite exists to detect.
|
||||
OFF_LIST_IP=1.1.1.1
|
||||
|
||||
# The positive control's destination. Overridable because it has to be a name
|
||||
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
|
||||
# fail on a runner that is correctly configured for a different set.
|
||||
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
|
||||
|
||||
connects() { # connects HOST PORT -> 0 if a TCP connection completes
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import socket, sys
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# --- the grant that decides everything --------------------------------------
|
||||
|
||||
@test "the sandbox jobs are not handed the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
|
||||
# act_runner mounts /var/run/docker.sock into every job container unless
|
||||
# container.docker_host is "-". An unset value is the default, and the
|
||||
# default mounts - so this is the assertion that a runner was actually
|
||||
# configured rather than merely intended.
|
||||
[ ! -e /var/run/docker.sock ] || {
|
||||
echo "this runner grants job containers the Docker socket, which is" >&2
|
||||
echo "root-equivalent on the runner host. Set container.docker_host: \"-\"" >&2
|
||||
echo "in its config.yaml - see dejima-ci-hardener." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the host-docker job does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
|
||||
# The positive control for the label routing. Without it, a host-docker
|
||||
# job that landed on a hardened runner would fail later and obscurely, and
|
||||
# a sandbox job that landed on this one would be caught only by the test
|
||||
# above - one direction of a two-direction problem.
|
||||
[ -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" = sandbox ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
|
||||
# HOST network namespace - that is the grant they exist for - so the bridge
|
||||
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
|
||||
# egress is the host's own, which this repository's examples do not define.
|
||||
# Asserting an allowlist there would test nothing and pass anyway, which is
|
||||
# the failure mode this whole suite is about.
|
||||
|
||||
@test "an allowlisted destination is reachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# The positive control for everything below. Without it, a runner with no
|
||||
# network at all passes every negative assertion in this file.
|
||||
run getent hosts "$ALLOWED_HOST"
|
||||
[ "$status" -eq 0 ] || {
|
||||
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
|
||||
echo "would pass for the wrong reason. Check the resolver and the" >&2
|
||||
echo "allowlist rendered from .dejima/allowed-domains on main - or set" >&2
|
||||
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
|
||||
false
|
||||
}
|
||||
run connects "$ALLOWED_HOST" 80
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "a name that is on no allowlist does not resolve" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# .invalid is reserved by RFC 2606 precisely so that it never resolves
|
||||
# anywhere - but here it has to be REFUSED by a resolver that answers for
|
||||
# allowlisted names, which the positive control above establishes.
|
||||
run getent hosts example.invalid
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "a job cannot reach a resolver of its own choosing" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# The check most easily left out, and the one that decides whether the DNS
|
||||
# allowlist means anything: a job that can query any resolver it likes can
|
||||
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
|
||||
# to the resolver on the bridge gateway is addressed to the host, so it is
|
||||
# an INPUT packet and never passes through DOCKER-USER - filtering only
|
||||
# DOCKER-USER leaves this open.
|
||||
run connects "$OFF_LIST_IP" 53
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes and" >&2
|
||||
echo "the DNS allowlist is decorative. The INPUT rules are missing -" >&2
|
||||
echo "re-run dejima-ci-firewall on the runner host." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "a destination that is on no allowlist is unreachable" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# An IP literal rather than a name, so this tests the firewall rather than
|
||||
# the resolver. Reaching it by name would fail at DNS first and prove
|
||||
# nothing about DOCKER-USER.
|
||||
run connects "$OFF_LIST_IP" 443
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
|
||||
echo "" >&2
|
||||
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
|
||||
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
|
||||
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
|
||||
echo "restart docker' drops these rules and nothing looks different" >&2
|
||||
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
|
||||
echo "" >&2
|
||||
echo "dejima-ci-hardener's dejima-ci-firewall.service reapplies them on" >&2
|
||||
echo "every Docker start, which is the fix rather than a one-off re-run." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "IPv6 egress is not an unfiltered way around all of the above" {
|
||||
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
|
||||
# An address family with no allowlist is not a smaller hole than the one
|
||||
# the rules above close; on a runner with IPv6 egress it is the same hole.
|
||||
#
|
||||
# Unlike the checks above, this one passes for two different reasons - v6
|
||||
# rejected, or v6 never configured - and both are acceptable outcomes, so
|
||||
# it is not given a positive control. What it rules out is the third case:
|
||||
# a runner with working IPv6 egress and rules written only for v4.
|
||||
run connects 2606:4700:4700::1111 443
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
@@ -136,6 +136,95 @@ dockerfile_for() {
|
||||
done
|
||||
}
|
||||
|
||||
TARBALL_SHA=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
|
||||
@test "valid_dep_spec accepts a well-formed tarball spec" {
|
||||
for spec in \
|
||||
"tarball:go@$TARBALL_SHA:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz" \
|
||||
"tarball:nvim@$TARBALL_SHA:https://github.com/neovim/neovim/releases/download/v0.11.0/nvim-linux-x86_64.tar.gz" \
|
||||
"tarball:a@$TARBALL_SHA:https://example.com/x?v=1.2%2B3"; do
|
||||
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
|
||||
[ "$status" -eq 0 ]
|
||||
done
|
||||
}
|
||||
|
||||
# The URL and the name are interpolated into a root RUN line in the generated
|
||||
# Dockerfile, so this is the boundary that keeps a declaration from becoming a
|
||||
# build-time root shell. The digest cases matter for a different reason: a
|
||||
# spec without one would be an approval of a name whose contents the far end
|
||||
# can change afterwards, which is not something an operator can agree to once.
|
||||
@test "valid_dep_spec rejects a tarball spec that is shell-active, unpinned or not https" {
|
||||
for spec in \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/\$(id).tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a;id.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a\`id\`.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a b.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a'\''.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a\\\\b.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:https://x.dev/a|id" \
|
||||
"tarball:go@$TARBALL_SHA:http://x.dev/a.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:ftp://x.dev/a.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA:/etc/passwd" \
|
||||
"tarball:go@deadbeef:https://x.dev/a.tar.gz" \
|
||||
"tarball:go@${TARBALL_SHA}Z:https://x.dev/a.tar.gz" \
|
||||
"tarball:go@:https://x.dev/a.tar.gz" \
|
||||
"tarball:go:https://x.dev/a.tar.gz" \
|
||||
"tarball:go@$TARBALL_SHA" \
|
||||
"tarball:@$TARBALL_SHA:https://x.dev/a.tar.gz" \
|
||||
"tarball:-go@$TARBALL_SHA:https://x.dev/a.tar.gz"; do
|
||||
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
|
||||
[ "$status" -ne 0 ] || { echo "accepted: $spec" >&2; false; }
|
||||
done
|
||||
}
|
||||
|
||||
# A name becomes a directory under /opt/dejima, so a traversal token in it
|
||||
# would place the unpacked archive somewhere else entirely.
|
||||
@test "valid_dep_spec rejects a tarball name that walks out of its directory" {
|
||||
for name in .. . ../evil a/b /abs; do
|
||||
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" \
|
||||
"tarball:$name@$TARBALL_SHA:https://x.dev/a.tar.gz"
|
||||
[ "$status" -ne 0 ] || { echo "accepted name: $name" >&2; false; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "generate_derived_dockerfile verifies a tarball's digest before unpacking it" {
|
||||
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
|
||||
printf 'tarball:go@%s:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz\n' "$TARBALL_SHA" > "$store/dependencies"
|
||||
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"https://go.dev/dl/go1.25.0.linux-amd64.tar.gz"* ]]
|
||||
[[ "$output" == *"$TARBALL_SHA /tmp/go.tar"* ]]
|
||||
[[ "$output" == *"/opt/dejima/go"* ]]
|
||||
[[ "$output" == *"ENV PATH=\$PATH:/opt/dejima/go/bin"* ]]
|
||||
|
||||
# An archive extracted first and verified after has already written
|
||||
# whatever it contained, so the ordering is the property, not the presence
|
||||
# of a checksum line anywhere in the file.
|
||||
sha_line=$(grep -n 'sha256sum -c' <<< "$output" | head -1 | cut -d: -f1)
|
||||
tar_line=$(grep -n 'tar -C /opt/dejima/go' <<< "$output" | head -1 | cut -d: -f1)
|
||||
[ "$sha_line" -lt "$tar_line" ]
|
||||
}
|
||||
|
||||
@test "generate_derived_dockerfile unpacks tarballs as root, before the drop to the sandbox user" {
|
||||
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
|
||||
printf 'tarball:go@%s:https://go.dev/dl/go.tar.gz\nnpm:typescript\n' "$TARBALL_SHA" > "$store/dependencies"
|
||||
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
|
||||
[ "$status" -eq 0 ]
|
||||
tar_line=$(grep -n 'tar -C /opt/dejima/go' <<< "$output" | head -1 | cut -d: -f1)
|
||||
drop_line=$(grep -n 'USER \${SANDBOX_USER}' <<< "$output" | head -1 | cut -d: -f1)
|
||||
[ "$tar_line" -lt "$drop_line" ]
|
||||
[[ "$(grep -E '^(USER|ENTRYPOINT)' <<< "$output" | tail -2)" == "USER root
|
||||
ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
|
||||
}
|
||||
|
||||
@test "generate_derived_dockerfile emits no tarball layer when none is approved" {
|
||||
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
|
||||
printf 'apt:libpq-dev\n' > "$store/dependencies"
|
||||
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"/opt/dejima"* ]]
|
||||
}
|
||||
|
||||
@test "read_declared skips comments/blanks and marks invalid entries rejected" {
|
||||
cat > "$PROJ/.dejima/dependencies" <<'EOF'
|
||||
# project deps
|
||||
|
||||
@@ -85,6 +85,16 @@ wait_ready() {
|
||||
if docker exec "$container" pgrep -x -f 'sleep infinity' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# A container that has already exited is never going to become ready,
|
||||
# and polling it for the full two minutes turns a startup failure - an
|
||||
# aborted entrypoint, say - into a wait long enough to read as a hang.
|
||||
# The reason is in its logs, which are printed either way; this only
|
||||
# decides how long it takes to see them.
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = false ]; then
|
||||
echo "container $container exited during startup - log:" >&2
|
||||
settled_logs "$container" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "container $container never reached the post-entrypoint sleep - startup log:" >&2
|
||||
@@ -197,6 +207,7 @@ teardown() {
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
@@ -210,6 +221,8 @@ teardown() {
|
||||
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
@@ -358,6 +371,26 @@ dexec_node() {
|
||||
[ -n "$output" ]
|
||||
}
|
||||
|
||||
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
|
||||
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
|
||||
# sockets, because that is what Docker's embedded resolver forwards
|
||||
# external names from and without it nothing in here resolves at all.
|
||||
# This is the assertion that the opening stops there. `node` is the
|
||||
# session user and there is no sudo in the image, so if it could reach a
|
||||
# resolver of its own choosing it could carry data out in the query name
|
||||
# itself - the exact thing dns-proxy.sh exists to prevent, and something
|
||||
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
|
||||
#
|
||||
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
|
||||
# so a pass here means the packet was stopped and not that the probe was
|
||||
# aimed at nothing.
|
||||
run dexec_node dig +short registry.npmjs.org
|
||||
[ -n "$output" ]
|
||||
|
||||
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
|
||||
# api.anthropic.com is left off the allowlist when the credential proxy is
|
||||
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
|
||||
@@ -458,6 +491,54 @@ dexec_node() {
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
|
||||
# .dejima/allowed-domains is read with the project and is meant to widen
|
||||
# what a session can reach. It must not widen how. GitHub's addresses are
|
||||
# confined to 443 by init-firewall.sh, and that is the entire read-only
|
||||
# guarantee - with 22 shut there is no transport left for the forwarded
|
||||
# ssh-agent's key to push with, whatever a hook does or does not catch.
|
||||
#
|
||||
# allowed-domains carries no port restriction, so before ipset_for_domain
|
||||
# this path put GitHub's address there and opened 22. It surfaced as an
|
||||
# intermittent startup failure rather than as a silent hole only because
|
||||
# the port-22 self-check re-resolves the name: it catches the hole when
|
||||
# GitHub's second answer matches the first, and misses it when the name
|
||||
# has rotated in between. This test does not depend on that coin flip.
|
||||
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_FORGE_HOSTS= \
|
||||
-e ALLOWED_DOMAINS="github.com api.github.com" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
# Coming up at all is part of the assertion: the port-22 self-check runs
|
||||
# during startup, and with the address in the wrong set it fails the boot.
|
||||
wait_ready "$test_container"
|
||||
|
||||
# Resolved inside the container, through the same dnsmasq that answered
|
||||
# during startup - so this asks about the address actually allowlisted,
|
||||
# not one a fresh lookup from elsewhere happened to return.
|
||||
local ip
|
||||
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
|
||||
[ -n "$ip" ]
|
||||
|
||||
run docker exec "$test_container" ipset test github-domains "$ip"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
run docker exec "$test_container" ipset test allowed-domains "$ip"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# And the property those two sets exist to produce.
|
||||
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
docker rm -f "$test_container" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
|
||||
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
|
||||
# derives hostnames from any one forge's variables - bin/dejima resolves
|
||||
@@ -961,6 +1042,18 @@ EOF
|
||||
local store; store="$(mktemp -d)"
|
||||
local df; df="$(mktemp)"
|
||||
printf 'apt:cowsay\n' > "$store/dependencies"
|
||||
|
||||
# The positive control, and it is load-bearing: this test proves the
|
||||
# derived layer installed cowsay by finding cowsay installed. If the base
|
||||
# image already carried it, the assertion below would pass with
|
||||
# generate_derived_dockerfile doing nothing at all. Checked here rather
|
||||
# than assumed, because "the base image gained a package" is a change
|
||||
# nobody would connect to this file.
|
||||
run docker run --rm --entrypoint dpkg-query "$IMAGE" -W -f='${Status}' cowsay
|
||||
[[ "$output" != *"install ok installed"* ]] || {
|
||||
echo "cowsay is in the base image, so this test cannot detect a broken derived build" >&2
|
||||
false
|
||||
}
|
||||
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
|
||||
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
|
||||
docker build -q -t "$derived" -f "$df" "$ctx" >&2
|
||||
@@ -1250,3 +1343,78 @@ EOF
|
||||
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
|
||||
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
|
||||
}
|
||||
|
||||
# The three-symptom failure entrypoint.sh's marker-directory check exists to
|
||||
# name: notifications stop, the session vanishes from dejima-attach, and the
|
||||
# notify hook reports EACCES - with no obvious moment they started.
|
||||
#
|
||||
# It is Docker's answer to a missing bind source that produces it. bin/dejima
|
||||
# creates the marker directory per launch and removes it on exit, so a
|
||||
# container that is RESTARTED rather than relaunched has no source left, and
|
||||
# Docker creates one root-owned rather than refusing. The directory then
|
||||
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
|
||||
@test "a marker directory the sandbox user cannot write is reported at startup" {
|
||||
# Its own container, not container_name() - that one is created in
|
||||
# setup_file and used by every dexec test in this file. Starting a second
|
||||
# container under that name conflicts, and removing it at the end of this
|
||||
# body would take the shared one with it.
|
||||
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local host_dir; host_dir="$(mktemp -d)"
|
||||
# Unwritable by the sandbox user, which is the property the check keys on.
|
||||
# Built here rather than by removing a bind source, so the test does not
|
||||
# depend on Docker's fallback behaviour to set up a test OF the
|
||||
# consequences of that behaviour.
|
||||
#
|
||||
# chmod rather than mounting :ro. A read-only mount would make the
|
||||
# "not repaired" assertion below vacuous - root could not chown it even
|
||||
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
|
||||
# container and still unwritable by the session user, so that assertion
|
||||
# means something.
|
||||
chmod 555 "$host_dir"
|
||||
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
|
||||
-v "$host_dir:/run/dejima-notify" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" == *"not writable by"* ]]
|
||||
[[ "$output" == *"RESTARTED rather than"* ]]
|
||||
# Reported, not repaired: chowning would restore notifications while the
|
||||
# session stayed absent from a registry this container never sees.
|
||||
#
|
||||
# Asserted as "still not writable by the session user" rather than "still
|
||||
# owned by root": ownership depends on who ran the suite, since mktemp -d
|
||||
# follows the caller, while writability is the property the check reads
|
||||
# and the one repairing it would change.
|
||||
run docker exec -u node "$container" test -w /run/dejima-notify
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
rm -rf "$host_dir"
|
||||
}
|
||||
|
||||
# Remote access is opt-in and off by default. The check above must say nothing
|
||||
# at all for the sessions that never asked for it, which is every session by
|
||||
# default - a warning about an unconfigured optional feature is noise on every
|
||||
# start, and noise is what stops warnings being read.
|
||||
@test "no marker-directory warning when remote access is not enabled" {
|
||||
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$container"
|
||||
|
||||
run settled_logs "$container"
|
||||
[[ "$output" != *"dejima-notify"* ]]
|
||||
[[ "$output" != *"remote access is enabled"* ]]
|
||||
}
|
||||
|
||||
+437
-2
@@ -663,6 +663,19 @@ EOF
|
||||
grep -q "not the issue's URL" "$REPO_ROOT/workflows/worktree-pr/briefing.md"
|
||||
}
|
||||
|
||||
@test "the worktree-pr workflow tells the agent not to hard-wrap bodies, and how to fix one" {
|
||||
# The forge renders a single newline as a line break, so a body wrapped
|
||||
# at a column arrives broken every ten words - worst on a phone, where
|
||||
# the screen wraps the line and the stray newline wraps it again. The
|
||||
# tools deliberately do not reflow anything (#79), which puts the whole
|
||||
# of this on the briefing.
|
||||
grep -q 'hard-wrap' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
|
||||
grep -q 'newline as a line break' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
|
||||
# And that revising the file a body came from sends nothing by itself -
|
||||
# the other half of #79, where edits were reported but never made.
|
||||
grep -q 'edit <N>' "$REPO_ROOT/workflows/worktree-pr/briefing.md"
|
||||
}
|
||||
|
||||
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
|
||||
# The whole privilege-drop model depends on entrypoint.sh itself starting
|
||||
# as root (to set up the firewall) - a stray `USER node` before
|
||||
@@ -813,6 +826,112 @@ EOF
|
||||
done
|
||||
}
|
||||
|
||||
# Runs a gitea CLI against a stub `curl` that records the request instead of
|
||||
# making one, from a throwaway repo whose origin looks like the configured
|
||||
# Gitea host. Every one of these tools reaches the network through a single
|
||||
# `curl -K -` call, so this is their whole outbound surface.
|
||||
#
|
||||
# The stub answers the way gitea_api's callers parse: the body, then the
|
||||
# status on its own line (which is what `-w '\n%{http_code}'` appends).
|
||||
gitea_cli() {
|
||||
local cli="$1"; shift
|
||||
local d="$BATS_TEST_TMPDIR/cli"
|
||||
if [ ! -d "$d" ]; then
|
||||
mkdir -p "$d/stub" "$d/repo"
|
||||
cat > "$d/stub/curl" <<'STUB'
|
||||
#!/bin/bash
|
||||
prev=""
|
||||
payload=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "-d" ]; then
|
||||
payload="$a"
|
||||
printf '%s' "$a" > "$STUB_PAYLOAD"
|
||||
fi
|
||||
prev="$a"
|
||||
done
|
||||
cat >/dev/null
|
||||
# A request carrying a body is a write, and its callers read one object
|
||||
# back; the reads these tools make before writing are list endpoints, whose
|
||||
# callers iterate. Answering both with the same shape would fail on the jq
|
||||
# rather than on anything under test.
|
||||
if [ -n "$payload" ]; then
|
||||
echo '{"html_url": "https://forge.invalid/x/y/pulls/1"}'
|
||||
else
|
||||
echo '[]'
|
||||
fi
|
||||
echo 200
|
||||
STUB
|
||||
chmod +x "$d/stub/curl"
|
||||
git -C "$d/repo" init -q -b feature
|
||||
# Identity passed per-command rather than inherited: a CI container
|
||||
# has no git config, and `commit` there exits 128 with "Author
|
||||
# identity unknown" - a precondition this suite was taking from
|
||||
# whoever ran it. The commit exists only so create's
|
||||
# `rev-parse --abbrev-ref HEAD` has a branch to report.
|
||||
git -C "$d/repo" -c user.name=t -c user.email=t@invalid \
|
||||
commit -q --allow-empty -m seed
|
||||
git -C "$d/repo" remote add origin git@forge.invalid:owner/name.git
|
||||
fi
|
||||
: > "$d/payload"
|
||||
( cd "$d/repo" && env PATH="$d/stub:$PATH" \
|
||||
GITEA_SSH_HOST=forge.invalid GITEA_API=https://forge.invalid/api/v1 \
|
||||
GITEA_TOKEN=stub-token STUB_PAYLOAD="$d/payload" \
|
||||
"$REPO_ROOT/forges/gitea/bin/$cli" "$@" )
|
||||
}
|
||||
|
||||
# What was in the -d of the last gitea_cli call.
|
||||
cli_payload() { cat "$BATS_TEST_TMPDIR/cli/payload"; }
|
||||
|
||||
@test "an edit sends only the fields it was given, so a title edit cannot blank a body" {
|
||||
# The half of #79 that was silent: an agent that said it had updated a
|
||||
# description had not, because there was no way to. Sending a fixed
|
||||
# {title, body} pair instead would have traded that for a worse bug -
|
||||
# every title-only edit wiping the description.
|
||||
local cli
|
||||
for cli in gitea-pr gitea-issue; do
|
||||
gitea_cli "$cli" edit 1 --title "a new title"
|
||||
[ "$(cli_payload | jq -r '.title')" = "a new title" ]
|
||||
[ "$(cli_payload | jq 'has("body")')" = "false" ]
|
||||
|
||||
gitea_cli "$cli" edit 1 --body "a new body"
|
||||
[ "$(cli_payload | jq 'has("title")')" = "false" ]
|
||||
# The provenance marker create writes survives an edit rather than
|
||||
# being shed by it.
|
||||
[[ "$(cli_payload | jq -r '.body')" == *"LLM agent"* ]]
|
||||
[[ "$(cli_payload | jq -r '.body')" == *"a new body"* ]]
|
||||
done
|
||||
}
|
||||
|
||||
@test "an edit with neither a title nor a body is refused rather than sent empty" {
|
||||
local cli
|
||||
for cli in gitea-pr gitea-issue; do
|
||||
run gitea_cli "$cli" edit 1
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"requires --title"* ]]
|
||||
# Nothing reached the network to be refused by the far end instead.
|
||||
[ ! -s "$BATS_TEST_TMPDIR/cli/payload" ]
|
||||
done
|
||||
}
|
||||
|
||||
@test "create still sends head, base, title and body after sharing a parser with edit" {
|
||||
# create's own argument parsing moved into the function edit uses, so
|
||||
# that the two cannot disagree about --body vs --body-file. This is the
|
||||
# side of that move with no test of its own before now.
|
||||
gitea_cli gitea-pr create --title "a title" --body "a body" --base trunk
|
||||
[ "$(cli_payload | jq -r '.head')" = "feature" ]
|
||||
[ "$(cli_payload | jq -r '.base')" = "trunk" ]
|
||||
[ "$(cli_payload | jq -r '.title')" = "a title" ]
|
||||
[[ "$(cli_payload | jq -r '.body')" == *"a body"* ]]
|
||||
}
|
||||
|
||||
@test "an edit takes its body from a file, the same way create does" {
|
||||
# The flag that matters inside the sandbox: a body written to a file
|
||||
# never enters the Bash command string deny-dangerous-bash.sh reads.
|
||||
echo "body out of a file" > "$BATS_TEST_TMPDIR/b.md"
|
||||
gitea_cli gitea-pr edit 1 --body-file "$BATS_TEST_TMPDIR/b.md"
|
||||
[[ "$(cli_payload | jq -r '.body')" == *"body out of a file"* ]]
|
||||
}
|
||||
|
||||
@test "gitea-issue list asks for issues only, never pull requests" {
|
||||
# Gitea's issues endpoint returns PRs too - a PR is an issue with a
|
||||
# branch attached - so without type=issues every open PR shows up as an
|
||||
@@ -829,6 +948,37 @@ EOF
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "gitea-actions asks for nothing that needs to own the repo" {
|
||||
# Gitea gates /repos/{o}/{r}/actions/runs - and the job and log endpoints
|
||||
# reached through it - behind reqOwner(). A sandbox should hold a token
|
||||
# for a dedicated account that is a collaborator and nothing more, and
|
||||
# that account is refused there with "user should be the owner of the
|
||||
# repo". No scope grants ownership, so a reader who hits this reaches for
|
||||
# a wider token, which is the wrong direction and the reason this is
|
||||
# pinned rather than left to the comment explaining it.
|
||||
#
|
||||
# Anchored on GITEA_API, not on the path: the log route is a web URL
|
||||
# under the same-looking /actions/runs/N/jobs/M prefix, and it is fine.
|
||||
local f="$REPO_ROOT/forges/gitea/bin/gitea-actions"
|
||||
run grep -nE '\$\{?GITEA_API\}?[^"]*actions/(runs|jobs)' "$f"
|
||||
[ "$status" -ne 0 ]
|
||||
# The three it may use instead, each reachable with plain read access.
|
||||
grep -q 'actions/tasks' "$f"
|
||||
grep -q 'commits/.*statuses' "$f"
|
||||
grep -q 'GITEA_WEB' "$f"
|
||||
}
|
||||
|
||||
@test "the gitea CLIs format their own tables, rather than shelling out to column" {
|
||||
# util-linux's `column` is not in the sandbox image, and it is the last
|
||||
# command in the pipeline - so a missing one fails with 127 after the
|
||||
# request has already succeeded, which reads as an API problem rather
|
||||
# than a missing tool. bin/dejima-audit and bin/dejima-attach pad with
|
||||
# printf for the same reason; this keeps the forge CLIs consistent with
|
||||
# them.
|
||||
run grep -rn 'column -t' "$REPO_ROOT/forges/"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "gitea-issue is installed into the image alongside gitea-pr" {
|
||||
grep -q 'COPY forges/' "$SANDBOX_DIR/Dockerfile"
|
||||
# install -m 755, not a plain cp, so the executable bit surviving isn't
|
||||
@@ -1490,6 +1640,80 @@ this line is not json'
|
||||
grep -q 'dejima-project approve' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
# The hook answers "is this installed?" per kind, and a tarball has no
|
||||
# package manager to ask - it is there if its directory is. A tarball spec
|
||||
# falling through to the package-name regex would be silently skipped, which
|
||||
# reads to the agent as "declared and present" for something that is neither.
|
||||
@test "sandbox-context reports a declared tarball dependency that is not unpacked" {
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
mkdir -p "$ws/.dejima"
|
||||
printf 'tarball:definitely-not-here@%s:https://example.com/x.tar.gz\n' "$sha" > "$ws/.dejima/dependencies"
|
||||
local out
|
||||
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
|
||||
rm -rf "$ws"
|
||||
[[ "$out" == *"definitely-not-here"* ]]
|
||||
# The digest reaches the agent intact: it is what the operator is being
|
||||
# asked to approve, so a truncated form would be asking them to agree to
|
||||
# something they cannot see.
|
||||
[[ "$out" == *"$sha"* ]]
|
||||
}
|
||||
|
||||
# The positive half of the test above. It needs a tarball dependency actually
|
||||
# unpacked in the image, which only exists once a project has approved one -
|
||||
# so on an image with none it skips rather than silently asserting nothing.
|
||||
# A visible skip is the honest form here; a test that arranged its own
|
||||
# /opt/dejima entry would be checking that mkdir works.
|
||||
#
|
||||
# The branch itself is pinned by the source-level test below, which holds
|
||||
# regardless of what this image happens to contain.
|
||||
@test "sandbox-context does not report a tarball dependency that is unpacked" {
|
||||
local present=""
|
||||
for d in /opt/dejima/*/; do [ -d "$d" ] && present="$(basename "$d")" && break; done
|
||||
[ -n "$present" ] || skip "no unpacked tarball dependency in this image"
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
mkdir -p "$ws/.dejima"
|
||||
printf 'tarball:%s@%s:https://example.com/x.tar.gz\n' "$present" "$sha" > "$ws/.dejima/dependencies"
|
||||
local out
|
||||
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
|
||||
rm -rf "$ws"
|
||||
[[ "$out" != *"$present"* ]]
|
||||
}
|
||||
|
||||
# runtime.bats proves the derived-image dependency mechanism works by
|
||||
# declaring apt:cowsay and then finding cowsay installed. That only proves
|
||||
# anything while the base image does not ship it - otherwise the assertion
|
||||
# passes with the mechanism entirely broken. runtime.bats checks this for
|
||||
# itself too, but only where Docker exists; this catches it in the suite that
|
||||
# runs everywhere, at the point the package list changes.
|
||||
@test "the base image ships none of the packages used as dependency fixtures" {
|
||||
for pkg in $(grep -oE "apt:[a-z0-9.+-]+" "$REPO_ROOT/test/runtime.bats" | cut -d: -f2 | sort -u); do
|
||||
run grep -nE "^ +$pkg \\\\$" "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "$pkg is a dependency-test fixture and is also in the base image" >&2
|
||||
false
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
# The image ships the sandbox's own machinery and the tooling with no route
|
||||
# through dejima-dep. A language toolchain has a route - tarball: - so one
|
||||
# appearing here means every project carries it with nobody having asked.
|
||||
@test "no language toolchain is installed into the base image" {
|
||||
run grep -nE '^(RUN|ENV).*(golang|/usr/local/go|rustup|\.cargo/bin|pyenv|rbenv|sdkman)' \
|
||||
"$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "declare it with dejima-dep instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "sandbox-context decides a tarball is present by its unpack directory" {
|
||||
grep -q '\[ -d "/opt/dejima/\$name" \] && continue' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
@test "sandbox-context reports nothing when no project declarations exist" {
|
||||
local ws; ws="$(mktemp -d)"
|
||||
local out
|
||||
@@ -1640,11 +1864,156 @@ EOF
|
||||
# from the branch the operator controls. A future edit that softens this into
|
||||
# "read .dejima/allowed-domains" without saying from where would leave the
|
||||
# document describing a control that doesn't control anything.
|
||||
# runs-on lives in the workflow file, which lives in the branch, so a
|
||||
# dedicated label routes honest jobs and stops nothing else. An earlier version
|
||||
# of this document said jobs "cannot land anywhere else", which is true only of
|
||||
# a branch that is not trying - the exact distinction the rest of the document
|
||||
# is built on. Pinned so it cannot quietly come back.
|
||||
# The counterintuitive half of the advisory, and the one a reader is most
|
||||
# likely to skip: a project with no CI is exposed too, because a branch adds
|
||||
# the first workflow. If that goes missing, the document reads as being about
|
||||
# people who already run CI, which is the smaller group.
|
||||
@test "docs/ci.md says a project with no CI is exposed too" {
|
||||
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
|
||||
grep -qi 'holds for projects with no CI' "$REPO_ROOT/README.md"
|
||||
}
|
||||
|
||||
# Moving a public project to another owner breaks its name, its links and its
|
||||
# ownership. It must not read as the recommended answer, and the alternatives
|
||||
# that require moving nothing have to be present.
|
||||
@test "docs/ci.md offers answers that do not require moving a repository" {
|
||||
local f="$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'Scope the dangerous runner down, rather than moving' "$f"
|
||||
grep -q 'Turn CI off for that repository' "$f"
|
||||
grep -q 'Do not grant push' "$f"
|
||||
}
|
||||
|
||||
@test "docs/ci.md does not present the runner label as a boundary" {
|
||||
grep -q 'A label is not a boundary' "$REPO_ROOT/docs/ci.md"
|
||||
grep -q 'weakest runner it can reach' "$REPO_ROOT/docs/ci.md"
|
||||
run grep -n 'cannot land anywhere else' "$REPO_ROOT/docs/ci.md"
|
||||
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
|
||||
}
|
||||
|
||||
# The shared-identity point is what decides whether an operator's branch
|
||||
# protection does anything, and it is the kind of thing that reads as a
|
||||
# digression to a later editor. Pinned in both places, and pinned together
|
||||
# with the separation that fixes it - stating the problem without the remedy
|
||||
# would leave a reader with nothing to do about it.
|
||||
@test "docs/security.md documents that the sandbox authenticates as the operator" {
|
||||
grep -q 'the sandbox is you' "$REPO_ROOT/docs/security.md"
|
||||
grep -qi 'guard rather than a boundary' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'forge.gitea. token_file' "$REPO_ROOT/docs/security.md"
|
||||
grep -q 'core. ssh_key' "$REPO_ROOT/docs/security.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md's branch-protection prerequisite links the shared-identity section" {
|
||||
grep -q 'security.md#to-your-forge-the-sandbox-is-you' "$REPO_ROOT/docs/ci.md"
|
||||
}
|
||||
|
||||
@test "docs/ci.md says the CI allowlist is read from main, not from the checkout" {
|
||||
grep -q 'origin/main' "$REPO_ROOT/docs/ci.md"
|
||||
grep -qi 'never from the checkout' "$REPO_ROOT/docs/ci.md"
|
||||
}
|
||||
|
||||
# --- the labels this repo's own workflow asks for ---
|
||||
#
|
||||
# Whether a job container gets the Docker socket is settled by the runner's
|
||||
# config.yaml - which lives in dejima-ci-hardener's territory, not this
|
||||
# repository's - so the only lever here is which runner its jobs may land on.
|
||||
# A generic label gives that lever away, which is what these pin.
|
||||
|
||||
# pull_request_target reads this workflow from the base branch, which is what
|
||||
# pins runs-on out of a branch's reach. Reverting to pull_request hands that
|
||||
# back, silently.
|
||||
@test "the workflow is read from the base branch, not the branch under test" {
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
grep -qE '^ pull_request_target:' "$wf"
|
||||
|
||||
# pull_request may still be present, but only while the transition is
|
||||
# explicitly in progress - it takes an untrusted run from the head with
|
||||
# branch-controlled runs-on, which is the thing being removed. Requiring
|
||||
# the marker means the day it is dropped, this fails until the trigger
|
||||
# goes with it, rather than the pair quietly becoming permanent.
|
||||
if grep -qE '^ pull_request:' "$wf"; then
|
||||
grep -q 'TRANSITIONAL' "$wf" || {
|
||||
echo "pull_request is still enabled without the transitional note -" >&2
|
||||
echo "it hands runs-on back to the branch. Remove the trigger." >&2
|
||||
false
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
# The trap in that conversion: the run's ref is the BASE branch, so a bare
|
||||
# checkout tests main and passes regardless of the change. Every suite that
|
||||
# means to test the branch has to name the head explicitly - and a green run
|
||||
# is exactly what it looks like when one does not.
|
||||
@test "every suite that tests the branch checks out the branch" {
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
# Both branch-testing jobs pin the head sha; the runner-env jobs must not,
|
||||
# since their assertions should come from the base branch.
|
||||
[ "$(grep -c 'pull_request.head.sha' "$wf")" -eq 2 ]
|
||||
[ "$(grep -c 'uses: actions/checkout@v4' "$wf")" -eq 4 ]
|
||||
}
|
||||
|
||||
@test "no workflow job runs on a generic runner label" {
|
||||
run grep -nE '^\s*runs-on:\s*(ubuntu|windows|macos|self-hosted)' \
|
||||
"$REPO_ROOT"/.gitea/workflows/*.yml
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "a generic label lets this job land on a runner nobody hardened:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "every workflow job runs on a dejima-specific label" {
|
||||
while IFS= read -r label; do
|
||||
[[ "$label" == dejima-* ]] || {
|
||||
echo "unexpected runs-on label: $label" >&2
|
||||
false
|
||||
}
|
||||
done < <(grep -hE '^\s*runs-on:' "$REPO_ROOT"/.gitea/workflows/*.yml \
|
||||
| sed -E 's/^\s*runs-on:\s*//')
|
||||
}
|
||||
|
||||
# Remote access is opt-in and off by default, so the marker-directory check
|
||||
# must be inside the "was it configured at all" guard. Outside it, every
|
||||
# ordinary session gets a warning about a feature it never asked for - and a
|
||||
# warning that fires on every start is one nobody reads.
|
||||
@test "the marker-directory check only runs when remote access is configured" {
|
||||
local f="$REPO_ROOT/docker/entrypoint.sh"
|
||||
grep -q 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f"
|
||||
# The check's own output must sit inside that guard, not before it.
|
||||
guard=$(grep -n 'if \[ -n "${DEJIMA_NOTIFY_DIR:-}" \]; then' "$f" | head -1 | cut -d: -f1)
|
||||
warn=$(grep -n 'remote access is enabled, but' "$f" | head -1 | cut -d: -f1)
|
||||
[ "$guard" -lt "$warn" ]
|
||||
}
|
||||
|
||||
# Chowning it would restore notifications while the session stayed absent from
|
||||
# dejima-attach, because the registry entry is written host-side and never
|
||||
# mounted here - a session that looked fixed with half the feature still
|
||||
# broken. The remedy is relaunching, which the message names.
|
||||
@test "the marker-directory check reports rather than repairs" {
|
||||
local f="$REPO_ROOT/docker/entrypoint.sh"
|
||||
run grep -nE 'chown[^|]*DEJIMA_NOTIFY_DIR|chmod[^|]*DEJIMA_NOTIFY_DIR' "$f"
|
||||
[ "$status" -ne 0 ] || { echo "$output" >&2; false; }
|
||||
grep -q "start a new session with 'dejima'" "$f"
|
||||
}
|
||||
|
||||
# bin/dejima warns and continues rather than refusing. The neighbouring
|
||||
# state-dir check does refuse, but that one is about a symlink handing a
|
||||
# session an attacker-chosen path; this one is only about an optional feature
|
||||
# working, and blocking a launch over undeliverable notifications costs more
|
||||
# than it saves.
|
||||
@test "bin/dejima warns about an unusable marker directory without refusing" {
|
||||
local line
|
||||
line=$(grep -n 'is not a directory this user owns and can write' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
|
||||
[ -n "$line" ]
|
||||
run sed -n "$((line)),$((line + 6))p" "$REPO_ROOT/bin/dejima"
|
||||
[[ "$output" != *"exit 1"* ]]
|
||||
}
|
||||
|
||||
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
|
||||
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
@@ -1676,6 +2045,10 @@ SCANNED_PATHS=(README.md docs docker bin forges workflows harnesses hooks instal
|
||||
}
|
||||
}
|
||||
|
||||
# The exemption above is only defensible while it is the address the docs
|
||||
# actually recommend. If the recommendation changes and this does not, the
|
||||
# exemption starts covering an address nothing points at - a hole rather than
|
||||
# a carve-out.
|
||||
@test "no hostname under a private-network suffix is used as a documentation example" {
|
||||
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
@@ -1773,6 +2146,55 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
|
||||
}
|
||||
|
||||
# Through a real pty, because the [a]/[c]/[q] prompt only exists when stdin
|
||||
# is a terminal - the same reason test/remote.bats drives its picker with
|
||||
# `script` rather than a pipe.
|
||||
startup_warning_launch_tty() {
|
||||
local keys="$1"
|
||||
printf '%s' "$keys" | HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
|
||||
DEJIMA_CONFIG_DIR="$DEJIMA_T/config" \
|
||||
SANDBOX_TEST_DOCKER_LOG="$DEJIMA_T/docker-log" \
|
||||
SSH_AUTH_SOCK= ALLOWED_DOMAINS= TERM=xterm-256color \
|
||||
script -qec "'$REPO_ROOT/bin/dejima' '$DEJIMA_T/ws'" /dev/null
|
||||
}
|
||||
|
||||
@test "the startup warning offers to review the findings there and then" {
|
||||
# Without this the operator is told to run a command in another terminal,
|
||||
# at the one moment when nothing is waiting on them: the container has
|
||||
# not started yet (#65).
|
||||
startup_warning_setup
|
||||
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
|
||||
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
|
||||
run startup_warning_launch_tty 'a
|
||||
q
|
||||
'
|
||||
[[ "$output" == *"[a]"* ]]
|
||||
[[ "$output" == *"review now"* ]]
|
||||
# 'a' ran the audit review in-process, rather than only naming it.
|
||||
[[ "$output" == *"dejima audit:"* ]]
|
||||
}
|
||||
|
||||
@test "the startup warning colours the tier and the keystrokes on a terminal" {
|
||||
# The complaint is that the warning is one undifferentiated block: the
|
||||
# word that says how bad it is, the run ids, and the keys to press all
|
||||
# read the same (#65).
|
||||
startup_warning_setup
|
||||
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
|
||||
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
|
||||
run startup_warning_launch_tty 'c
|
||||
'
|
||||
[[ "$output" == *$'\033'* ]]
|
||||
}
|
||||
|
||||
@test "the startup warning emits no colour when it is not going to a terminal" {
|
||||
startup_warning_setup
|
||||
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
|
||||
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
|
||||
run startup_warning_launch
|
||||
[[ "$output" == *"unacknowledged CRITICAL"* ]]
|
||||
[[ "$output" != *$'\033'* ]]
|
||||
}
|
||||
|
||||
@test "audit.strict = on refuses to start over unacknowledged CRITICAL history when stdin is not a tty" {
|
||||
startup_warning_setup
|
||||
printf '[audit]\nstrict = on\n' >> "$DEJIMA_T/config/config"
|
||||
@@ -2084,15 +2506,21 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "every sandbox suite is in the CI matrix, and runtime.bats is not" {
|
||||
@test "every suite is run by CI, and the two that need their own job have one" {
|
||||
# Derived from the directory rather than a list kept here, so adding a
|
||||
# test file fails this until the workflow is told about it. A suite nobody
|
||||
# wired into CI runs nowhere, and nothing else would say so.
|
||||
#
|
||||
# Two suites are not matrix entries because they cannot be. runtime.bats
|
||||
# needs a Docker daemon; ci-runner.bats asserts on the runner itself and
|
||||
# has to run once per tier rather than once per suite. Both are checked
|
||||
# below for having a job of their own instead - "not in the matrix" must
|
||||
# not be a way to be in neither.
|
||||
local wf="$REPO_ROOT/.gitea/workflows/tests.yml"
|
||||
local f base missing=""
|
||||
for f in "$REPO_ROOT"/test/*.bats; do
|
||||
base="$(basename "$f" .bats)"
|
||||
[ "$base" = "runtime" ] && continue
|
||||
case "$base" in runtime|ci-runner) continue ;; esac
|
||||
grep -qE "^ *- *$base\$" "$wf" || missing="$missing $base"
|
||||
done
|
||||
[ -z "$missing" ] || {
|
||||
@@ -2105,6 +2533,13 @@ startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g';
|
||||
# fail every time rather than testing anything.
|
||||
! grep -qE '^ *- *runtime$' "$wf"
|
||||
grep -q 'test/runtime.bats' "$wf"
|
||||
|
||||
# ci-runner.bats likewise, and once per tier: a single job would leave one
|
||||
# of the two runners unasserted, which is the thing it exists to catch.
|
||||
! grep -qE '^ *- *ci-runner$' "$wf"
|
||||
grep -q 'test/ci-runner.bats' "$wf"
|
||||
grep -q 'DEJIMA_CI_TIER: sandbox' "$wf"
|
||||
grep -q 'DEJIMA_CI_TIER: host-docker' "$wf"
|
||||
}
|
||||
|
||||
@test "the agent briefing teaches the project scope, not just global" {
|
||||
|
||||
@@ -44,5 +44,19 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
crash, a restart, or a destructive command it is warning about will get
|
||||
the call blocked, which looks like a broken tool rather than what it is.
|
||||
Writing the file first keeps the prose out of the command entirely.
|
||||
`{{issue_command}}` (list/view/create/comment/close/reopen) takes the same
|
||||
flag, for recording a problem you found but aren't fixing.
|
||||
`{{issue_command}}` (list/view/create/edit/comment/close/reopen) takes the
|
||||
same flag, for recording a problem you found but aren't fixing.
|
||||
- Do not hard-wrap prose in those bodies. Write each paragraph as one long
|
||||
line and separate paragraphs with a blank line. The forge renders a single
|
||||
newline as a line break, so text you wrapped at a column arrives with a
|
||||
break every ten words: it wraps early on a desktop, and on a phone the
|
||||
screen breaks the line first and your newline breaks it again a few words
|
||||
later, which is much harder to read than either alone. Keep the breaks
|
||||
that carry structure - list items, table rows, headings, and anything
|
||||
inside a code fence - and drop the ones that were only there to keep the
|
||||
source tidy. The tools send the body through exactly as written; nothing
|
||||
reflows it for you.
|
||||
- To revise a body after the fact, use `{{pr_command}} edit <N> --body-file`
|
||||
(or `{{issue_command}} edit <N>`). Rewriting the file you built the body
|
||||
from changes nothing on its own - the forge has its own copy from the
|
||||
moment you created it, so an edit has to be sent.
|
||||
|
||||
Reference in New Issue
Block a user