3 Commits

Author SHA1 Message Date
Privatae 8822d5df85 bastion 0.55.3
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
2026-10-25 23:16:45 +00:00
Privatae 77b7853ae1 bastion 0.54.1
ci / personal-data (push) Successful in 6s
ci / fuzz (push) Successful in 2m49s
ci / test (push) Successful in 3m7s
2026-10-21 23:39:13 +00:00
Privatae d62da8adb4 bastion 0.54.0
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s
2026-10-21 22:29:10 +00:00
195 changed files with 16929 additions and 2980 deletions
+3
View File
@@ -0,0 +1,3 @@
# Test tooling for one test lab, built only with -tags lab; it is not part of
# any release build, so it is left out of the published source.
client-go/lab.go export-ignore
+19 -5
View File
@@ -16,7 +16,7 @@ on:
branches: [main]
env:
GO_IMAGE: golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59
GO_IMAGE: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
# The pull request's own head, or the pushed commit.
WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }}
@@ -58,7 +58,10 @@ jobs:
echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done
echo "== go build"; go build -o /tmp/privatae-bastion .
echo "== go test"; go test -count=1 ./...'
if [ -f lab.go ]; then echo "== go vet -tags lab"; go vet -tags lab ./...; fi
echo "== go test"; go test -count=1 ./...
echo "== staticcheck"; sh ../deploy/staticcheck.sh
echo "== the GitHub release step, against a fake API"; python3 -I ../deploy/test_github_release.py'
- name: the same for deploy/release-sign
run: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \
@@ -80,9 +83,20 @@ jobs:
test "$(git rev-parse HEAD)" = "$WANT"
- name: no person's data in the tree
# Reads files, runs nothing from the tree. The term check needs the
# maintainers' key, which a CI job never holds, so here it is skipped and
# the run says so; the entity-id, provenance, email and phone checks run.
run: python3 scripts/test_no_personal_data.py
# maintainers' key and the entity-id check the reserved ids, which a CI
# job never holds, so the job names files that do not exist: both
# classes are skipped and the run prints a NOTE for each; the
# provenance, email and phone checks run. Left at their defaults, a
# path that exists but cannot be read fails the run instead.
run: |
none="$PWD/.ci-holds-none/none"
NO_PERSONAL_DATA_KEY_FILE="$none" RESERVED_ENTITY_IDS_FILE="$none" python3 scripts/test_no_personal_data.py
- name: every test the claims table cites exists
# docs/claims.md names, for each security claim, the test that fails
# if the claim stops being true. A renamed or deleted test would leave
# the claim looking covered; this fails the run instead. Reads files
# and greps; runs nothing from the tree.
run: bash deploy/check-claims.sh
- name: no Host source file names in the tree
# The member repository publishes this tree as a release was built,
# and its release step refuses a path into the Host's Python
+241
View File
@@ -0,0 +1,241 @@
# The checks of .gitea/workflows/ci.yml, for the copy of this repository on
# GitHub. Gitea reads .gitea/workflows when it exists and then ignores this
# directory, so each forge runs one of the two files, never both.
#
# The jobs, image and commands are the same as there. Every job runs in the Go
# toolchain image deploy/build.sh builds releases with, pinned by the same
# digest, so CI and the release use the same compiler. GitHub does not let a
# job's container name come from env, so the digest is written out in each
# job; it must match GO_IMAGE in deploy/build.sh and .gitea/workflows/ci.yml.
# No action from the marketplace is used: the commit is fetched with git from
# this repository's public URL, so nothing outside this file and the pinned
# image runs. The job token can only read.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
env:
# The pull request's own head, or the pushed commit.
WANT: ${{ github.event.pull_request.head.sha || github.sha }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
GOTOOLCHAIN: local
CGO_ENABLED: "0"
defaults:
run:
shell: sh -eu {0}
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 30
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
env:
PRIVATAE_REQUIRE_AGE: "1"
steps:
- name: tools
# pdftotext, python3, setpriv and git are what the extract and
# peer-identity tests call; without them those tests fail rather than
# skip. Alpine's git has no http-backend (it is in git-daemon), which
# the forge-token test serves its repository with.
run: apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon
- name: fetch the commit
# The checkout belongs to the runner's user and the job runs as root,
# so git is told the directory is safe; go build reads it to stamp the
# binary with the commit.
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: age
# The backup tests check this code against the age command itself: a
# pinned age release, its archive checked against the SHA-256 below,
# and PRIVATAE_REQUIRE_AGE makes those tests fail rather than skip if
# it is missing.
run: |
case "$(uname -m)" in
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
*) echo "no pinned age release for $(uname -m)"; exit 1 ;;
esac
curl -fsSL -o /tmp/age.tgz "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-$a.tar.gz"
echo "$sum /tmp/age.tgz" | sha256sum -c -
tar -xzf /tmp/age.tgz -C /tmp age/age
/tmp/age/age --version
echo "AGE_BIN=/tmp/age/age" >> "$GITHUB_ENV"
- name: gofmt, go vet on linux/darwin/windows, build, go test
working-directory: client-go
run: |
echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done
echo "== go build"; go build -o /tmp/privatae-bastion .
echo "== go test"; go test -count=1 ./...
- name: the same for deploy/release-sign
working-directory: deploy/release-sign
run: |
out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do GOOS=$os go vet ./...; done
go test -count=1 ./...
- name: staticcheck on linux, darwin and windows
# The pinned staticcheck release, built with this toolchain from the
# module proxy and checked against the checksum database.
run: sh deploy/staticcheck.sh
- name: the release workflow's GitHub step, against a fake API
run: python3 -I deploy/test_github_release.py
personal-data:
runs-on: ubuntu-24.04
timeout-minutes: 10
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: tools
run: apk add -q --no-cache bash git python3
- name: fetch the commit
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: this file's image is the image releases are built with
# Each job here names the digest itself, so a change to GO_IMAGE in
# deploy/build.sh that missed this file fails here.
run: |
want="$(sed -n 's/^GO_IMAGE="\(golang@sha256:[0-9a-f]\{64\}\)"$/\1/p' deploy/build.sh)"
test -n "$want"
test "$(grep -c "^ image: $want\$" .github/workflows/ci.yml)" -eq 3
test "$(grep -c '^ image: ' .github/workflows/ci.yml)" -eq 3
grep -q "^ GO_IMAGE: $want\$" .gitea/workflows/ci.yml
grep -q "^ GO_IMAGE: $want\$" .github/workflows/ci.yml
- name: no person's data in the tree
# Reads files, runs nothing from the tree. The term check needs the
# maintainers' key and the entity-id check the reserved ids, which
# this job never holds, so both are skipped and the run prints a NOTE
# for each; the provenance, email and phone checks run.
run: python3 -I scripts/test_no_personal_data.py
- name: every test the claims table cites exists
# docs/claims.md names, for each security claim, the test that fails
# if the claim stops being true. A renamed or deleted test would leave
# the claim looking covered; this fails the run instead. Reads files
# and greps; runs nothing from the tree.
run: bash deploy/check-claims.sh
- name: no Host source file names in the tree
# The member repository publishes this tree as a release was built,
# and its release step refuses a path into the Host's Python
# (server/... or brain/... .py): this repo has no such files, so the
# path can only name another program's source. git grep exits 1 when
# nothing matches and above 1 when it could not search; only 1 passes.
run: |
rc=0
git grep -nIE 'server/[a-z_/]+\.py|brain/[a-z_/]+\.py' || rc=$?
case "$rc" in
1) ;;
0) echo "these lines name another program's source file; describe it instead" >&2
exit 1 ;;
*) echo "git grep could not search the tree (exit $rc), so it was not checked" >&2
exit 1 ;;
esac
fuzz:
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 20
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: tools
run: apk add -q --no-cache bash git
- name: fetch the commit
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: every fuzz target, 5 seconds each
# A short pass on every commit. Run deploy/fuzz.sh with its default of
# 20 seconds before changing a wall a host's input goes through.
run: bash deploy/fuzz.sh 5
claims-on-a-machine:
# The claim tests a container cannot run: the op and action-op sandboxes
# (root, systemd units, overlayfs, a cgroup with the pids controller),
# the egress tests in private user and network namespaces, and the root
# command watch (nftables). They run here on the runner's own virtual
# machine, as root, because that is what they test: the bastion as a
# networked machine's system service. The machine is GitHub's and is
# discarded after the job. The toolchain is the one in the pinned image.
# Every test docs/claims.md cites is run, and the job fails if any of
# them skips.
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 30
# This job runs on the runner itself, not in the image, and Ubuntu
# 24.04's sh is dash 0.5.12-6ubuntu5, which has no pipefail (Debian added
# it in 0.5.12-7): `set -o pipefail` there is a fatal error. bash has it.
defaults:
run:
shell: bash -eu {0}
env:
GO_IMAGE: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: fetch the commit
run: |
set -o pipefail
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: tools, and the pinned toolchain on the machine
run: |
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends nftables iproute2 poppler-utils >/dev/null
# The forge-token claim test needs git 2.46 or later; Ubuntu 24.04's
# own git is 2.43.
if ! git version | awk '{split($3, v, "."); exit !(v[1] > 2 || (v[1] == 2 && v[2] >= 46))}'; then
sudo add-apt-repository -y ppa:git-core/ppa >/dev/null
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null
fi
git version
docker create --name go "$GO_IMAGE" >/dev/null
sudo docker cp go:/usr/local/go /opt/go-pinned
docker rm go >/dev/null
/opt/go-pinned/bin/go version
- name: age
# The same pinned age release as the test job.
run: |
case "$(uname -m)" in
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
*) echo "no pinned age release for $(uname -m)"; exit 1 ;;
esac
curl -fsSL -o "$RUNNER_TEMP/age.tgz" "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-$a.tar.gz"
echo "$sum $RUNNER_TEMP/age.tgz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/age.tgz" -C "$RUNNER_TEMP" age/age
echo "AGE_BIN=$RUNNER_TEMP/age/age" >> "$GITHUB_ENV"
- name: every cited test, as root
working-directory: client-go
run: |
set -o pipefail
tests="$(grep -o '_test\.go`: `[A-Za-z0-9_]*`' ../docs/claims.md | sed 's/.*`: `//; s/`$//' | sort -u | paste -sd'|' -)"
test -n "$tests"
sudo env PATH="/opt/go-pinned/bin:$PATH" HOME="$HOME" AGE_BIN="$AGE_BIN" PRIVATAE_REQUIRE_AGE=1 GOTOOLCHAIN=local CGO_ENABLED=0 \
GOFLAGS=-mod=readonly GOMODCACHE="$HOME/go/pkg/mod" GOCACHE="$HOME/.cache/go-build" \
go test -count=1 -json -run "^($tests)\$" . > "$RUNNER_TEMP/claims.json" || {
grep -h '"Action":"fail"' "$RUNNER_TEMP/claims.json" | head -40; exit 1; }
- name: none of them skipped
run: bash deploy/check-claims.sh --skipped "$RUNNER_TEMP/claims.json"
+67
View File
@@ -0,0 +1,67 @@
# Attaches each release's binaries, its signed manifest and the manifest's
# signature to the release on GitHub, for the copy of this repository there.
# Gitea reads .gitea/workflows when it exists and then ignores this directory.
#
# Nothing here is trusted to say what the release is. deploy/release-assets.sh
# rebuilds the binaries from the release tag with the tag's own pinned build,
# writes the release manifest from them, and fetches the signature published
# for that manifest, which only the release key holder can make and which is
# named by the manifest's digest. Unless the rebuilt bytes are exactly the
# signed release, there is no signature by that name and nothing is attached.
# The release key never comes near this workflow.
#
# A tag push runs it once the release is published. Run it by hand
# (workflow_dispatch, from main) to attach the files to an earlier release
# whose tag is here; its signature is still published, so that works for any
# release from 0.53.2 on.
#
# No action from the marketplace is used: the commit is fetched with git from
# this repository's public URL, and the build runs in the Go toolchain image
# deploy/build.sh pins by digest. The job's token can write the release and
# is passed only to the last step.
name: release
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
tag:
description: the release tag, vX.Y.Z
required: true
permissions:
contents: read
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
assets:
runs-on: ubuntu-24.04
timeout-minutes: 40
permissions:
contents: write
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
steps:
- name: fetch this workflow's commit and the release tags
run: |
git init -q .
git fetch -q --tags "$REPO_URL" "$GITHUB_SHA"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
- name: rebuild the release and check it against its signature
run: deploy/release-assets.sh "$TAG" "$RUNNER_TEMP/assets"
- name: attach the files to the release
# The one step with the token. Its shell, git and Python are named by
# path, and Python runs isolated (-I), so nothing an earlier step put
# on PATH or in the environment chooses what runs here.
shell: /usr/bin/bash -euo pipefail {0}
env:
GH_TOKEN: ${{ github.token }}
run: |
/usr/bin/git show "refs/tags/$TAG:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md"
/usr/bin/python3 -I deploy/github-release.py "$GITHUB_REPOSITORY" "$TAG" "$RUNNER_TEMP/assets" "$RUNNER_TEMP/CHANGELOG.md"
+381 -7
View File
@@ -23,6 +23,380 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked.
## 0.55.3
- **A pairing change reaches every Host on the machine at once.** Each pairing
beats its Host every 60 seconds, and the beat is how every Host on the
machine learns that a pairing went active or was revoked. Approving or
revoking a pairing changed it on disk immediately but triggered no beat, so
every Host kept the old state for up to a minute: a connection request to a
Host the anchor had just approved was refused as not on a shared machine. Now
a grant that makes a pairing active, and a revoke, make every running
pairing beat right away. A refused grant, or a re-sync of a pairing that is
already active, beats nothing.
## 0.55.2
- **A search reply says how its text was sanitized.** Every successful reply
from the bastion agent (search, read range, catalog) was stamped
`sanitized: true`, while the agent the bastion deploys runs in `flag` mode,
which replaces e-mail addresses, phone numbers, card, bank and ID numbers,
IP and MAC addresses and keys, and leaves names, street addresses and
dates of birth in. A reply now carries `sanitize_mode` (the mode this
bastion wrote into the agent's manifest, or for an agent run by hand the
mode it reports, else `unknown`), `sanitized` true only for `sterilize`,
and `sanitize_note` saying what the mode leaves in. A reply cannot raise
the mode this bastion wrote.
- **The README says what the bastion needs.** It said "no Docker, no Python".
The bastion itself needs neither, and the README now names the optional
features that do: coder runs, the service slot and the memory mirror need a
container runtime, folder search needs Python 3.8 or later, Python ops need
`/usr/bin/python3`, and the Linux accessibility reader needs python3 with
AT-SPI. It also said Go 1.25; `client-go/go.mod` needs 1.26.
- **GitHub releases carry their files.** A new workflow,
`.github/workflows/release.yml`, runs on each release tag (and by hand for
an earlier one): `deploy/release-assets.sh` rebuilds the release from its
tag, writes the release manifest from the rebuilt binaries with
`deploy/manifest.py` (which `publish.sh` now uses too, so the two cannot
differ), and fetches the signature published for that manifest. Only when
the signature verifies are the six binaries, `version.json`, its signature
and `SHA256SUMS` attached to the release. Releases 0.53.2, 0.54.0 and
0.54.1 were rebuilt this way and each matched its signed manifest.
- **The lab harness is out of the release build.** `mode=lab` drove one test
lab's own cluster and database, and its layout was in the published source.
It is now built only with `-tags lab` and is not in the published source; a
release bastion answers a lab run with exit 126, as a bastion without a lab
always did.
- **staticcheck is clean** on linux, darwin and windows, for both Go modules,
and CI runs it (`deploy/staticcheck.sh`, a pinned staticcheck release, with
the per-OS runs merged so code used on one OS only is not reported unused).
Unused code is removed and error strings no longer end in punctuation; no
behaviour changes.
- **The claim tests a container cannot run now run on GitHub.** 25 tests the
claims table cites (the op and action-op sandboxes, the egress tests in
private namespaces, the root command watch) skipped in CI, because a CI
container has no systemd, no namespaces of its own and no nftables. A new
job runs every cited test as root on GitHub's discarded virtual machine and
fails if any skips; `deploy/check-claims.sh --skipped` names them.
## 0.55.1
- **A root op's tasks are capped.** An op's tasks were capped at 64 with
`RLIMIT_NPROC`, which the kernel never applies to root, so a root op could
start as many tasks as it liked until its time limit. A root op now runs
in a systemd scope of its own in `privatae_ops.slice` with `TasksMax=96`,
which counts every task in it, root's included: the op's 64 and the
bastion's two processes that start it. Those two run with one CPU's worth
of Go runtime threads (`GOMAXPROCS=1`), so they take the same share of the
cap on a machine of any size: with the runtime set for 64 CPUs they took
44 of the 96, and set for 256 no root op could start. The scope's process
reads its own cgroup's `pids.max` before the op starts, and the op's
report carries `tasks_capped` only when that is the cap. Where no scope
can be made (no systemd, no pids controller, or `systemd-run` failing),
the op runs as before and its report's `tasks_note` says its tasks were
not capped, and why. A user op's tasks are held by `RLIMIT_NPROC` as
before, and its report says so. A scope is killed with the run at the
time limit, when the bastion stops, and by the startup sweep. Two ops can
run at once, so an action op's memory watch now reads each other op's
unit or scope in the slice as another program: it yields when a root op
beside it makes the machine wait on memory, as it did when root ops ran
in the bastion's own cgroup, and its own unit still never counts.
## 0.55.0
- **A local machine's egress lane never reaches the machine itself.** Before,
on a machine with no anchor the lane vetted no destination, so a host could
dial this machine's own services through it: `POST
http://127.0.0.1:11434/api/pull` reached ollama whatever the `models` switch
said. It now refuses `localhost` names, loopback and unspecified addresses
(`0.0.0.0/8` and `::` included), link-local addresses (the cloud metadata
address included), the rest of `64:ff9b::/32`, the deprecated IPv6 forms
below, and any address this machine takes as its own, as the anchored lane
checks them, also inside an IPv4-mapped, NAT64 or 6to4 address. The LAN
stays reachable: a private, CGNAT or reserved address that is not this
machine's is dialled. Every address a name resolves to is checked, and only
those addresses are dialled, never the name again.
- **A container or VM on this machine is this machine, not the LAN.** On
Linux, an address the kernel routes straight onto a container's veth, or
onto a bridge whose every port is a veth, a tap or a dummy (the bridge
networks Docker, Podman, LXD and libvirt make for their containers and
VMs), is refused as this machine. A route through a gateway on such a link
leaves, and a link an IPv4 or IPv6 default route of the main table goes
out of (any nexthop's, for a multipath default) is never inside: a bastion
that is itself in a container reaches the internet through the machine it
runs on, and dials the network on its own veth, its LAN included, as
before. Before, the local egress lane dialled such an address as LAN,
so a host could reach a service in a container here, `172.17.0.2:11434`
for one, though the same service on loopback was refused. A bridge with a
network card, a bond or a VLAN among its ports is still the LAN. Every lane
reads the same check, so a coder run's proxy, the anchored lane and the
backup lanes now also refuse a public address on such a network; their
private addresses were refused already. macOS and Windows have no route
lookup here, so there such an address is still dialled.
- **Every lane refuses the deprecated IPv6 forms.** IPv4-compatible `::/96`,
IPv4-translated `::ffff:0:0/96` and site-local `fec0::/10` are no
destination of their own, and where each is delivered depends on the
platform. A coder run's proxy and the backup lanes already refused them; the
egress lane now does too, anchored or local, by address and in any answer a
name resolves to. The anchored lane refuses nothing else it did not refuse
before. All the lanes now read one address classifier.
- **Both egress lanes try each vetted address, as Happy Eyeballs does.** A
name with more than one address is dialled at each in order, the next
starting as soon as an attempt fails or when none has answered within
300 ms, and the first to answer is used; the others are cancelled, and one
that connects anyway is closed. Before, the anchored lane dialled only the
first address, and a coder run's proxy gave each address an even share of
the dial budget, so an address whose packets go nowhere held up the next
one for seconds. The next one now starts within 300 ms, about what the OS
dialler took when it was given the name.
- **`pair` says to enrol a second factor after every pairing that has none.**
It said so only after it anchored a networked machine, so a local pairing,
one over TCP or a unix socket, and a later host on an anchored machine were
told nothing, and on a local machine a folder grant then installs on the
host's signed grant alone. The line names `--host` when more than one host
is paired, and `sudo -H` only when `pair` itself ran with machine authority,
so a local install is told to enrol as its own user. A pairing that keeps
its factor (a `--replace`), or a machine with an approval device pinned, is
not told.
- **A root-command grant lasts at most 30 days.** It was a year. `root-exec
grant --for 31d` is refused and says the longest term; grant again before
it ends. A grant given under the longer term ends 30 days after it was
given, and `root-exec` shows that end. A grant file with no grant time is
refused, since it cannot be held to the term.
- **SECURITY.md, the README's license section and `docs/host-on-this-machine.md`
name each kind of Host.** They described only a Host on Privatae's platform.
They now say that a C-Series Host runs on your own machine in its own Linux
virtual machine, is open source (Privatae/cseries, AGPL-3.0-only), needs no
Privatae account, and that `npx cseries` sets the bastion up networked,
paired, anchored and with a second factor on every pairing (the commands are
shown); that C-Series Cloud and a network membership are optional and paid;
and that a Host on Privatae's own platform is the case where the Host's
software is not open source.
- **Security reports go to security@privatae.ai,** the address
privatae.ai/.well-known/security.txt names. SECURITY.md said info@.
- **`docs/models.md` says what stops a host changing your models on a local
machine.** It told you to scope the pairing on your host, which a local
machine never reads. There, switching `models` off for the host is what
filters the ollama forward, and the egress lane is no way around it. On an
anchored machine the forward filters for every pairing, which the page now
says too.
- **A tunnel endpoint the host sends must be one host and port.** The
pairing response's `endpoint` and `endpoints`, and the endpoints in a
heartbeat's rotate directive, were written into the WireGuard
configuration unchecked. That configuration is one setting per line, so an
endpoint with a newline in it added settings of its own:
`203.0.113.7:51820` followed by a newline and `allowed_ip=::/0` let the
tunnel carry every IPv6 address, not only the host's. An endpoint must now
be an IP address or a host name with a port from 1 to 65535, and nothing
else, no space or control character. A pairing response with any other is
refused and nothing is saved, and a rotate directive with one is not acted
on. A WireGuard key must be 32 bytes. The tunnel's peer is now configured in
one place, which checks every value it writes.
- **A pairing file that holds such an endpoint is listed but never served.**
An older build may have saved one. Its tunnel and lanes never come up, and
`run` names the file and says why. Every other pairing still loads and
serves, `status` shows the pairing as `invalid` with the same reason, and
`unpair` removes it, as does pairing that host again with `pair --replace`.
One such file no longer stops `run`, `status` and `unpair` for the whole
machine.
- **Such a pairing cannot hold the anchor or be approved, and no host is told
of it.** `claim-anchor` marked it active and anchored the machine to it; it
now refuses, says why, and changes nothing. An approval from the anchor
host marked it active and started it with the endpoint its file holds; it
is now refused. The heartbeat and the anchor host's pairing list leave it
out: a host knows only the states active, pending and revoked, and could
show a pairing in any other state as active.
- **A harness run's agent credential is readable only by the harness.** The
credential file was readable by every user on the machine (0644), in a
folder anyone could open and write (0777), for the length of the run. A
bastion running as root now hands the folder (0700) and the file (0600) to
the user the harness image runs its agent as (uid 10005). One that is not
root cannot hand them over, so it leaves them open for the agent, and the
folder sits inside one that only the bastion's own user can enter.
- **A harness run's tree is out of other users' reach.** In a worktree run the
tree mounted at `/work` was opened to every user (0777), its top folder
included, in the shared temporary folder. Any local user could read or
change the Host's tree during the run. It now sits in a folder only the
bastion's own user can enter, as a git run's tree already did, and a run
refuses a tree whose folder other users can enter. Opening the tree for the
agent no longer follows a link in it. Before, on a bastion running as root,
a link in the repository, to `/etc/shadow` for example, made the linked
file world-writable. A file is opened as 0666, or 0777 if it was
executable, so a git run no longer commits every file as executable.
- **A worktree run returns only what is in its tree.** A file swapped for a
link while the tree was being packed, or a folder swapped for a link, was
read through the link, so a bastion running as root could return any file
on the machine to the Host. Each name is now opened inside the folder
already open above it, never through a link, and goes in only if it is the
plain file or folder that was looked at. On Linux a device or pipe swapped
in for a file in that gap is not opened either: opening some devices does
something by itself. Links, pipes, devices and sockets are left out
(before, each came back as an empty entry of its kind), and the run's
result names them in `worktree_skipped`, with `worktree_skipped_count`.
- **A coding agent can no longer run code as the bastion through the
repository's git settings.** In a git run the bastion committed and pushed
in the agent's own folder, `.git` included, so a hook, a file system
monitor, a filter, a signing program or a changed remote the agent set ran
as the bastion's own user, which can be root, or sent the push, with its
token, to another server. The bastion now makes its own fresh clone, in a
folder the agent cannot reach, copies in the agent's plain files and
folders (never a link, never anything of its `.git`), and commits and
pushes only there. The branch gets one commit with the agent's final
files; the agent's own commits are not pushed one by one. A link the
agent made is left out and named in `worktree_skipped`; a link the
repository already holds stays as it is. Every git the bastion runs for a
harness or a mirror runs with no hooks, no file system monitor, no `ext::`
transport, and no system or user git settings of the machine.
- **The agent in a git run no longer holds the push token, and no process
argument shows it.** The agent's clone named the repository with the
run's token in it, so the agent could push straight to the repository
during the run, past the bastion's copy, and every git the bastion ran
for the run had the token in its arguments, which any local user can
read. Every git call now names the repository without the token and gets
it from its environment, as an `Authorization` header sent to that
repository's address only; no clone's config holds it, and a run is
refused if the token is in any file of the agent's clone's `.git`.
- **A user-privilege op is no longer refused because other programs run as
its user.** An op's tasks are capped at 64, and the cap counted every task
the machine ran as the op's user (`privatae-session`, or `nobody`, which
system daemons share), so on a busy machine a user op could not start or
could not fork at all. The op now runs in a user namespace of its own,
as the same user and seeing every file's real owner, where the cap counts
only its own tasks. Where the machine has no user namespaces, the op runs
as before.
- **A harness run's git calls carry the harness identity.** git made one
from this machine's user and host name instead, which cost a host name
lookup per call (seconds where there is no resolver) and wrote this
machine's name into the clone handed to the agent.
- **A run whose agent container cannot be removed uses nothing of its
tree.** A failed removal was ignored, so an agent still running could go
on writing the tree while the bastion read it. The run now fails, says
so, and copies, returns and pushes nothing.
- **A git run whose commit fails pushes nothing and fails.** A failed add or
commit was ignored and the branch pushed anyway, and the run read as
successful. It now ends the run with an error and no push. A run in which
the agent changed no file pushes no branch and reports `changed: false`.
- **Repositories that keep files in Git LFS work in git runs again.** No
user git settings are read any more, so the LFS filter `git lfs install`
sets there was lost. When `git-lfs` is installed, the bastion now supplies
the standard LFS filter itself on its own git commands, sends the large
files before the push, and sends them only to the repository's own LFS
address, whatever a `.lfsconfig` the agent wrote names. A filter of any
other name in a `.gitattributes` still runs nothing. Tested with git-lfs
3.7.1.
- **Names that open as `.git` on Windows or macOS are left out of a git
run's copy.** Besides `.git` in any letter case, the copy now leaves out
`.git` followed by dots or spaces or a `:`, the short name `GIT~1`, and
`.git` with the characters HFS+ ignores, the names git itself refuses, so
no file the agent made can be written into the clean clone's own `.git`.
- **A run's tree has a size limit, and a file is never read into memory
whole.** A file of any size in the tree, even one that takes no disk
space, was read whole into memory, so an agent could make the bastion run
out of memory. A worktree run now returns at most 9 MiB of tree, which is
what fits in one message to the Host beside the transcript; a larger tree
is refused with `worktree_too_large` and none of it is returned. A git run
copies at most 2 GiB, and refuses a larger tree with `tree_too_large`
before it pushes anything; each file and folder counts 512 bytes and its
name besides its content, so a tree of millions of empty files is refused
too, and the copy keeps nothing per file in memory. Of the entries left
out, the count is kept in full and the first 100 names. A folder is read
512 names at a time, each counted as it is read, so a folder of millions
of names is refused without being read whole first. A result too large for one message to the Host
is refused with `result_too_large` instead of being lost on the way.
- **The local console shows a pairing that is not served as "invalid".** A
pairing whose file holds an endpoint this build refuses showed as active
on the console when its stored state was empty, as it is in older files.
It now reads as the setup page and `status` read it.
- **Built with Go 1.26.10, golang.org/x/net 0.61.0, x/crypto 0.58.0 and x/sys
0.49.0.** `govulncheck` finds 13 known vulnerabilities this code reaches in
Go 1.25.14 and x/net 0.57.0, which 0.54.1 was built with: in crypto/x509
(intermediate certificates since found compromised were still trusted),
crypto/tls, net/http and HTTP/2, net/textproto, os and html/template. With
these versions it finds none. The
build image is still pinned by digest, and CI runs in the same one. The
module now needs Go 1.26, as x/net's fixed release does. A published release
is still rebuilt by `deploy/verify-release.sh` with the toolchain its own
tag pins.
- **`go test ./...` passes for a contributor who is not root.** A root
command now starts in `/` when the process cannot enter root's home, as it
already did when that home is missing; the bastion runs these commands only
as root, so on a machine nothing changes, and the root-command tests now run
as any user. `TestTreeCheckoutMovesOnlyForward`, which needs a tree root
owns, skips as another user, and `TestClaimAnchorWithNoToken` checks the
refusal without machine authority and then skips the rest, where it passed
having checked nothing more.
- **The tests name no folder of our own build machine.** The sandbox tests'
fixture folder is `/srv/privatae-optest` (`/tmp` cannot hold an op's
effects), and the action tests' run note asks for any folder outside
`/tmp`.
- **How this repository is published (docs only).** From this release on,
GitHub receives each change merged since the last release as its own commit,
titled as the change was, instead of one commit per release. The README,
CONTRIBUTING.md and SECURITY.md say so, and say that releases from 0.53.2 on
can be verified from a clone, since the earlier tags are not public.
- **The personal-data check reads what it used to miss.** A listed term
written right after a backslash escape (`\n`, `\t`, `\u00e9`) is found, and
so is any value inside a run of base64 or base64url that decodes to text.
The reserved entity ids are read from `/etc/privatae/reserved-entity-ids`
(one per line, `RESERVED_ENTITY_IDS_FILE` to point elsewhere) instead of
being written in the script; where that file is absent, as in public CI,
the entity-id check is skipped and the run prints a NOTE, as the term check
already does without its key. A file that holds no id, or a line that is
not one, fails the run.
- **The former-name check reads comments and Markdown.** A kept identifier
is set aside only in Go code; in a comment, this changelog or any other
file it is a hit like the bare word, and the two entries that spelled the
device-fingerprint prefix out now name `fpSANPrefix` instead.
- **CI on GitHub.** `.github/workflows/ci.yml` runs the jobs of
`.gitea/workflows/ci.yml` on GitHub Actions in the same Go image, pinned by
the same digest, with no action from the marketplace and a token that can
only read. A step fails if its digest stops matching `deploy/build.sh`.
- Tests use documentation addresses (`2001:db8::/32`, `203.0.113.0/24`) and
neutral paths, and comments state their reason instead of citing a pull
request by number; a test keeps published source free of such numbers.
## 0.54.1
- **No change to what the bastion does.** The README is now a short front
page: install, quick start, local and networked modes, a summary of the
security model, how this repository is published, and how to build and
verify a release. The reference text moved into one page per topic under
`docs/`, listed in `docs/README.md`, with only its headings and links
changed. The README and CONTRIBUTING.md now say that issues and pull
requests are welcome on GitHub.
- **The claims table is its own file, `docs/claims.md`,** and
`deploy/check-claims.sh` reads it there. Two rows that cited two tests each
now cite one test per row, so the check passes again.
## 0.54.0
- **Every audit row also goes to the system log.** The rows written to
`mgmt_audit.jsonl`, `pairing_audit.jsonl` and each pairing's `audit.jsonl`
(terminals, root commands, store ops, op runs) are also written to the
machine's system log, so a log collector can keep them where no host can
write: syslog facility `auth`, tag `privatae-bastion-audit` (the journal on
systemd) on Linux and macOS, and the Application event log, source
`PrivataeBastionAudit`, on Windows, registered when the system service is
installed and removed with it. Each line is the file's row with `audit`
(the file) and `pairing` added, and a root command's text left out (its
digest and length stay). The lines go through a bounded queue, so a slow or
stuck system log never holds up the audited op; a full queue drops lines and
the next line carries `system_log_dropped`. The file stays the record.
- **A server is networked and anchored by default.** `install.sh` run as root
now installs the networked bastion (systemd on Linux, launchd on macOS);
`--local` keeps a root install local, a reinstall over a machine already
paired in local mode keeps it local unless `--networked` is passed, and run
as your own user it is local as before. On a networked machine with no anchor, the first `pair` run with
machine authority anchors it to that host. Before, such a machine refused
every op until `claim-anchor` was run by hand; now it is anchored at once:
every terminal and every write waits for the person's approval, and any
later host for the anchor's. `pair` then says to
enrol the second factor. `claim-anchor` with no token on a machine already
anchored to that host now succeeds and changes nothing (an installer that
anchors after pairing finds the work done); naming another host is still
refused without `--rotate`. The Windows installer is unchanged: `-Networked`
as before.
## 0.53.2
- **No change to what the bastion does.** Two code comments named source files
@@ -183,13 +557,13 @@ says which version is. Older entries are not marked.
- **The device fingerprint is sent under a second name to a Host with no
platform.** A pairing with such a Host (`--redeem-url unix://…` or
`https://…`) sends an operator certificate request that carries the
fingerprint as two URI SANs: first `urn:skema:hwfp:<fp>` (`fpSANPrefix`),
then `urn:privatae:hwfp:<fp>`, which that Host reads. A platform pairing's
request names it once, under `urn:skema:hwfp:`, as before: the platform
signs a request with that one name and refuses one with any other. A
signed accounting verifies with the fingerprint under either name, and a
signer certificate that names two different fingerprints is refused rather
than resolved.
fingerprint as two URI SANs: first under the original device-fingerprint
URN prefix (`fpSANPrefix` in pair.go), then `urn:privatae:hwfp:<fp>`, which
that Host reads. A platform pairing's request names it once, under
`fpSANPrefix`, as before: the platform signs a request with that one name
and refuses one with any other. A signed accounting verifies with the
fingerprint under either name, and a signer certificate that names two
different fingerprints is refused rather than resolved.
## 0.52.0
+16 -4
View File
@@ -7,10 +7,15 @@ their approval on that machine.
## What you can do today
Sign-in on this forge is not reachable from the internet, so you cannot file
an issue or open a pull request here. You can read, clone, build and verify a
release, and fork under the GPL. Report a bug or a security problem by email
(see [SECURITY.md](SECURITY.md)).
Issues and pull requests are welcome on GitHub, at
[github.com/Privatae/bastion](https://github.com/Privatae/bastion).
Development happens in a private repository, so an accepted change is carried
into the development repository and arrives here with the next release, as
its own commit. Sign-in on the git.privatae.ai forge is not reachable from
the internet, so issues and pull requests cannot be opened there. You can
also read, clone, build and verify a release, and fork under the GPL. Report
a security problem by email, not in a public issue (see
[SECURITY.md](SECURITY.md)).
## Checks a change has to pass
@@ -20,8 +25,15 @@ gofmt -l . # must print nothing
go vet ./...
CGO_ENABLED=0 go build .
go test ./...
cd .. && deploy/staticcheck.sh # must find nothing, on linux, darwin and windows
```
Some claim tests need a real machine and root: the op sandboxes, the
namespace tests and the root command watch skip in a container and as a
user. GitHub's CI runs every test `docs/claims.md` cites as root on its own
virtual machine and fails if one skips; to run them yourself, use a machine
you can throw away.
The backup tests also check this code against the age command itself when
one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
+247 -1321
View File
File diff suppressed because it is too large Load Diff
+34 -11
View File
@@ -4,7 +4,7 @@
Please do not open a public issue for a security problem.
Email **info@privatae.ai** with:
Email **security@privatae.ai** with:
- the version (`privatae-bastion version`) and platform,
- what you found and how to reproduce it,
@@ -22,13 +22,15 @@ notes if you wish.
machine do more than the grant and the local approval allow: reading files
outside a granted store, deleting where only archiving is allowed, running a
program that is not in the fixed table, bypassing the second factor, opening
a listener that is not documented in the README's security model.
a listener that is not documented in the security model
([docs/security-model.md](docs/security-model.md)).
## What is out of scope
- The Privatae platform the bastion connects to. It is separate software with
its own reporting path; reports about it sent to the address above are
forwarded.
- The Host the bastion connects to. It is separate software: a C-Series Host
is [Privatae/cseries](https://github.com/Privatae/cseries), and a Host on
Privatae's own platform is software that is not open source. Reports about
either sent to the address above are forwarded.
- Findings that require root on the machine already. The bastion's root ops
assume the machine's administrator is trusted.
@@ -100,7 +102,7 @@ one installed.
**Checking our work.** Release builds are reproducible.
`deploy/verify-release.sh` fetches the published manifest, checks its
signature, checks out the release's source, rebuilds it, and compares every
binary's digest. From 0.43.0 on, each public snapshot in this repository
binary's digest. From 0.53.2 on, each published release in this repository
carries the annotated tag `v<version>`, whose message records the release's
`built_from`. That field names the commit in the repository the release was
built in, which is not a commit here, so the script uses the tag and prints
@@ -113,9 +115,30 @@ the folder you granted. The agent is a separate program in its own repository
(`privatae/bastion-agent`), and it reads your files in order to index them. What
leaves your machine is what its manifest and your grant allow. Its extraction
and PII redaction happen inside it, so check that repository, not this one,
for how well those work.
for how well those work. The bastion runs it in `flag` mode, which does not
find names, street addresses or dates of birth, and every search reply says so
([DCN stores](docs/context.md#distributed-context-network-dcn-stores)).
**The platform.** Your host runs on our platform, which is not open source. The
bastion keeps the host to your grant and your approvals whatever the platform
does. What the host does with what you share with it is governed by software
you cannot read here.
**The Host.** The bastion holds your Host to your grant and your approvals.
What the Host does with what you share with it is the Host's own software, and
which software that is depends on where the Host runs.
- **A C-Series Host** runs on your own machine, in its own Linux virtual
machine. Its source is [Privatae/cseries](https://github.com/Privatae/cseries),
under AGPL-3.0-only; the bastion and the bastion agent are GPL-3.0-or-later.
`npx cseries` installs the bastion beside it as the machine's system service
and, on every pairing, runs `set-mode networked`, then
`pair --redeem-url <the Host's https enroll address> --host-ca <its CA> -`
with the token on standard input, then `claim-anchor` and
`stepup-enroll --console-only --host <the Host>`: the machine is anchored to
that Host and you approve its writes here with your own authenticator. No
Privatae account is needed
([A Host in a VM on this machine](docs/host-on-this-machine.md#a-host-in-a-vm-on-this-machine)).
- **Optional and paid, from Privatae:** C-Series Cloud, which runs a Host on a
server of its own that Privatae sets up and keeps patched, and a Privatae
network membership, which joins a Host on your own machine to the Privatae
network.
- **A Host on Privatae's own platform** is the case where the Host's software
is not open source. There, the bastion keeps the Host to your grant and your
approvals whatever the platform does, and what the Host does with what you
share with it is governed by software you cannot read here.
+433
View File
@@ -0,0 +1,433 @@
package main
import (
"bytes"
"errors"
"os/exec"
"strconv"
"strings"
)
// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT.
//
// The process scan names the account an agent runs as (procUser on every
// platform, winTokenUser on Windows) and says nothing else about it. The
// question an assessment asks is about the account: is it an administrator, is
// it a person's own account or one that exists for a service, and is that
// person logged in right now. "An agent runs as a person, holds administrator
// rights, and nobody is at the keyboard" is a different finding from "an agent
// runs under a service account with no rights", and the account name alone
// cannot tell them apart.
//
// Three facts, each with an honest third value:
//
// owner_class domain_user, local_user, service, system, or unknown
// owner_admin yes, no, or unknown
// owner_interactive yes, no, or unknown
//
// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list
// has not learned that the account is unprivileged, and saying "no" there is
// the confident-wrong answer this plane refuses everywhere else
// (baseline_identity.go draws the same line for sudo rules it could not read).
//
// Every decision is in this file, with no build tag, so the Linux test run
// exercises the macOS and Windows rules too. The platform files
// (agents_<goos>.go) only read: /proc and /etc on Linux, `id`, `dscl` and
// `who` on macOS, the process token and the session list on Windows.
//
// These ride on agentObs, so they travel only where `agents` does: inside the
// heartbeat's `observing` gate (agents.go).
const (
ownerClassDomain = "domain_user"
ownerClassLocal = "local_user"
ownerClassService = "service"
ownerClassSystem = "system"
ownerUnknown = "unknown"
ownerYes = "yes"
ownerNo = "no"
)
// ownerFacts is the three answers for one account.
type ownerFacts struct {
Class string
Admin string
Interactive string
}
// unknownOwner is the answer when nothing could be read: a process whose
// owner this bastion may not inspect, or a build with no reader.
var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
func yesNo(b bool) string {
if b {
return ownerYes
}
return ownerNo
}
// ── Linux and macOS ─────────────────────────────────────────────────────────
// unixOwner is what a Linux or macOS reader collected about one account.
// Every field that can be unreadable carries its own "known" flag.
type unixOwner struct {
UID int // -1 when the uid could not be read
Name string
// LocalRecord: "yes" when the account is in this machine's own account
// database (/etc/passwd; the macOS local directory node), "no" when it is
// not, "unknown" when that could not be read.
LocalRecord string
// DirectorySource: this machine resolves accounts from a directory
// (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was
// copied from one). Only then does "not in the local database" mean a
// domain account rather than a uid with no account at all.
DirectorySource bool
// SystemUIDMax is the highest uid the platform hands to system accounts.
SystemUIDMax int
Groups []string // group names the account or process holds
GroupsKnown bool
AdminGroups map[string]bool
// SudoApplies: on this platform a sudo rule can grant administration to
// any user or group (Linux), so "in no admin group" is only a "no" once
// the rules were read as well. macOS leaves it false and decides on the
// admin group, which is what its stock sudoers grants.
// SudoNamed / SudoKnown: a sudo rule names the account directly or
// through a group (sudoPrincipals), and whether the rules were readable.
SudoApplies bool
SudoNamed bool
SudoKnown bool
// Sessions is the set of account names with a login session now
// (logind, utmp, `who`); SessionsKnown is false when none could be read.
Sessions map[string]bool
SessionsKnown bool
}
// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below
// UID_MIN (1000 by default) are system accounts. login.defs can move both;
// linuxUIDLimits reads it.
const linuxSystemUIDMax = 999
// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and
// keeps the ids below for its own daemons (_www, _spotlight and the rest).
const darwinSystemUIDMax = 500
func classifyUnixOwner(o unixOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.UID == 0:
f.Class = ownerClassSystem
case o.UID == -1:
// Unread uid: the class cannot be told from a name alone. Other
// negative ids are real: macOS's directory gives nobody -2.
case o.UID == 65534 || o.UID <= o.SystemUIDMax:
// nobody, and every uid in the system range, is an account that exists
// for a program rather than a person.
f.Class = ownerClassService
case strings.ContainsAny(o.Name, `@\`):
// user@realm (SSSD) and DOMAIN\user (winbind) are directory names.
f.Class = ownerClassDomain
case o.LocalRecord == ownerYes:
f.Class = ownerClassLocal
case o.LocalRecord == ownerNo && o.DirectorySource:
f.Class = ownerClassDomain
}
switch {
case o.UID == 0:
f.Admin = ownerYes
case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups):
f.Admin = ownerYes
case o.SudoKnown && o.SudoNamed:
f.Admin = ownerYes
case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies):
f.Admin = ownerNo
}
if o.SessionsKnown && o.Name != "" {
f.Interactive = yesNo(o.Sessions[o.Name])
}
return f
}
func hasAnyGroup(groups []string, want map[string]bool) bool {
for _, g := range groups {
if want[g] {
return true
}
}
return false
}
// linuxAdminGroups: the groups a stock distribution's sudoers grants
// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and
// Arch, admin on older Ubuntu).
var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true}
// darwinAdminGroups: macOS administrators are members of `admin`.
var darwinAdminGroups = map[string]bool{"admin": true}
// parseStatusIDs reads the real and effective uid and the supplementary gid
// list out of /proc/<pid>/status. ok is false when there is no Uid line.
func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) {
realUID, effUID = -1, -1
for _, line := range strings.Split(status, "\n") {
switch {
case strings.HasPrefix(line, "Uid:"):
f := strings.Fields(line)
if len(f) >= 3 {
r, err1 := strconv.Atoi(f[1])
e, err2 := strconv.Atoi(f[2])
if err1 == nil && err2 == nil {
realUID, effUID, ok = r, e, true
}
}
case strings.HasPrefix(line, "Groups:"):
for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) {
if n, err := strconv.Atoi(g); err == nil {
gids = append(gids, n)
}
}
}
}
return realUID, effUID, gids, ok
}
// parseGroupNames maps gid -> name from an /etc/group body.
func parseGroupNames(body string) map[int]string {
out := map[int]string{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = p[0]
}
}
return out
}
// parsePasswdUIDs is the set of uids /etc/passwd itself lists.
func parsePasswdUIDs(body string) map[int]bool {
out := map[int]bool{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = true
}
}
return out
}
// parseLoginDefsSysMax returns the highest system uid login.defs declares:
// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default.
func parseLoginDefsSysMax(body string) int {
sysMax, uidMin := -1, -1
for _, line := range strings.Split(body, "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 || strings.HasPrefix(f[0], "#") {
continue
}
n, err := strconv.Atoi(f[1])
if err != nil {
continue
}
switch f[0] {
case "SYS_UID_MAX":
sysMax = n
case "UID_MIN":
uidMin = n
}
}
switch {
case sysMax >= 0:
return sysMax
case uidMin > 0:
return uidMin - 1
}
return linuxSystemUIDMax
}
// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names
// a directory source, so an account missing from /etc/passwd is resolved from
// one rather than simply absent.
func nsswitchHasDirectory(body string) bool {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "passwd:") {
continue
}
for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) {
switch src {
case "sss", "ldap", "winbind", "nis", "nisplus":
return true
}
}
}
return false
}
// parseLogindUserState reads systemd-logind's per-user state file
// (/run/systemd/users/<uid>). active and online mean the user has a session
// now; lingering means services run for them with no session.
func parseLogindUserState(body string) (interactive bool, ok bool) {
for _, line := range strings.Split(body, "\n") {
if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found {
switch v {
case "active", "online":
return true, true
case "lingering", "closing", "offline":
return false, true
}
return false, false
}
}
return false, false
}
// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a
// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7)
// is a login session.
const (
utmpRecordSize = 384
utmpUserOffset = 44
utmpUserSize = 32
utmpUserProcess = 7
)
// parseUtmpUsers is the set of account names with a USER_PROCESS record.
func parseUtmpUsers(b []byte) map[string]bool {
out := map[string]bool{}
for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize {
rec := b[off : off+utmpRecordSize]
if int(rec[0])|int(rec[1])<<8 != utmpUserProcess {
continue
}
name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize]
if i := bytes.IndexByte(name, 0); i >= 0 {
name = name[:i]
}
if len(name) > 0 {
out[string(name)] = true
}
}
return out
}
// parseWhoUsers is the set of account names `who` lists (macOS): the first
// field of every line.
func parseWhoUsers(out string) map[string]bool {
users := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) > 0 {
users[f[0]] = true
}
}
return users
}
// parseIDGroups reads `id -Gn <user>`: group names separated by spaces.
func parseIDGroups(out string) []string { return strings.Fields(out) }
// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it
// says the local node has no such record. A dscl the timeout killed has an
// exit code of -1 and has said nothing about the account.
func dsclRecordMissing(err error) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() > 0
}
// parseDsclUser reads `dscl . -read /Users/<name> UniqueID OriginalNodeName`.
// A mobile account (cached from a directory) carries OriginalNodeName.
func parseDsclUser(out string) (uid int, mobile bool, ok bool) {
uid = -1
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if v, found := strings.CutPrefix(line, "UniqueID:"); found {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
uid, ok = n, true
}
}
if strings.HasPrefix(line, "OriginalNodeName:") {
mobile = true
}
}
return uid, mobile, ok
}
// ── Windows ─────────────────────────────────────────────────────────────────
// Well-known SIDs, from Microsoft's tables:
// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and
// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers
const (
sidLocalSystem = "S-1-5-18"
sidLocalService = "S-1-5-19"
sidNetworkService = "S-1-5-20"
sidAdministrators = "S-1-5-32-544"
sidInteractive = "S-1-5-4"
sidRemoteInteractive = "S-1-5-14"
sidLocalAccount = "S-1-5-113"
sidNTServicePrefix = "S-1-5-80-"
sidVirtualMachinePfx = "S-1-5-83-"
)
// winOwner is what the Windows reader collected from one process token.
type winOwner struct {
SID string
Domain string // the account's domain as LookupAccount names it
Computer string // this machine's NetBIOS name
// Groups are the token's group SIDs, deny-only ones included: an
// administrator running without elevation holds Administrators as a
// deny-only group, and is still an administrator account.
Groups map[string]bool
GroupsKnown bool
// SessionSIDs: accounts with a Remote Desktop Services session now
// (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem).
SessionSIDs map[string]bool
SessionsKnown bool
}
func classifyWinOwner(o winOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.SID == "":
case o.SID == sidLocalSystem:
f.Class = ownerClassSystem
case o.SID == sidLocalService || o.SID == sidNetworkService ||
strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx):
f.Class = ownerClassService
case o.GroupsKnown && o.Groups[sidLocalAccount]:
f.Class = ownerClassLocal
case o.Domain == "":
case strings.EqualFold(o.Domain, o.Computer):
f.Class = ownerClassLocal
case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"):
// A machine or domain account whose domain is not this computer:
// an Active Directory or Entra ID account.
f.Class = ownerClassDomain
}
switch {
case o.SID == sidLocalSystem:
// "System is a hidden member of Administrators" (the second page above).
f.Admin = ownerYes
case o.GroupsKnown:
f.Admin = yesNo(o.Groups[sidAdministrators])
}
switch {
case o.SessionsKnown && o.SID != "":
f.Interactive = yesNo(o.SessionSIDs[o.SID])
case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]):
// No session list (the bastion is not LocalSystem), but this very
// process was started by an interactive logon, so the account has
// one. The converse proves nothing: a scheduled task's token is not
// interactive while its owner may well be logged in.
f.Interactive = ownerYes
}
return f
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
)
// The Linux reader end to end over an invented machine: a /proc with two
// processes and an /etc and /run that say who they belong to. Nothing here is
// read from the box the test runs on.
func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) {
root := t.TempDir()
write := func(rel, body string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+
"kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n")
write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n")
write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n")
write("etc/nsswitch.conf", "passwd: files\n")
write("run/systemd/users/1001", "UID=1001\nSTATE=active\n")
write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n")
write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n")
write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n")
write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n")
oldID, oldProc := identityRoot, procRoot
identityRoot, procRoot = root, filepath.Join(root, "proc")
t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc })
read := ownerFactsReader()
for _, c := range []struct {
pid int
user string
want ownerFacts
}{
{400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}},
// Effective root under kim's account: administrator, class unchanged.
{403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}},
// A pid with no status file: everything unknown, nothing guessed.
{404, "ghost", unknownOwner},
} {
if got := read(c.pid, c.user); got != c.want {
t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want)
}
}
}
// Every observation scanAgents returns carries the owner facts: the wiring,
// not only the classifier. The one process is this test itself, so the facts
// come from the real /proc and are only checked for being filled in.
func TestScanAgentsCarriesOwnerFacts(t *testing.T) {
prev := scanProcessesFn
t.Cleanup(func() { scanProcessesFn = prev })
self := os.Getpid()
scanProcessesFn = func() (map[int]agentObs, error) {
return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code",
Cmdline: "claude", raw: "claude"}}, nil
}
got, err := scanAgents()
if err != nil || len(got) == 0 {
t.Fatalf("scan: %d observations, %v", len(got), err)
}
// The network signal may add this box's own model clients; every
// observation, however it was caught, must carry the facts.
for _, a := range got {
if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" {
t.Fatalf("owner facts missing from pid %d", a.PID)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"encoding/binary"
"testing"
)
// Every value below is invented. No account, group or SID here belongs to a
// real machine.
func TestClassifyUnixOwner(t *testing.T) {
linux := func(o unixOwner) unixOwner {
o.SystemUIDMax = linuxSystemUIDMax
o.AdminGroups = linuxAdminGroups
o.SudoApplies = true
return o
}
cases := []struct {
name string
in unixOwner
want ownerFacts
}{
{"root is the system and an administrator",
linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassSystem, ownerYes, ownerNo}},
{"a daemon account in the system range",
linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassService, ownerNo, ownerNo}},
{"nobody is a service account",
linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}),
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"a person in the sudo group, logged in",
linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"},
GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"a person a sudo rule names directly",
linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"},
GroupsKnown: true, SudoNamed: true, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}},
{"no admin group and the sudo rules read: no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"},
GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{"no admin group but the sudo rules unreadable: unknown, never no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"groups unreadable: unknown, never no",
linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"not in passwd on a machine that resolves from a directory",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"not in passwd and no directory: a uid with no account, not a domain user",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"a realm-qualified name is a directory account",
linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"uid unread",
linux(unixOwner{UID: -1, Name: ""}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"macOS admin group member",
unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: map[string]bool{"sam": true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"macOS standard user: the admin group decides",
unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"macOS daemon",
unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax,
AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"macOS mobile account",
unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyUnixOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestClassifyWinOwner(t *testing.T) {
const box = "DESK-DEMO"
user := "S-1-5-21-1111111111-2222222222-3333333333-1001"
cases := []struct {
name string
in winOwner
want ownerFacts
}{
{"LocalSystem",
winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box},
ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}},
{"NetworkService",
winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box,
Groups: map[string]bool{}, GroupsKnown: true},
ownerFacts{ownerClassService, ownerNo, ownerUnknown}},
{"a virtual service account",
winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"local administrator, unelevated, at the desktop",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidAdministrators: true, sidInteractive: true},
GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"domain user, not an administrator, no session now",
winOwner{SID: user, Domain: "CORPDEMO", Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true,
SessionSIDs: map[string]bool{}, SessionsKnown: true},
ownerFacts{ownerClassDomain, ownerNo, ownerNo}},
{"the local-account group decides even with no domain name",
winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"no session list, but this process is an interactive logon",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerYes}},
{"no session list and a batch token: unknown, never no",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"token unreadable",
winOwner{},
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyWinOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestOwnerFileParsers(t *testing.T) {
status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n"
r, e, gids, ok := parseStatusIDs(status)
if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 {
t.Fatalf("status: %d %d %v %v", r, e, gids, ok)
}
if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok {
t.Fatal("a status with no Uid line must not read as uid 0")
}
names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n")
if names[27] != "sudo" || names[0] != "root" || len(names) != 2 {
t.Fatalf("groups: %v", names)
}
uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n")
if !uids[1001] || !uids[0] || uids[5] {
t.Fatalf("passwd: %v", uids)
}
for body, want := range map[string]int{
"": linuxSystemUIDMax,
"UID_MIN 500\n": 499,
"UID_MIN 1000\nSYS_UID_MAX 899\n": 899,
"# SYS_UID_MAX 10\n": linuxSystemUIDMax,
} {
if got := parseLoginDefsSysMax(body); got != want {
t.Errorf("login.defs %q: %d, want %d", body, got, want)
}
}
if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") ||
nsswitchHasDirectory("passwd: files systemd\n") {
t.Fatal("nsswitch directory detection")
}
for body, want := range map[string][2]bool{
"UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true},
"STATE=lingering\n": {false, true},
"STATE=something-new\n": {false, false},
"UID=1001\n": {false, false},
} {
got, ok := parseLogindUserState(body)
if got != want[0] || ok != want[1] {
t.Errorf("logind %q: %v %v", body, got, ok)
}
}
if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 {
t.Fatalf("who: %v", u)
}
uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n")
if !ok || uid != 1204567 || !mobile {
t.Fatalf("dscl: %d %v %v", uid, mobile, ok)
}
if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" {
t.Fatalf("id: %v", g)
}
}
func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) {
rec := func(typ uint16, user string) []byte {
b := make([]byte, utmpRecordSize)
binary.LittleEndian.PutUint16(b, typ)
copy(b[utmpUserOffset:], user)
return b
}
var blob []byte
blob = append(blob, rec(utmpUserProcess, "pat")...)
blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended
blob = append(blob, rec(utmpUserProcess, "lee")...)
blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread
got := parseUtmpUsers(blob)
if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 {
t.Fatalf("utmp: %v", got)
}
}
+161 -3
View File
@@ -213,9 +213,17 @@ type agentObs struct {
// what makes a service RESTART the same entry rather than a new one
// (baseline.go). Empty where the platform does not supply one: darwin and
// windows never populate procFacts.Unit, so those fall back to the origin.
Unit string `json:"unit,omitempty"`
Evidence []evidence `json:"evidence,omitempty"`
raw string // the UNTRUNCATED command line, for classification only; never reported
Unit string `json:"unit,omitempty"`
// The account behind User (agentowner.go): domain_user, local_user,
// service, system or unknown; whether it administers this machine; and
// whether it has a login session now. yes, no or unknown each — unknown
// is what an unreadable token or group list says, never no. Empty only on
// an observation made outside scanAgents.
OwnerClass string `json:"owner_class,omitempty"`
OwnerAdmin string `json:"owner_admin,omitempty"`
OwnerInteractive string `json:"owner_interactive,omitempty"`
Evidence []evidence `json:"evidence,omitempty"`
raw string // the UNTRUNCATED command line, for classification only; never reported
// automation: the signature came from the automation tables, so the
// classifier gives this kind `automation` unless model traffic promotes it.
automation bool
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
}
extra := originExtras()
facts := procFactsReader()
owners := ownerFactsReader()
out := make([]agentObs, 0, len(byPid))
for pid, a := range byPid {
if faIsOwnChild(pid) {
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
if f, ok := facts(pid); ok {
a.Unit = f.Unit
}
// Who the owning account is, beside who it is called. Read here
// rather than in each platform's walk so a process the network
// signal alone caught (mergeConnections) gets the same answer.
o := owners(pid, a.User)
a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
if a.DelegationToken != "" {
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
return out, nil
}
// scanMCPProcessesFn is the seam a test uses to hand the correlation a
// process list; production never reassigns it.
var scanMCPProcessesFn = scanMCPProcesses
// mcpServersReport reads the declared tool servers and correlates them with
// the process list. It lives here, not in mcpconfig.go, because reading the
// process list runs `ps` on macOS: the config reader itself starts nothing,
// and the claims test holds it to that.
func mcpServersReport() ([]mcpServerObs, mcpConfigScan) {
declared, scan, read := cachedMCPConfigs()
if !scan.Supported {
scan.ProcessScan = "unsupported"
return []mcpServerObs{}, scan
}
procs, err := scanMCPProcessesFn(mcpWantedBases(declared))
switch {
case err == nil:
scan.ProcessScan = "ok"
case agentScanSupported:
scan.ProcessScan = "failed"
default:
scan.ProcessScan = "unsupported"
}
if !read {
// No read of the files has finished yet (timed_out says so). With
// nothing known to be declared, every tool-server-looking process
// would read as running_not_declared, which nobody has checked.
return []mcpServerObs{}, scan
}
out := correlateMCP(declared, procs, err == nil)
if out == nil {
out = []mcpServerObs{}
}
if len(out) >= mcpMaxServers {
scan.Truncated = true
}
return out, scan
}
// mcpCache holds the last complete read of the config files. The heartbeat
// is every 60 s and the read is up to a thousand files across every account;
// like the scheduler inventory (schedCacheFor) it is redone every ten
// minutes, while the process correlation above runs on every beat. One read
// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL,
// counted from when it started: a home on a network mount that stopped
// answering holds that one goroutine, delays one beat by at most that much,
// never the beats after it, and never starts a second read.
var mcpCache struct {
sync.Mutex
at time.Time
have bool
declared []mcpServerObs
scan mcpConfigScan
inflight chan struct{}
started time.Time // when the read in flight began
gen int
}
const mcpCacheFor = 10 * time.Minute
// mcpReadTimeout is how long one beat waits for a read in progress.
var mcpReadTimeout = 20 * time.Second
// cachedMCPConfigs is the last complete read. read is false when no read has
// finished yet, so the list says nothing about what is declared.
func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) {
mcpCache.Lock()
if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor {
d, s := mcpCache.declared, mcpCache.scan
mcpCache.Unlock()
return d, s, true
}
done := mcpCache.inflight
if done == nil {
done = make(chan struct{})
mcpCache.inflight, mcpCache.started = done, time.Now()
gen := mcpCache.gen
go func() {
d, s := readMCPConfigs()
mcpCache.Lock()
if mcpCache.gen == gen {
mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true
mcpCache.inflight = nil
}
mcpCache.Unlock()
close(done)
}()
}
wait := mcpReadTimeout - time.Since(mcpCache.started)
mcpCache.Unlock()
timedOut := false
if wait <= 0 {
// An earlier beat already waited the whole time for this read.
select {
case <-done:
default:
timedOut = true
}
} else {
t := time.NewTimer(wait)
select {
case <-done:
case <-t.C:
timedOut = true
}
t.Stop()
}
mcpCache.Lock()
defer mcpCache.Unlock()
if !mcpCache.have {
// Nothing read yet: say so, rather than "no servers".
return nil, mcpConfigScan{Supported: true, TimedOut: true}, false
}
s := mcpCache.scan
s.TimedOut = timedOut
return mcpCache.declared, s, true
}
// resetMCPConfigCache forgets the last read; a read still running is
// ignored when it finishes. Tests use it to start clean.
func resetMCPConfigCache() {
mcpCache.Lock()
defer mcpCache.Unlock()
mcpCache.gen++
mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil
}
var (
llmIPCache map[string]string
llmIPCacheAt time.Time
@@ -554,11 +695,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// done for nothing, on the user's own hardware.
var schedEntries []schedEntry
var schedScan schedScanReport
// Declared tool servers (mcpconfig.go): the agent clients' own config
// files, correlated with the process list. Same gate and same
// quiet-vs-blind rule as the schedulers: read only for a pairing entitled
// to observe, and the capability travels only from a build that can.
var mcpServers []mcpServerObs
var mcpScan mcpConfigScan
if observing {
schedEntries, schedScan = scanSchedulers()
if schedScan.Supported {
caps = append(caps, "scheduler-observe")
}
mcpServers, mcpScan = mcpServersReport()
if mcpScan.Supported {
caps = append(caps, "tool-server-observe")
}
}
// Context preparation (contextprep.go): the tools on this box that
// turn a user's bytes into their content — a PDF's text, an image's OCR.
@@ -674,6 +825,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// that read 0 crontabs is visibly different from one that read 40.
body["schedulers"] = schedEntries
body["scheduler_scan"] = schedScan
// The tool servers each agent client declares, and which are running
// (or running with no declaration). Paths, server names and command
// lines, so inside this gate with the agent list; env values and
// header values never leave mcpconfig.go, only their names and
// whether one looked like a secret.
body["tool_servers"] = mcpServers
body["tool_server_scan"] = mcpScan
// Where this machine keeps its own record — the findings journal and
// the signed accounting (machinestore.go, baselinesign.go). The host
// drafts a machine store at this root for the user to approve, once;
+73
View File
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// ownerFactsReader answers the three owner facts agentowner.go decides on.
// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the
// system's own tools, each a fixed argv under runTimed's timeout: `dscl .`
// (the LOCAL node only) for the uid and whether the account is a mobile copy
// of a directory account, `id -Gn` for its groups (nested groups resolved by
// the system), and `who` once per scan for who is logged in. Answers are kept
// per account for the scan, so ten agents of one person cost three commands.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
cache := map[string]ownerFacts{}
return func(pid int, user string) ownerFacts {
if user == "" || strings.ContainsAny(user, "/ \t\n") {
return unknownOwner
}
if f, ok := cache[user]; ok {
return f
}
if !sessionsRead {
sessionsRead = true
if out, err := runTimed("who"); err == nil {
sessions, sessionsOK = parseWhoUsers(string(out)), true
}
}
o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: sessions, SessionsKnown: sessionsOK}
out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName")
if err == nil {
if uid, mobile, ok := parseDsclUser(string(out)); ok {
o.UID = uid
o.LocalRecord = ownerYes
if mobile {
o.LocalRecord, o.DirectorySource = ownerNo, true
}
}
} else if dsclRecordMissing(err) {
// dscl ran and has no such record in the local node: the account
// comes from a directory. The uid then has to come from id.
o.LocalRecord, o.DirectorySource = ownerNo, true
if out, err := runTimed("id", "-u", "--", user); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
o.UID = n
}
}
}
if out, err := runTimed("id", "-Gn", "--", user); err == nil {
o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true
}
f := classifyUnixOwner(o)
cache[user] = f
return f
}
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: one `ps` sweep, every process with its parent and
// arguments. An error is a sweep that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
_, argvs, parents, err := psSweep()
if err != nil {
return nil, err
}
self := os.Getpid()
out := make([]mcpProc, 0, len(argvs))
for pid, argv := range argvs {
if pid != self {
out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv})
}
}
return out, nil
}
// procParent answers from the sweep scanProcesses just took.
func procParent(pid int) int {
psCache.Lock()
+132
View File
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: every process, with its parent and command line, from one
// /proc walk. The want set only matters on Windows, where reading a command
// line costs a handle per process. An error is a walk that did not happen,
// and then no running state is guessed.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil, fmt.Errorf("read %s: %w", procRoot, err)
}
self := os.Getpid()
var out []mcpProc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self || !e.IsDir() {
continue
}
p := mcpProc{PID: pid}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil {
p.PPID = parseProcStatPPID(string(raw))
}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil {
p.Argv = splitNul(raw)
}
out = append(out, p)
}
return out, nil
}
// procParent reads one process's parent pid from /proc/<pid>/stat.
func procParent(pid int) int {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
@@ -126,6 +155,109 @@ func procUser(pid int) string {
return ""
}
// ownerFactsReader answers, per process, the three facts about its owner that
// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group,
// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once
// per scan, on first use, and every process looks itself up in them.
//
// The uid and groups come from /proc/<pid>/status, so they are what the
// PROCESS holds, the same source procUser names it from.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var (
loaded bool
groupNames map[int]string
groupsOK bool
passwd map[int]bool
passwdOK bool
sysMax int
directory bool
sudoers map[string]bool
sudoKnown bool
sessions map[string]bool
sessionsOK bool
logindUsers = identityPath("/run/systemd/users")
)
load := func() {
loaded = true
if b, err := os.ReadFile(identityPath("/etc/group")); err == nil {
groupNames, groupsOK = parseGroupNames(string(b)), true
}
if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil {
passwd, passwdOK = parsePasswdUIDs(string(b)), true
}
sysMax = linuxSystemUIDMax
if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil {
sysMax = parseLoginDefsSysMax(string(b))
}
if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil {
directory = nsswitchHasDirectory(string(b))
}
var partial string
sudoers, partial = sudoPrincipals()
sudoKnown = partial == ""
// Who has a session: logind's own record when it runs, else utmp.
// Neither readable leaves the answer unknown rather than "nobody".
if _, err := os.Stat(logindUsers); err != nil {
if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil {
sessions, sessionsOK = parseUtmpUsers(b), true
}
}
}
return func(pid int, user string) ownerFacts {
if !loaded {
load()
}
raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status")
if err != nil {
return unknownOwner
}
realUID, effUID, gids, ok := parseStatusIDs(string(raw))
if !ok {
return unknownOwner
}
o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory,
SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true,
SudoNamed: sudoers[user], SudoKnown: sudoKnown}
if passwdOK {
o.LocalRecord = yesNo(passwd[realUID])
}
if groupsOK {
o.GroupsKnown = true
for _, g := range gids {
if n, ok := groupNames[g]; ok {
o.Groups = append(o.Groups, n)
}
}
}
switch {
case sessionsOK:
o.Sessions, o.SessionsKnown = sessions, true
default:
b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID))
switch {
case err == nil:
if live, ok := parseLogindUserState(string(b)); ok {
o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true
}
case os.IsNotExist(err):
// logind keeps a file for every user it tracks; none means
// this account has no session and no lingering services.
if _, derr := os.Stat(logindUsers); derr == nil {
o.Sessions, o.SessionsKnown = map[string]bool{}, true
}
}
}
f := classifyUnixOwner(o)
if effUID == 0 {
// A process running with effective uid 0 holds root whatever
// account started it: that is the administrator answer that
// matters, while the class stays the account's own.
f.Admin = ownerYes
}
return f
}
}
func procCmdline(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil {
+8
View File
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
func procExe(pid int) string { return "" }
func procParent(pid int) int { return 0 }
func processParents() map[int]int { return map[int]int{} }
// ownerFactsReader: no reader here, so every owner fact is unknown.
func ownerFactsReader() func(pid int, user string) ownerFacts {
return func(int, string) ownerFacts { return unknownOwner }
}
// scanMCPProcesses: no enumerator, so running state is never measured here.
func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator }
+169 -5
View File
@@ -306,6 +306,7 @@ func processParents() map[int]int {
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) {
winBeginSIDScan()
out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against. Every process comes from one Toolhelp snapshot with its
// parent; the command line, which costs a handle and a memory read, is read
// only for a program in want (interpreters, package runners, and every
// program a declared server names) or whose own name carries the word mcp.
// The rest keep their parent link with no command line, which is all the
// correlation needs from them. An error is a snapshot that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, fmt.Errorf("process snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
self := os.Getpid()
var out []mcpProc
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
pid := int(e.ProcessID)
if pid <= 0 || pid == self {
continue
}
p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)}
exe := windows.UTF16ToString(e.ExeFile[:])
if base := binBase(exe); want[base] || mcpWord(base) {
cmdline := ""
if h, herr := winOpenForRead(pid); herr == nil {
cmdline = winCmdline(h)
windows.CloseHandle(h)
}
if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 {
p.Argv = argv
} else {
p.Argv = []string{exe}
}
}
out = append(out, p)
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, fmt.Errorf("process walk: %w", err)
}
return out, nil
}
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
var (
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
if err != nil || tu.User.Sid == nil {
return ""
}
account, domain, _, err := tu.User.Sid.LookupAccount("")
return winAccountLabel(tu.User.Sid.String(), winScanSIDNames())
}
// winSIDScan is the current scan's account-name cache (sidnames.go), shared
// by winTokenUser and ownerFactsReader so a SID is looked up once per scan
// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a
// new one; the readers that run after it in scanAgents use that one.
var winSIDScan struct {
sync.Mutex
names *sidNames
}
func winBeginSIDScan() *sidNames {
n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots)
winSIDScan.Lock()
winSIDScan.names = n
winSIDScan.Unlock()
return n
}
func winScanSIDNames() *sidNames {
winSIDScan.Lock()
n := winSIDScan.names
winSIDScan.Unlock()
if n == nil {
return winBeginSIDScan()
}
return n
}
// winLookupSID is LookupAccountSid on this machine for one SID string.
func winLookupSID(s string) (string, string, error) {
a, err := winLookupSIDAccount(s)
return a.Account, a.Domain, err
}
// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded:
// callers go through sidNames or winAccounts.
func winLookupSIDAccount(s string) (sidAccount, error) {
sid, err := windows.StringToSid(s)
if err != nil {
return tu.User.Sid.String()
return sidAccount{}, err
}
if domain != "" {
return domain + `\` + account
account, domain, typ, err := sid.LookupAccount("")
return sidAccount{Account: account, Domain: domain, Type: typ}, err
}
// winAccounts is the process-wide account cache for lookups made outside a
// scan: the approvals pipe and the folder agent's drop (sidnames.go).
var winAccounts = newSIDDirectory(winLookupSIDAccount)
// ownerFactsReader answers the three owner facts agentowner.go decides on,
// from each process's own token: its user SID and that SID's domain, and its
// group SIDs (deny-only ones included, which is how an administrator running
// without elevation still reads as an administrator account). The session
// list is read once per scan and needs LocalSystem, which the system service
// is; elsewhere it is left unknown and the token's own logon type answers
// what it can (classifyWinOwner). The domain comes from the scan's shared
// account-name cache (winScanSIDNames), so a SID winTokenUser already looked
// up is not asked again, and an unreachable domain controller leaves the
// class unknown instead of holding the scan.
func ownerFactsReader() func(pid int, user string) ownerFacts {
computer, _ := windows.ComputerName()
names := winScanSIDNames()
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
return func(pid int, _ string) ownerFacts {
if !sessionsRead {
sessionsRead = true
sessions, sessionsOK = winSessionSIDs()
}
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return unknownOwner
}
defer windows.CloseHandle(h)
var tok windows.Token
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
return unknownOwner
}
defer tok.Close()
o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
o.SID = tu.User.Sid.String()
o.Domain = winAccountDomain(o.SID, names)
}
if tg, err := tok.GetTokenGroups(); err == nil {
o.Groups, o.GroupsKnown = map[string]bool{}, true
for _, g := range tg.AllGroups() {
if g.Sid != nil {
o.Groups[g.Sid.String()] = true
}
}
}
return classifyWinOwner(o)
}
return account
}
// winSessionSIDs is the set of user SIDs with an active or disconnected
// Remote Desktop Services session (the console is one). ok is false when the
// list could not be read: WTSQueryUserToken needs LocalSystem's privilege.
func winSessionSIDs() (map[string]bool, bool) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil {
return nil, false
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
list := unsafe.Slice(info, count)
out := map[string]bool{}
for _, s := range list {
if s.State != windows.WTSActive && s.State != windows.WTSDisconnected {
continue
}
var tok windows.Token
if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil {
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false
}
continue // a session with no user (the services session, a login screen)
}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
out[tu.User.Sid.String()] = true
}
tok.Close()
}
return out, true
}
func procUser(pid int) string {
+40 -3
View File
@@ -810,6 +810,11 @@ type pairingEntry struct {
// --replace, so a pending host re-presenting a token keeps its original age
// (the 10-minute unapproved clock does not restart). nil when the directory
// cannot be read — the host keeps what it has, as with peers.
//
// A pairing whose file this build refuses (Config.invalid) is left out: it is
// never served, so to a host it is not on this box. A host knows the states
// active, pending and revoked and may read any other as active, so "invalid"
// is never sent. The machine's own status and setup page still show it.
func pairingsReport() []pairingEntry {
cfgs, err := loadPairings()
if err != nil {
@@ -817,7 +822,7 @@ func pairingsReport() []pairingEntry {
}
out := make([]pairingEntry, 0, len(cfgs))
for _, c := range cfgs {
if c.HostName == "" {
if c.HostName == "" || c.invalid != "" {
continue
}
out = append(out, pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName),
@@ -849,7 +854,7 @@ func unapprovedAttempts(now time.Time) []peerAnnounce {
if err != nil || now.Sub(since) < unapprovedAfter {
continue
}
out = append(out, peerAnnounce{HostName: e.HostName, Handle: e.Handle, State: e.State, Since: e.Since})
out = append(out, peerAnnounce(e))
}
return out
}
@@ -978,6 +983,20 @@ func cmdRepinAnchor() error {
return nil
}
// anchorAlreadyHolds: the anchor names an active pairing here, and host
// (claim-anchor --host, "" for none) names that same pairing.
func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
c := pairingFor(cfgs, prev.AnchorHost)
if c == nil || !c.active() {
return false
}
if host == "" {
return true
}
named, err := pickPairing(cfgs, host)
return err == nil && named.HostName == c.HostName
}
// pickPairing resolves --host against this box's pairings. It accepts the full
// host_name the box reports, or just the handle in front of it, because the
// name the user is looking at depends on which surface they read it from —
@@ -1021,6 +1040,17 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
return err
}
prev := currentAnchor()
// Already anchored to the host this asks for (no token, no other host
// named), and that host's pairing is active: done, not an error. `pair`
// anchors a networked box to its first host by itself, and an installer
// that runs claim-anchor after pairing must find that a success. An anchor
// whose pairing is pending or revoked is not done, and is refused below as
// before. --host takes the short handle as well as the full name
// (pickPairing), so the same host named either way is the same request.
if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) {
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
return nil
}
if prev != nil && !rotate {
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
}
@@ -1088,9 +1118,16 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
case len(cfgs) == 0:
return fmt.Errorf("this box has no pairing yet — pair it to your host first (`privatae-bastion pair <token>`, token from Network → Set up a bastion), then `privatae-bastion claim-anchor` with no token")
default:
return fmt.Errorf("this box serves %d hosts — say which one should hold the anchor:\n%s\n e.g. `privatae-bastion claim-anchor --host %s`\nThe anchor host approves every pairing made here from then on; the other pairings keep the lanes they already have.",
return fmt.Errorf("this box serves %d hosts — say which one should hold the anchor:\n%s\n e.g. `privatae-bastion claim-anchor --host %s`\nThe anchor host approves every pairing made here from then on; the other pairings keep the lanes they already have",
len(cfgs), " "+strings.Join(hostNames(cfgs), "\n "), hostNames(cfgs)[0])
}
// A pairing whose file this build refuses is never served, so the box
// could not reach the host it would be anchored to, and that host would
// approve every pairing made here. Refused with the reason, before
// anything is written.
if cfg.invalid != "" {
return fmt.Errorf("%s cannot hold the anchor: %s", cfg.HostName, cfg.invalid)
}
// REVOKED is not "waiting", it is a decision, and it must not be undone by
// a command the user ran to do something else. pair.go holds the same line
// for a re-pair (pending again, never straight back to active),
+48 -4
View File
@@ -606,7 +606,7 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "machine authority") {
t.Fatalf("claim-anchor must need machine authority whether or not a token is given: %v", err)
}
return
t.Skip("the rest needs root: without machine authority claim-anchor stops at the refusal checked above")
}
t.Run("local mode refuses — the token is not what gates this", func(t *testing.T) {
@@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
}
})
t.Run("already anchored — still refused without --rotate", func(t *testing.T) {
t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
// `pair` anchors a networked box by itself, and an installer that runs
// claim-anchor afterwards must find that done, not an error.
freshBox(t, true)
writePairing(t, tMember)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") {
t.Fatalf("dropping the token must not let a second claim through: %v", err)
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
t.Fatalf("naming the anchor host itself must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
t.Fatalf("the anchor must be untouched: %+v", a)
}
})
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
// A success here would tell an installer the anchor holds while its
// host has no active lanes on this box.
for _, st := range []string{pairingPending, pairingRevoked} {
freshBox(t, true)
writePairingState(t, tMember, st)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil {
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
}
if got := loadState(t, tMember); got != st {
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
}
}
})
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
freshBox(t, true)
writePairing(t, tMember)
writePairing(t, tAnchor)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("the anchor must be untouched: %+v", a)
}
})
+32
View File
@@ -138,6 +138,30 @@ type pipeClient struct {
// "" when nobody is.
SessionUserSID string
SessionActive bool // WTSActive: the session someone is in front of
// NameTimedOut: the account directory did not answer for UserSID within
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
// account approved. pipeApprove refuses; list and deny go on, by SID.
NameTimedOut bool
}
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
// request is read, so the lookup gets well under that: a directory that does
// not answer costs one connection 2 s, and holds at most one goroutine for
// the pipe however many connections come.
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
// pipeClientName fills c.UserName from the account directory, or sets
// c.NameTimedOut when the directory did not answer in time. A SID with no
// account leaves UserName empty: the log shows the SID, as before.
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
acct, outcome := dir.resolve(c.UserSID, lane)
switch outcome {
case sidResolved:
c.UserName = sidAccountLabel(acct)
case sidTimedOut:
c.NameTimedOut = true
}
}
// purposeDigest is the sha256 of a purpose as the tray carries it back.
@@ -330,6 +354,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
var pipeApproveMu sync.Mutex
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
// approval's audit line names the account that gave it, and when the
// directory could not say whose account this is, nothing is approved.
// Before the throttle and the code, so this counts as no wrong code.
if c.NameTimedOut {
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
" in time; try again in a minute, or approve from an Administrator terminal"}
}
pipeApproveMu.Lock()
defer pipeApproveMu.Unlock()
code := strings.TrimSpace(req.Code)
+3 -6
View File
@@ -311,12 +311,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
}
c.UserSID = u.User.Sid.String()
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil {
c.UserName = name
if dom != "" {
c.UserName = dom + `\` + name
}
}
// Bounded and cached (sidnames.go): a directory that does not answer
// leaves NameTimedOut set, and pipeApprove refuses.
pipeClientName(&c, winAccounts, pipeSIDLane)
var sess, n uint32
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
+179
View File
@@ -0,0 +1,179 @@
package main
// Audit rows to the system log, as well as to their files.
//
// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a
// pairing's audit.jsonl for terminals, root commands, store ops and op runs)
// lives here, on this machine. The host can fetch them over the tunnel but
// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host
// as the only central view of a fleet, and the host is the agent the audit is
// about. So each row is also written to the machine's own system log, where
// whatever already collects that log (journald or syslog forwarding, a log
// shipper, a SIEM) takes it to a store the host cannot write:
//
// Linux, macOS syslog, facility auth, tag privatae-bastion-audit
// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit)
// Windows the Application event log, source PrivataeBastionAudit, event id 1
//
// The system-log line is the file's row with two fields added in front of it:
// `audit`, which file the row went to, and `pairing`, the pairing it belongs to
// ("" for a box-level file). The file stays the record: a system log that is
// missing or refuses a write costs that copy and nothing else, and is never a
// reason to refuse or slow the op being audited.
//
// A root command's text is the one thing the file has that the system-log line
// does not: it is whatever the host typed, a password on a command line
// included, and the system log is readable by more accounts than the 0600 file
// and forwarded off the machine by design. The line keeps command_sha256 and
// command_bytes, so a collector can still match it to the file and the
// transcript and see if either was changed.
//
// The write itself never runs on the audited op's goroutine. The syslog socket
// is a blocking write: a journald or syslog daemon that is stuck or slow would
// otherwise hold every audited op, and every other audit writer behind the
// lock, for as long as it stays stuck. Lines go into a bounded queue that one
// goroutine drains; a full queue drops the line and counts it, and the next
// line queued after the gap carries the count (auditDroppedField).
import (
"bytes"
"encoding/json"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
const (
auditSyslogTag = "privatae-bastion-audit"
auditEventSource = "PrivataeBastionAudit"
auditEventID = 1
auditFilePairing = "pairing_audit.jsonl"
auditFilePairingD = "audit.jsonl"
)
// auditSink takes one line for the system log. A variable so a test can watch
// the lines; the default queues it for the platform's writer and never blocks.
var auditSink = auditEnqueue
const (
// auditQueueMax lines wait for the system log; beyond that they are
// dropped and counted. A burst of audited ops far larger than any lane
// produces fits; a system log stuck for good holds no more than this.
auditQueueMax = 4096
// auditDroppedField is added to the first line queued after a drop: how
// many lines the system log lost just before it, so the gap shows where.
auditDroppedField = "system_log_dropped"
// auditFlushOnExit bounds how long a finished command waits for its rows
// to reach a slow system log.
auditFlushOnExit = 2 * time.Second
)
// auditQueue is the bounded queue in front of a system-log writer, drained by
// one goroutine so lines keep their order.
type auditQueue struct {
ch chan []byte
write func([]byte)
once sync.Once
dropped atomic.Int64
pending atomic.Int64 // queued or being written
}
func newAuditQueue(n int, write func([]byte)) *auditQueue {
return &auditQueue{ch: make(chan []byte, n), write: write}
}
// The box's one queue, in front of systemAuditWrite (auditlog_*.go).
var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) })
func auditEnqueue(line []byte) { auditQ.enqueue(line) }
// auditFlush waits, at most d, for queued lines to reach the system log. A
// command that audits and then exits (pair, claim-anchor, set-mode) calls it
// on the way out (main.go), so its rows are not lost with the process; a
// stuck system log costs that wait and nothing more.
func auditFlush(d time.Duration) { auditQ.flush(d) }
// enqueue hands one line to the drain goroutine, or drops it and counts it
// when the queue is full. It never waits on the writer.
func (q *auditQueue) enqueue(line []byte) {
q.once.Do(func() { go q.drain() })
n := q.dropped.Swap(0)
if n > 0 {
line = auditWithDropped(line, n)
}
q.pending.Add(1)
select {
case q.ch <- line:
default:
q.pending.Add(-1)
q.dropped.Add(n + 1)
}
}
func (q *auditQueue) drain() {
for line := range q.ch {
q.write(line)
q.pending.Add(-1)
}
}
func (q *auditQueue) flush(d time.Duration) {
deadline := time.Now().Add(d)
for q.pending.Load() > 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
}
// auditWithDropped adds the drop count to a line (every line here is the
// JSON object auditToSystemLog made).
func auditWithDropped(line []byte, n int64) []byte {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(line))
dec.UseNumber()
if err := dec.Decode(&fields); err != nil {
return line
}
fields[auditDroppedField] = n
out, err := json.Marshal(fields)
if err != nil {
return line
}
return out
}
// auditToSystemLog sends one audit row, as it was written to its file, to the
// system log, with the file's name and the pairing in front.
func auditToSystemLog(file, pairing string, rec []byte) {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(rec))
dec.UseNumber() // numbers go out exactly as the file has them
if err := dec.Decode(&fields); err != nil {
return
}
if cmd, ok := fields["command"].(string); ok {
// The text stays in the file; see the top of this file.
delete(fields, "command")
if _, ok := fields["command_bytes"]; !ok {
fields["command_bytes"] = len(cmd)
}
}
fields["audit"] = file
fields["pairing"] = pairing
line, err := json.Marshal(fields)
if err != nil {
return
}
auditSink(line)
}
// auditPairingOfDir names the pairing of a per-pairing audit file from its
// directory: the last path element, which is the pairing's host name
// (Config.dcnDir: dcn/<host_name>), except for a legacy pairing that kept the
// old shared directory, where it is that directory's name.
func auditPairingOfDir(dir string) string {
if dir == "" {
return ""
}
return filepath.Base(filepath.Clean(dir))
}
+34
View File
@@ -0,0 +1,34 @@
//go:build !windows
package main
import (
"log/syslog"
"sync"
)
var (
auditSyslogMu sync.Mutex
auditSyslog *syslog.Writer
)
// systemAuditWrite sends one line to syslog (journald on a systemd machine),
// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on
// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped
// after a failed write, so the next row tries again; a machine with no syslog
// socket costs the copy, never the row in the file.
func systemAuditWrite(line []byte) {
auditSyslogMu.Lock()
defer auditSyslogMu.Unlock()
if auditSyslog == nil {
w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
return
}
auditSyslog = w
}
if err := auditSyslog.Info(string(line)); err != nil {
_ = auditSyslog.Close()
auditSyslog = nil
}
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// The sink the binary ships with, read before auditlog_test.go's init swaps
// it out (package variables are set before any init runs).
var auditSinkShipped = auditSink
// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the
// platform's writer called on the audited op's own goroutine.
func TestAuditSinkIsTheQueue(t *testing.T) {
if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() {
t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck")
}
}
// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns,
// far more rows than the queue holds are all taken at once; the ones past the
// bound are dropped and counted, and the count rides on the next line that
// gets into the queue.
func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) {
release := make(chan struct{})
var mu sync.Mutex
var got []string
q := newAuditQueue(8, func(l []byte) {
<-release
mu.Lock()
got = append(got, string(l))
mu.Unlock()
})
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
q.enqueue([]byte(`{"n":1}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck system log")
}
close(release)
q.flush(5 * time.Second)
// Nothing lost without a count: every one of the 1001 rows is either a
// line written or counted on a line written after the gap.
q.enqueue([]byte(`{"n":2}`))
q.flush(5 * time.Second)
mu.Lock()
defer mu.Unlock()
var counted float64
for _, l := range got {
m := map[string]any{}
if err := json.Unmarshal([]byte(l), &m); err != nil {
t.Fatal(err)
}
if n, ok := m[auditDroppedField].(float64); ok {
counted += n
}
}
if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 {
t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted)
}
}
// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in
// the 0600 file; the system-log line carries its digest and length.
func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`))
m := map[string]any{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatal(err)
}
if strings.Contains(line, "hunter2") || m["command"] != nil {
t.Fatalf("the command text reached the system log: %s", line)
}
if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) {
t.Fatalf("the line must keep the digest and the length: %s", line)
}
auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`))
if !strings.Contains(line, `"command_bytes":70000`) {
t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line)
}
}
+69
View File
@@ -0,0 +1,69 @@
//go:build !windows
package main
import (
"log/syslog"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a
// datagram socket nobody reads blocks once the socket's queue is full, as
// /dev/log does while journald is stuck. Audit rows behind the queue all
// return at once; the same writer called inline (as 0.54.0 first did) does not.
func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) {
dir, err := os.MkdirTemp("", "al")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
sock := filepath.Join(dir, "s")
l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
t.Fatal(err)
}
// Closing the reading end is what lets the stuck writes below return
// (refused) so their goroutines end; the writer itself is never closed,
// since its Close would wait behind a write that is stuck.
t.Cleanup(func() { l.Close() })
w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
t.Fatal(err)
}
write := func(line []byte) { _ = w.Info(string(line)) }
// The inline writer does block: without this the test proves nothing.
inline := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
write([]byte(`{"op":"x"}`))
}
close(inline)
}()
select {
case <-inline:
t.Skip("this kernel never blocked a datagram writer; nothing to bound")
case <-time.After(2 * time.Second):
}
q := newAuditQueue(64, write)
done := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
q.enqueue([]byte(`{"op":"y"}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck syslog socket")
}
if q.dropped.Load() == 0 {
t.Fatal("rows past the bound must be counted as dropped")
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// The test binary never writes to the real system log: the suite's audit
// rows would otherwise land in the journal of whatever machine runs it.
func init() { auditSink = func([]byte) {} }
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
func auditSinkCapture(t *testing.T) func() []map[string]any {
t.Helper()
var mu sync.Mutex
var lines [][]byte
prev := auditSink
auditSink = func(line []byte) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, append([]byte(nil), line...))
}
t.Cleanup(func() { auditSink = prev })
return func() []map[string]any {
mu.Lock()
defer mu.Unlock()
var out []map[string]any
for _, l := range lines {
m := map[string]any{}
if err := json.Unmarshal(l, &m); err != nil {
t.Fatalf("system-log line is not JSON: %s", l)
}
out = append(out, m)
}
return out
}
}
func auditFileRows(t *testing.T, path string) []map[string]any {
t.Helper()
fh, err := os.Open(path)
if err != nil {
t.Fatalf("audit file %s: %v", path, err)
}
defer fh.Close()
var out []map[string]any
sc := bufio.NewScanner(fh)
for sc.Scan() {
m := map[string]any{}
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
t.Fatalf("audit row is not JSON: %s", sc.Text())
}
out = append(out, m)
}
return out
}
// auditSameRow: the system-log line is the file's row, every field equal,
// with `audit` and `pairing` added.
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
t.Helper()
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
}
for k, v := range file {
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
}
}
if len(logged) != len(file)+2 {
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
}
}
func jsonEq(a, b any) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
// it writes to its file to the system log as well, unchanged, naming the file
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
// terminals and root commands.
func TestAuditRowsReachTheSystemLog(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
got := auditSinkCapture(t)
cfgDir, err := configDir()
if err != nil {
t.Fatal(err)
}
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
pairingAudit("pairing_add", "other.host.example", "ok")
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
lines := got()
if len(lines) != 5 {
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
}
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
}
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
}
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
// costs the copy only; the row is still written to its file.
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
prev := auditSink
auditSink = func([]byte) {}
t.Cleanup(func() { auditSink = prev })
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
t.Fatalf("file rows = %d, want 1", len(rows))
}
}
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
// the digits the file has, not as a float.
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
t.Fatalf("system-log line %s does not carry the id exactly", line)
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package main
import (
"strings"
"sync"
"golang.org/x/sys/windows/svc/eventlog"
)
var (
auditEventMu sync.Mutex
auditEventLog *eventlog.Log
)
// systemAuditWrite sends one line to the Application event log under the
// source PrivataeBastionAudit, event id 1, at information level. The source
// is registered when the system service is installed
// (registerAuditEventSource); before that, or if the log refuses, the copy is
// lost and the row in the file is not. Called only from auditQ's drain
// (auditlog.go): the event log service can be slow to answer.
func systemAuditWrite(line []byte) {
auditEventMu.Lock()
defer auditEventMu.Unlock()
if auditEventLog == nil {
l, err := eventlog.Open(auditEventSource)
if err != nil {
return
}
auditEventLog = l
}
if err := auditEventLog.Info(auditEventID, string(line)); err != nil {
_ = auditEventLog.Close()
auditEventLog = nil
}
}
// registerAuditEventSource registers PrivataeBastionAudit as an Application
// event log source, so the rows show with their own source name. Already
// registered is success.
func registerAuditEventSource() error {
err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error)
if err != nil && strings.Contains(err.Error(), "already exists") {
return nil
}
return err
}
// removeAuditEventSource unregisters the source when the service is removed.
// Events already written stay in the log.
func removeAuditEventSource() {
_ = eventlog.Remove(auditEventSource)
}
-43
View File
@@ -16,7 +16,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
@@ -64,48 +63,6 @@ func drive(t *testing.T, backupRoot string, req backupFetchReq) (string, string,
}
}
func awaitJob(t *testing.T, backupRoot, id string) backupJob {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, _, code := drive(t, backupRoot, backupFetchReq{Op: "status", JobID: id})
if code != 0 {
t.Fatalf("status exited %d", code)
}
var j backupJob
if err := json.Unmarshal([]byte(out), &j); err != nil {
t.Fatalf("status is not JSON: %v (%s)", err, out)
}
if j.State != "running" {
return j
}
time.Sleep(25 * time.Millisecond)
}
t.Fatal("job never left running")
return backupJob{}
}
func startAndAwait(t *testing.T, root string, req backupFetchReq) backupJob {
t.Helper()
out, errOut, code := drive(t, root, req)
if code != 0 {
t.Fatalf("start exited %d: %s", code, errOut)
}
var started struct {
ID string `json:"id"`
State string `json:"state"`
}
if err := json.Unmarshal([]byte(out), &started); err != nil {
t.Fatalf("start reply is not JSON: %v (%s)", err, out)
}
if started.State == "done" { // already-present short circuit
var j backupJob
_ = json.Unmarshal([]byte(out), &j)
return j
}
return awaitJob(t, root, started.ID)
}
func artifactServer(t *testing.T, body []byte) (*httptest.Server, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+3
View File
@@ -655,6 +655,9 @@ func cmdBackupDecide(host, prefix string, approve bool) error {
}
}
r, err = resealApprove(dir, who, id, code)
if err != nil {
return err
}
} else {
r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "")
}
+34
View File
@@ -10,6 +10,7 @@ package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -149,3 +150,36 @@ func TestHostNumberComesFromTheTunnelAddress(t *testing.T) {
}
}
}
// With a second factor enrolled, a wrong code is refused by resealApprove,
// and cmdBackupDecide returns that refusal. It once checked the outer err
// while the approval's error went to the one its `who, err :=` declared, so
// a wrong code fell through to r.State on a nil request and panicked.
func TestResealDecideWithAWrongCodeReturnsTheRefusal(t *testing.T) {
dirs := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})
fx := newBackupFixture(t, "")
fx.installAsBastionKey(t)
own, _ := age.GenerateX25519Identity()
id := resealStartAs(t, fixtureHost, dirs[fixtureHost], resealReq{Op: "start", Sidecar: string(fx.sidecar(t)),
To: own.Recipient().String(), Stamp: fixtureStamp, HostLabel: "B"})
if err := stepupSave(dirs[fixtureHost], &stepupFile{SecretB32: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
EnrolledAt: "2026-08-29T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = w.WriteString("not-a-code\n")
_ = w.Close()
realStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = realStdin; _ = r.Close() })
if err := cmdBackupDecide(fixtureHost, id, true); err == nil {
t.Fatal("a wrong second-factor code approved the request")
}
if st := resealStatusAs(t, fixtureHost, dirs[fixtureHost], id); st["state"] == "done" || st["sidecar"] != "" {
t.Fatalf("a wrong code handed the backup key over: %v", st)
}
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"sync"
"time"
)
// A beat out of turn, for every pairing on this box.
//
// Each running pairing beats its own Host on a 60-second ticker, and every
// beat carries the box's pairings as `peers` with the state of each (peers.go).
// That is how every Host on the machine learns that a pairing went active or was
// revoked. A pairing_grant_sync or pairing_grant_drop changes that state on disk
// at once, but nothing beat: every Host kept the old state for up to a minute.
// So a pairing approved a second after a beat still read as pending for the
// next minute, and a Host asking to connect to it in that time was refused. The
// anchor Host can read its own confirmed grant, but no other Host on the
// machine has one, and a revoke reached the other Hosts no sooner.
//
// So a change to a pairing's state asks every running pairing's beat loop to beat
// now. The signal carries nothing: the beat reads the pairings from disk as it
// always does, so it reports the state the change wrote. One pending signal per
// loop is kept and the rest are dropped, so a burst of changes is one extra beat
// per Host, never one per change.
var beatNow = struct {
sync.Mutex
subs map[chan struct{}]struct{}
}{subs: map[chan struct{}]struct{}{}}
// subscribeBeatNow registers one beat loop. The channel holds at most one
// pending signal; stop unregisters it.
func subscribeBeatNow() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
beatNow.Lock()
beatNow.subs[ch] = struct{}{}
beatNow.Unlock()
return ch, func() {
beatNow.Lock()
delete(beatNow.subs, ch)
beatNow.Unlock()
}
}
// kickBeats asks every registered beat loop to beat now. It never blocks: a
// loop that already has a signal pending, or is mid-beat, beats once more.
func kickBeats() {
beatNow.Lock()
defer beatNow.Unlock()
for ch := range beatNow.subs {
select {
case ch <- struct{}{}:
default:
}
}
}
// runBeatLoop beats every `every`, and also whenever kickBeats asks. beforeTick
// runs before a ticked beat only (the tunnel's path failover, which belongs to
// the clock, not to a pairing change); it may be nil. It returns when ctx ends.
func runBeatLoop(ctx context.Context, every time.Duration, beforeTick, beat func()) {
kick, stop := subscribeBeatNow()
defer stop()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if beforeTick != nil {
beforeTick()
}
beat()
case <-kick:
beat()
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build !windows
// Tagged !windows like anchor_test.go, whose box helpers (setupBox, driveOp,
// signTerms) it uses; beatnow_test.go runs the signal and the loop everywhere.
package main
import (
"crypto/ed25519"
"os"
"testing"
"time"
)
// optest v0.16.145: an approval left every Host on the machine reading
// "pending" until the next 60-second beat. A grant that flips a pairing active,
// and a revoke, ask every pairing to beat now; a refused grant, and a re-sync
// of a pairing that is already active, change no state and ask nothing.
func TestPairingStateChangeBeatsEveryHost(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
hn, _ := os.Hostname()
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
kick, stop := subscribeBeatNow()
defer stop()
_, otherPriv, _ := ed25519.GenerateKey(nil)
terms, sig := signTerms(t, otherPriv, grantTerms(tMember, []string{"observe"}, future))
if _, _, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 {
t.Fatalf("a grant under another key must be refused (126): %d", code)
}
if signalled(kick, 100*time.Millisecond) {
t.Fatal("a refused grant asked for a beat")
}
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("a good grant must be accepted: exit %d, %s", code, errOut)
}
if loadState(t, tMember) != pairingActive {
t.Fatal("grant_sync must flip the pairing active")
}
if !signalled(kick, time.Second) {
t.Fatal("a grant that flipped a pairing active did not ask every pairing to beat now")
}
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"observe", "dcn"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("a re-sync of an active pairing must be accepted: exit %d, %s", code, errOut)
}
if signalled(kick, 100*time.Millisecond) {
t.Fatal("a re-sync that changed no pairing state asked for a beat")
}
terms, sig = signTerms(t, priv, map[string]any{"anchor": tAnchor, "issued_at": time.Now().UTC().Format(time.RFC3339),
"kind": "pairing_revoke", "server": hn, "subject": tMember})
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_drop", []string{tMember, terms, sig}); code != 0 {
t.Fatalf("a good revoke must be accepted: %d %s", code, errOut)
}
if loadState(t, tMember) != pairingRevoked {
t.Fatal("grant_drop must mark the pairing revoked")
}
if !signalled(kick, time.Second) {
t.Fatal("a revoke did not ask every pairing to beat now")
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"context"
"sync/atomic"
"testing"
"time"
)
// signalled reports whether ch carries a signal within d.
func signalled(ch <-chan struct{}, d time.Duration) bool {
select {
case <-ch:
return true
case <-time.After(d):
return false
}
}
// One signal reaches every registered loop, a burst is one pending signal per
// loop, and a loop that stopped is not signalled.
func TestKickBeatsReachesEveryLoopOnce(t *testing.T) {
a, stopA := subscribeBeatNow()
defer stopA()
b, stopB := subscribeBeatNow()
gone, stopGone := subscribeBeatNow()
stopGone()
kickBeats()
kickBeats()
kickBeats()
stopB()
for name, ch := range map[string]<-chan struct{}{"a": a, "b": b} {
if !signalled(ch, time.Second) {
t.Fatalf("loop %s was not asked to beat", name)
}
if signalled(ch, 50*time.Millisecond) {
t.Fatalf("loop %s holds more than one pending beat after a burst", name)
}
}
if signalled(gone, 50*time.Millisecond) {
t.Fatal("a loop that unregistered was still signalled")
}
}
// The beat loop beats on a kick without waiting for its tick, and the
// tick-only step (path failover) does not run for it.
func TestRunBeatLoopBeatsOnKick(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var beats, ticks atomic.Int32
done := make(chan struct{})
go func() {
defer close(done)
runBeatLoop(ctx, time.Hour, func() { ticks.Add(1) }, func() { beats.Add(1) })
}()
// The loop registers inside its goroutine; keep asking until it hears.
deadline := time.Now().Add(3 * time.Second)
for beats.Load() == 0 && time.Now().Before(deadline) {
kickBeats()
time.Sleep(10 * time.Millisecond)
}
if beats.Load() == 0 {
t.Fatal("a kicked beat loop did not beat before its 1-hour tick")
}
if ticks.Load() != 0 {
t.Fatal("a kicked beat ran the tick-only step")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the beat loop did not return when its context ended")
}
beatNow.Lock()
left := len(beatNow.subs)
beatNow.Unlock()
if left != 0 {
t.Fatalf("a returned beat loop stayed registered (%d left)", left)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"os"
"path/filepath"
"regexp"
"testing"
)
// The Go toolchain image is pinned by digest in deploy/build.sh, which builds
// releases, and again in each forge's CI, so CI tests with the compiler the
// release is built with. GitHub's workflow checks that the three agree, but
// only GitHub runs that file, and only once a commit is published there. This
// test runs wherever go test runs, so a digest changed in one place and not the
// others fails before the change is merged.
var (
releaseImageRe = regexp.MustCompile(`(?m)^GO_IMAGE="(golang@sha256:[0-9a-f]{64})"$`)
giteaImageRe = regexp.MustCompile(`(?m)^ GO_IMAGE: (\S+)$`)
githubImageRe = regexp.MustCompile(`(?m)^ image: (\S+)$`)
)
func TestCIUsesTheReleaseGoImage(t *testing.T) {
read := func(rel string) string {
data, err := os.ReadFile(filepath.Join("..", rel))
if err != nil {
t.Fatal(err)
}
return string(data)
}
release := releaseImageRe.FindAllStringSubmatch(read("deploy/build.sh"), -1)
if len(release) != 1 {
t.Fatalf(`deploy/build.sh: want one GO_IMAGE="golang@sha256:<64 hex>" line, found %d`, len(release))
}
want := release[0][1]
for _, c := range []struct {
rel string
re *regexp.Regexp
n int
}{
{".gitea/workflows/ci.yml", giteaImageRe, 1},
{".github/workflows/ci.yml", githubImageRe, 3},
} {
got := c.re.FindAllStringSubmatch(read(c.rel), -1)
if len(got) != c.n {
t.Errorf("%s: want %d image line(s), found %d", c.rel, c.n, len(got))
}
for _, g := range got {
if g[1] != want {
t.Errorf("%s names %s; deploy/build.sh builds releases with %s", c.rel, g[1], want)
}
}
}
}
+418 -33
View File
@@ -11,6 +11,7 @@ package main
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
@@ -49,23 +50,7 @@ func parseSources(t *testing.T) *srcPkg {
if err != nil {
t.Fatalf("parse %s: %v", n, err)
}
p.files[n] = f
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(fd)
// The same name in two platform files (processIsRoot, …) is kept
// once per file so neither body is lost to the other.
if _, dup := p.funcs[key]; dup {
key = key + "@" + n
}
p.funcs[key] = fd
if fd.Recv != nil {
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
}
}
p.addFile(n, f)
}
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
@@ -73,6 +58,46 @@ func parseSources(t *testing.T) *srcPkg {
return p
}
// addFile registers one parsed file and its functions. A test that proves a
// check can fail adds a synthetic file through here, so it is indexed exactly
// as a real one would be.
func (p *srcPkg) addFile(n string, f *ast.File) {
p.files[n] = f
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(fd)
// The same name in two platform files (processIsRoot, …) is kept
// once per file so neither body is lost to the other.
if _, dup := p.funcs[key]; dup {
key = key + "@" + n
}
p.funcs[key] = fd
if fd.Recv != nil {
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
}
}
}
// funcKeysIn is the p.funcs key of every function declared in file n.
func (p *srcPkg) funcKeysIn(n string) []string {
var out []string
for _, d := range p.files[n].Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(fd)
if p.funcs[key] != fd {
key = key + "@" + n
}
out = append(out, key)
}
return out
}
// funcKey is "name" for a function and "Type.name" for a method.
func funcKey(fd *ast.FuncDecl) string {
if fd.Recv == nil || len(fd.Recv.List) == 0 {
@@ -488,10 +513,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
}
}
// No MCP endpoint. Every HTTP route the binary registers, on any listener,
// is in this table; none of them, and no op name on the management lane,
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC,
// the protocol MCP is carried on; and no dependency is an MCP library.
// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
// the binary registers, on any listener, is in this table; none of them, and
// no op name on the management lane, names MCP; no string in the code is a
// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
// carried on; and no dependency is an MCP library.
//
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
// string containing "/mcp" at all. The connection-config reader
// (mcpconfig.go) has to name the files agent clients declare their tool
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
// protocol instead of the word (mcpReaderViolations):
//
// - no literal anywhere is a URL or route with an mcp path segment;
// - the config basenames and keys may appear in mcpconfig.go and nowhere
// else, so a second file that starts reading (or writing) them is a
// change a reviewer sees here;
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
// can reach through this package's call graph, net.Dial or net.Listen,
// net/http, os/exec, TLS dialling or the websocket client. The reader is
// handed its process list by agents.go; it starts and dials nothing.
//
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
// fail.
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
p := parseSources(t)
// A route served by more than one surface names each file, "a.go|b.go":
@@ -547,14 +592,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
}
}
for _, v := range mcpReaderViolations(p) {
t.Error(v)
}
for name, f := range p.files {
for _, s := range stringLits(f) {
l := strings.ToLower(s)
if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") ||
strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") {
t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)
}
}
for _, imp := range f.Imports {
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
t.Errorf("%s imports %s", name, imp.Path.Value)
@@ -575,6 +616,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
}
}
// mcpConfigNames are the file basenames and keys agent clients declare tool
// servers under. Only mcpconfig.go may name them. settings.json and
// config.toml are left out on purpose: many programs use those names (the
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
// about tool servers; the keys beside them do.
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
"mcpservers", "context_servers", "mcp_servers",
// The clients' own directories, which name a settings.json or
// config.toml as surely as the basename would.
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
// mcpURLOrRoute reports whether a string literal is a URL or route that
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
// /mcp-proxy) except a client's config FILE name: /mcp.json and
// /mcp_config.json.
func mcpURLOrRoute(l string) bool {
l = strings.ToLower(l)
if i := strings.Index(l, "://"); i >= 0 {
rest := l[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
return strings.Contains(rest[j:], "/mcp")
}
return false
}
for i := 0; ; {
j := strings.Index(l[i:], "/mcp")
if j < 0 {
return false
}
rest := l[i+j+len("/mcp"):]
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
return true
}
i += j + len("/mcp")
}
}
// mcpReaderSink: a package-level function or type that opens a connection,
// resolves a name, or starts a process — named as a call or as a value.
func mcpReaderSink(recv, sel string) bool {
switch {
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
return true
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
return true
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
return true
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
return true
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
return true
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
return true
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
// A DLL loaded by name reaches any function in it, past every check here.
return true
case recv == "registry" && sel == "OpenRemoteKey":
return true
}
return false
}
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
// whatever the receiver is called — d.Dial on a net.Dialer variable,
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
// purpose: it only applies to code the reader can reach.
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
"CombinedOutput": true, "Connect": true}
// mcpReaderAllowedImports is every package a config-reader file may import:
// parsing, strings, and opening files. Anything else is a change a reviewer
// has to see here first. x/sys/windows and its registry package are the
// Windows open (CreateFile and the handle's own type, attributes and owner)
// and the profile list it checks owners against; each can also start a
// process, open a socket, load a DLL or open a remote registry, and those
// calls are sinks in mcpReaderSink like their unix equivalents.
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
"golang.org/x/sys/windows/registry": true}
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
// The http, exec and websocket packages also export constants and types
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
// their functions count here.
func mcpValueSink(recv, sel string) bool {
switch recv {
case "http", "exec", "websocket", "rpc", "jsonrpc":
switch sel {
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
return true
}
return false
}
return mcpReaderSink(recv, sel)
}
// mcpSinksIn lists the network and process calls inside one declaration.
func mcpSinksIn(n ast.Node) []string {
var out []string
called := map[*ast.SelectorExpr]bool{}
ast.Inspect(n, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
called[sel] = true
}
}
return true
})
ast.Inspect(n, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.SelectorExpr:
recv := exprText(v.X)
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
out = append(out, recv+"."+v.Sel.Name)
}
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
}
}
return true
})
return out
}
// mcpReaderViolations is every way the package breaks the guarantee above.
func mcpReaderViolations(p *srcPkg) []string {
var out []string
names := make([]string, 0, len(p.files))
for n := range p.files {
names = append(names, n)
}
sort.Strings(names)
var readerFiles []string
for _, name := range names {
if strings.HasPrefix(name, "mcpconfig") {
readerFiles = append(readerFiles, name)
}
for _, s := range stringLits(p.files[name]) {
l := strings.ToLower(s)
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
}
// The protocol's namespace. The reader may name the npm scope
// its reference servers are published under, to recognise one
// running; nothing else may name it at all.
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
}
if name == "mcpconfig.go" {
continue
}
for _, b := range mcpConfigNames {
if strings.Contains(l, b) {
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
}
}
}
}
if len(readerFiles) == 0 {
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
}
var roots []string
for _, name := range readerFiles {
for _, imp := range p.files[name].Imports {
path, _ := strconv.Unquote(imp.Path.Value)
if !mcpReaderAllowedImports[path] {
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
}
}
roots = append(roots, p.funcKeysIn(name)...)
}
// Package-level variables are nodes too ("var:name"): a function held in
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
// reads the variable, and a reader file's own variables are roots, since
// their initialisers run with the package.
g := p.callGraph()
vars := map[string][]ast.Expr{}
for _, name := range names {
for _, d := range p.files[name].Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, sp := range gd.Specs {
vs := sp.(*ast.ValueSpec)
for _, id := range vs.Names {
key := "var:" + id.Name
vars[key] = append(vars[key], vs.Values...)
if strings.HasPrefix(name, "mcpconfig") {
roots = append(roots, key)
}
}
}
}
}
refs := func(n ast.Node) []string {
var out []string
ast.Inspect(n, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok {
if _, isVar := vars["var:"+id.Name]; isVar {
out = append(out, "var:"+id.Name)
}
if _, isFn := p.funcs[id.Name]; isFn {
out = append(out, id.Name)
}
}
return true
})
return out
}
sinks := map[string][]string{}
for key, fd := range p.funcs {
g[key] = append(g[key], refs(fd)...)
if s := mcpSinksIn(fd); len(s) > 0 {
sinks[key] = s
}
}
for key, vals := range vars {
for _, v := range vals {
g[key] = append(g[key], refs(v)...)
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
}
}
seen := reach(g, roots, nil)
for fn, path := range seen {
if what := sinks[fn]; len(what) > 0 {
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
fn, strings.Join(what, ", "), path))
}
}
sort.Strings(out)
return out
}
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
// network package, reaches a dialler through another function, or a second
// file that names a client's config file or an /mcp URL: each one is added
// as a synthetic source and must be reported. The real tree must report
// nothing, and the scan must have found sinks and roots to compare, so a
// broken extraction cannot pass by finding nothing.
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
clean := parseSources(t)
if v := mcpReaderViolations(clean); len(v) != 0 {
t.Fatalf("the real tree must be clean: %v", v)
}
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
}
add := func(p *srcPkg, name, src string) {
t.Helper()
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
p.addFile(name, f)
}
cases := []struct {
name, want string
files map[string]string
}{
{"the reader execs", "can reach mcpSneakRun", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader imports net/http", "imports net/http", map[string]string{
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
{"a route", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
{"the reader resolves a name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
}
for _, c := range cases {
p := parseSources(t)
for n, src := range c.files {
add(p, n, src)
}
found := false
for _, v := range mcpReaderViolations(p) {
if strings.Contains(v, c.want) {
found = true
}
}
if !found {
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
}
}
// A config file name is not a URL path, and a file in the reader may
// name one.
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
if mcpURLOrRoute(ok) {
t.Errorf("%q read as an MCP URL or route", ok)
}
}
}
// callGraph maps each function to what it calls or refers to, by name.
// A method call through a value is resolved to every method of that name in
// the package, so the graph over-approximates: a path it cannot rule out is
@@ -582,6 +963,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
// followed; none of the handlers below does that.
func (p *srcPkg) callGraph() map[string][]string {
g := map[string][]string{}
// The per-file duplicates ("name@file"), by name, built once: looking
// them up by prefix for every identifier made this graph cost seconds.
dups := map[string][]string{}
for k := range p.funcs {
if i := strings.IndexByte(k, '@'); i >= 0 {
dups[k[:i]] = append(dups[k[:i]], k)
}
}
for key, fd := range p.funcs {
if fd.Body == nil {
continue
@@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string {
if _, ok := p.funcs[v.Name]; ok {
out = append(out, v.Name)
}
for k := range p.funcs {
if strings.HasPrefix(k, v.Name+"@") {
out = append(out, k)
}
}
out = append(out, dups[v.Name]...)
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
out = append(out, p.methods[sel.Sel.Name]...)
+64
View File
@@ -90,6 +90,70 @@ func TestATunnelAddressThatIsNotOneHostIsRefused(t *testing.T) {
}
}
// Tunnel endpoints as the host may send them. The first is the hostile
// shape: the configuration text is one setting per line, so a newline ends
// the endpoint and starts a setting of the sender's choosing.
var (
badTunnelEndpoints = []string{
"203.0.113.7:51820\nallowed_ip=::/0", "[fe80::1%x\nallowed_ip=::/0]:51820",
"203.0.113.7:51820\r", "203.0.113.7:51820\t", " 203.0.113.7:51820", "203.0.113.7:51820 ",
"relay\x00.example.net:51820", "relay a.example.net:51820", "", "203.0.113.7",
"203.0.113.7:0", "203.0.113.7:65536", "203.0.113.7:+80", "relay.example.net:port",
"-relay.example.net:51820", "relay..example.net:51820", "[relay.example.net]:51820",
"[fe80::1%eth0]:51820", "0.0.0.0:51820", "[::]:51820", "2001:db8::7:51820",
}
goodTunnelEndpoints = []string{"203.0.113.7:51820", "[2001:db8::7]:51820", "relay-a.example.net:51820"}
)
// An endpoint that is not one host:port is refused at every place the peer
// is configured, and nothing is set on the device.
func TestATunnelEndpointThatIsNotHostPortIsRefused(t *testing.T) {
for _, bad := range badTunnelEndpoints {
cfg := &Config{WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: bad, HostTunnelIP: "2001:db8::e:3"}
dev := testWGDevice(t, "2001:db8::a:1")
if err := configureTunnelPeer(dev, cfg); err == nil {
t.Errorf("%q: configureTunnelPeer must refuse", bad)
}
if err := swapPeer(cfg, dev, bad, testWGKey(t)); err == nil {
t.Errorf("%q: swapPeer must refuse", bad)
}
if peers := wgPeers(t, dev); len(peers) != 0 {
t.Errorf("%q: a refused endpoint must leave the device with no peer, it holds %v", bad, peers)
}
}
}
// uapiRecorder keeps the configuration text it is given, in place of a device.
type uapiRecorder struct{ texts []string }
func (r *uapiRecorder) IpcSet(s string) error { r.texts = append(r.texts, s); return nil }
// Whatever endpoint the pairing holds, the text the device is given names
// one peer with exactly one allowed address, the host's alias, or the
// pairing is refused before any text is built.
func TestThePeerConfigurationHasExactlyOneAllowedAddress(t *testing.T) {
for _, ep := range append(append([]string{}, goodTunnelEndpoints...), badTunnelEndpoints...) {
rec := &uapiRecorder{}
cfg := &Config{WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: ep, HostTunnelIP: "2001:db8::e:3"}
if err := configureTunnelPeer(rec, cfg); err != nil {
continue
}
for _, text := range rec.texts {
seen := map[string]int{}
for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
k, _, _ := strings.Cut(line, "=")
seen[k]++
}
if seen["allowed_ip"] != 1 || seen["public_key"] != 1 || seen["endpoint"] != 1 ||
!strings.Contains(text, "\nallowed_ip=2001:db8::e:3/128\n") {
t.Errorf("%q: the configuration must name one peer with one allowed address, got %q", ep, text)
}
}
}
}
func TestTheTunnelReachesOnlyThePairedHost(t *testing.T) {
for _, hostIP := range []string{"2001:db8::e:3", "2001:db8::e:ffff", "2001:db8::1"} {
cfg := &Config{
+8 -48
View File
@@ -10,8 +10,8 @@ package main
// over the machine's own connection.
//
// THE VETTING APPLIES ALWAYS, anchored box or not. The egress lane
// (egress.go) only vets on an anchored box, because on a local box the host
// using it is the person's own. A coding agent is not the person: it runs
// (egress.go) keeps a local box's LAN reachable, because on a local box the
// host using it is the person's own. A coding agent is not the person: it runs
// whatever a prompt or a repository talks it into, so this proxy reaches the
// public internet and nothing else — not this machine, not its LAN, not the
// cloud metadata address, nor any address this machine takes as its own (a
@@ -23,7 +23,6 @@ package main
import (
"bufio"
"context"
"errors"
"net"
"os"
"path/filepath"
@@ -50,35 +49,12 @@ var (
coderDialOut egressDialer = (&net.Dialer{Timeout: egressDialTimeout}).DialContext
)
// coderInternalIP is internalIP (egress.go) plus the IPv6 forms that carry an
// IPv4 address and are not covered there: IPv4-compatible ::a.b.c.d (::/96),
// IPv4-translated ::ffff:0:a.b.c.d, and the deprecated site-local fec0::/10.
// IPv4-mapped ::ffff:a.b.c.d is covered by internalIP through To4.
// coderInternalIP is internalIP (egress.go): a coder run's proxy and the
// backup lanes refuse the same address classes as the egress lane, from the
// one classifier (classifyIP), the IPv4-compatible, IPv4-translated and
// site-local fec0::/10 forms included.
func coderInternalIP(ip net.IP) bool {
if internalIP(ip) {
return true
}
if ip.To4() != nil || len(ip) != net.IPv6len {
return false
}
zero := true
for _, b := range ip[:12] {
if b != 0 {
zero = false
break
}
}
if zero {
return true // ::/96 — never a public host
}
if ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 && ip[4] == 0 && ip[5] == 0 &&
ip[6] == 0 && ip[7] == 0 && ip[8] == 0xff && ip[9] == 0xff && ip[10] == 0 && ip[11] == 0 {
return true // ::ffff:0:0/96
}
if ip[0] == 0xfe && ip[1]&0xc0 == 0xc0 {
return true // fec0::/10
}
return false
return internalIP(ip)
}
// coderLANAllowed: the owner's allow_lan names this requested host (an entry
@@ -245,23 +221,7 @@ func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
coderRefuse(client, why)
return
}
var upstream net.Conn
err = errors.New("no address")
for i, a := range dialAddrs {
// Each address gets an even share of what is left of the dial budget,
// so one that black-holes cannot use it all.
actx, acancel := ctx, context.CancelFunc(func() {})
if left := len(dialAddrs) - i; left > 1 {
if dl, ok := ctx.Deadline(); ok {
actx, acancel = context.WithTimeout(ctx, time.Until(dl)/time.Duration(left))
}
}
upstream, err = coderDialOut(actx, "tcp", a)
acancel()
if err == nil {
break
}
}
upstream, err := dialEach(ctx, coderDialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
return
+1 -1
View File
@@ -47,7 +47,7 @@ func TestBoundTreeRecountsAndCaps(t *testing.T) {
t.Fatalf("full tree: count=%d trunc=%v %+v", count, trunc, out)
}
// the cap holds on THIS side even if a helper lied about truncation
out, count, trunc = boundTree([]*computerNode{win}, 3)
_, count, trunc = boundTree([]*computerNode{win}, 3)
if count != 3 || !trunc {
t.Fatalf("capped: count=%d trunc=%v", count, trunc)
}
+37 -22
View File
@@ -76,12 +76,20 @@ type Config struct {
// that its file does not hold yet (rotate.go, setUnsaved; guarded by
// rotation.mu). Unexported: never in the file.
savePending bool
// invalid is why loadPairings found this pairing's file unusable (an
// endpoint that is not one host:port), naming the file; "" for a usable
// one. Such a pairing is listed, so status shows it and unpair removes it,
// but it never serves: active() is false. Unexported: never in the file.
invalid string
}
const (
pairingActive = "active"
pairingPending = "pending"
pairingRevoked = "revoked"
// pairingInvalid is reported, never stored: the file holds a value this
// build refuses (Config.invalid).
pairingInvalid = "invalid"
// transportUnix is Config.Transport for a Host on this machine.
transportUnix = "unix"
@@ -103,11 +111,15 @@ func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransp
// active is the ONE place the state field is interpreted: "" and "active" serve,
// anything else does not. run.go, the heartbeat and the mgmt lane all ask this.
func (c *Config) active() bool {
return c.State == "" || c.State == pairingActive
return c.invalid == "" && (c.State == "" || c.State == pairingActive)
}
// stateName is the state as reported: the empty legacy value reads "active".
// stateName is the state as reported: the empty legacy value reads "active",
// and a pairing whose file is unusable reads "invalid" whatever its state.
func (c *Config) stateName() string {
if c.invalid != "" {
return pairingInvalid
}
if c.State == "" {
return pairingActive
}
@@ -316,17 +328,36 @@ func (c *Config) updateOnDiskIf(apply func(disk *Config) error) error {
// updatePairingOnDisk is updateOnDisk for a caller that only has the host
// name (the mgmt handlers). Returns the pairing as written, or nil with
// errPairingGone when no such file exists.
//
// The copy is checked as loadPairings checks a file, so a caller that starts
// the pairing from it never serves one whose file this build refuses.
func updatePairingOnDisk(host string, apply func(disk *Config)) (*Config, error) {
pd, err := pairingsDir()
if err != nil {
return nil, err
}
c := &Config{HostName: host}
var out *Config
err := c.updateOnDisk(func(d *Config) {
err = c.updateOnDisk(func(d *Config) {
apply(d)
cp := *d
cp.invalid = invalidReason(&cp, filepath.Join(pd, host+".json"))
out = &cp
})
return out, err
}
// invalidReason is why the pairing c, read from file, is never served, naming
// the file, or "" when it may be served. It is the check the pairing response
// and the rotate directive get, for a file an older build saved from either.
func invalidReason(c *Config, file string) string {
if err := checkEndpoints(append([]string{c.Endpoint}, c.Endpoints...)...); err != nil {
return fmt.Sprintf("pairing %s: %v — it is not served; pair it again (`privatae-bastion pair --replace <token>`) or remove it (`privatae-bastion unpair %s`)",
file, err, c.HostName)
}
return ""
}
// unpairHost tears down and deletes one pairing: its file and its grant file.
// The context store and DCN dir are the USER's data and are left in place —
// deleting a pairing must not delete what the user put on this machine.
@@ -495,31 +526,15 @@ func loadPairings() ([]*Config, error) {
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("pairing %s: %w", e.Name(), err)
}
// One such file must not stop every other pairing, status or unpair:
// it loads, never serves.
c.invalid = invalidReason(&c, filepath.Join(pd, e.Name()))
cfgs = append(cfgs, &c)
}
sort.Slice(cfgs, func(i, j int) bool { return cfgs[i].HostName < cfgs[j].HostName })
return cfgs, nil
}
// loadConfig keeps single-pairing call sites working: with exactly one pairing it
// returns it. With several it REFUSES — a straggler caller must name a host and
// fail safe, not silently act on an arbitrary one (that ambiguity is the same
// family of bug as the single-config unpair).
func loadConfig() (*Config, error) {
cfgs, err := loadPairings()
if err != nil {
return nil, err
}
switch len(cfgs) {
case 0:
return nil, fmt.Errorf("no pairings")
case 1:
return cfgs[0], nil
default:
return nil, fmt.Errorf("%d hosts are paired — this operation must name one (see --host)", len(cfgs))
}
}
// hostNames lists the paired host names (for error messages).
func hostNames(cfgs []*Config) []string {
names := make([]string, 0, len(cfgs))
+1 -1
View File
@@ -135,7 +135,7 @@ func maybeContain(pairing, dcnDir string, heartbeatResponse []byte) {
// already passed the agent checks above, and an agent whose own
// fingerprint is in the accounting (one running as a service, say)
// would otherwise be spared in silence — killTree returns 0 for a
// spared root and a zero kill is not recorded (review of #100).
// spared root and a zero kill is not recorded.
root := pid
killed := killTree(pid, func(p int) bool {
if p == root {
+6 -6
View File
@@ -104,7 +104,7 @@ func recordContainResult(pairing string, o findingOrder, outcome string, stopped
// "Carried out once" held only while this process lived: a bastion that
// restarted after a kill and before the beat carried the answer (a self-update,
// a crash) would receive the order again, run it again, and report
// not_a_finding — or kill a restarted copy (review of #100). So every result
// not_a_finding — or kill a restarted copy. So every result
// is written to the config dir as it is recorded and read back on first use.
var containResultsLoaded bool
@@ -165,7 +165,7 @@ func containResultsSaveLocked() {
}
if b, err := json.Marshal(disk); err == nil {
// Atomic: a crash mid-write must not corrupt the record and make the
// box forget answers it gave (review of #100).
// box forget answers it gave.
_ = atomicWrite(p, b)
}
}
@@ -231,7 +231,7 @@ type findingView struct {
protected map[int]bool
// identity: each finding pid's identity read straight after the walk. The
// kill checks it before touching the root, so a pid reused between the walk
// and the kill is never signalled (review of #100).
// and the kill is never signalled.
identity map[int]string
}
@@ -441,9 +441,9 @@ func maybeContainFindings(pairing, dcnDir string, heartbeatResponse []byte) {
// process in the tree, not only the ones an order names — an accounting entry
// (accountingPIDs), an app the user is running, anything this bastion started,
// init, the bastion. killTree does not signal a protected process or walk
// beneath it (review of #100: stopping a replaced sshd would otherwise have
// taken every ssh session with it). It also returns the bastion's ANCESTORS —
// its launch chain — which the finding path refuses by name: if any process in
// beneath it: without that, stopping a replaced sshd would take every ssh
// session with it. It also returns the bastion's ANCESTORS — its launch
// chain — which the finding path refuses by name: if any process in
// a tree were an ancestor of the bastion, the tree's root would be one too, so
// the named check always fires first and a tree-level check could never be
// reached.
+11 -11
View File
@@ -290,7 +290,7 @@ func TestProcessFindingsFromTheRealComparison(t *testing.T) {
}
}
// THE GUARDS HOLD ACROSS THE WHOLE TREE (review of #100, blocking). A finding
// THE GUARDS HOLD ACROSS THE WHOLE TREE. A finding
// whose tree holds an accounting entry, an app, or something the bastion
// started: those processes and everything beneath them are spared, the rest
// of the tree is stopped.
@@ -334,8 +334,8 @@ func TestContainFindingRefusesTheBastionsOwnAncestors(t *testing.T) {
}
// THE REFUSALS liveProcessFindings MAKES, through the real function with only
// the machine replaced (review of #100: every other test replaced the whole
// function, so none of these was tested).
// the machine replaced. Every other test replaces the whole function, so
// this is the one that tests these refusals.
//
// MUTATION: drop the root check. RESULT: red (no_authority not returned).
// MUTATION: drop !digestOK. RESULT: red (an edited accounting is used).
@@ -375,7 +375,7 @@ func TestLiveProcessFindingsRefusesWhatItCannotTrust(t *testing.T) {
}
}
// RESULTS SURVIVE A RESTART (review of #100): an answer recorded before the
// RESULTS SURVIVE A RESTART: an answer recorded before the
// process died is still there — and still stops a re-run — after it starts.
//
// MUTATION: skip containResultsSaveLocked. RESULT: red.
@@ -413,7 +413,7 @@ func TestContainFindingRunsAnOrderOnce(t *testing.T) {
}
}
// THE REAL FILTER IS WIRED IN (review of #100): with containFilter left at its
// THE REAL FILTER IS WIRED IN: with containFilter left at its
// default, a new process the bastion itself started is filtered by the same
// deltaFilter the pass uses, and so is never containable; the same process
// started by anyone else is.
@@ -422,7 +422,7 @@ func TestContainFindingRunsAnOrderOnce(t *testing.T) {
func TestContainFilterIsTheDeltaPassFilter(t *testing.T) {
// The process's AGE is supplied (an hour), not read from a real process:
// a real pid's age depends on where the suite runs — pid 1 in a fresh PID
// namespace is seconds old and reads as short-lived (review of #100).
// namespace is seconds old and reads as short-lived.
self := os.Getpid()
prevTree, prevAge := processTree, deltaAgeFn
t.Cleanup(func() { processTree, deltaAgeFn = prevTree, prevAge })
@@ -446,7 +446,7 @@ func TestContainFilterIsTheDeltaPassFilter(t *testing.T) {
}
}
// THE ROOT'S IDENTITY IS CARRIED FROM THE WALK (review of #100): a pid that
// THE ROOT'S IDENTITY IS CARRIED FROM THE WALK: a pid that
// changed hands between the finding walk and the kill is not signalled at all.
//
// MUTATION: killTree(pid, protected) instead of killTreeExpect with the view's
@@ -471,7 +471,7 @@ func TestContainFindingDoesNotKillARootThatChangedHandsAfterTheWalk(t *testing.T
}
}
// THE AGENT PATH HAS THE SAME TREE GUARD (review of #100): an accounting entry
// THE AGENT PATH HAS THE SAME TREE GUARD: an accounting entry
// or an app inside an agent's tree is spared with its subtree.
//
// MUTATION: killTree(pid, spareFromContain) in maybeContain. RESULT: red.
@@ -497,9 +497,9 @@ func TestAgentContainSparesProtectedProcessesInTheTree(t *testing.T) {
}
}
// AN AGENT WHOSE OWN FINGERPRINT IS IN THE ACCOUNTING IS STILL STOPPED (review
// of #100, round 3): the tree guard applies BENEATH the agent, never to the
// agent the host named. Before this, such an agent was spared in silence.
// AN AGENT WHOSE OWN FINGERPRINT IS IN THE ACCOUNTING IS STILL STOPPED: the
// tree guard applies BENEATH the agent, never to the agent the host named.
// Before this, such an agent was spared in silence.
//
// MUTATION: killTree(pid, guard) — the guard on the root too. RESULT: red.
func TestAgentContainStopsAnAgentThatIsItselfInTheAccounting(t *testing.T) {
+2 -2
View File
@@ -200,10 +200,10 @@ func contextAskViaSock(root, name string) (sent bool, host, note string, availab
return true, rep.Host, "", true
}
// contextSockUnsupported is the note used where the platform has no
// errContextSockUnsupported is the note used where the platform has no
// implementation yet, so the reason a person sees names the platform rather
// than pretending the feature is merely broken.
var contextSockUnsupported = errors.New("peer identity over a local socket is not " +
var errContextSockUnsupported = errors.New("peer identity over a local socket is not " +
"implemented for this platform yet")
func contextSockFmtUser(uid uint32, name string) string {
+10
View File
@@ -0,0 +1,10 @@
//go:build !linux && !darwin && !windows
package main
// The release builds linux, darwin and windows (deploy/build.sh). Any other
// unix (freebsd, openbsd) still builds, with the local context socket unable
// to tell who is on the other end: contextPeerUser then fails, and
// contextSockServe answers every connection with that reason and forwards
// nothing. An ask that cannot be attributed is refused, never let through.
func contextPeerUID(uintptr) (uint32, error) { return 0, errContextSockUnsupported }
+2 -2
View File
@@ -33,11 +33,11 @@ const contextSockDir = `C:\ProgramData\privatae-bastion`
func contextSockDialable(string) string { return "" }
func contextSockListen(path string) (net.Listener, error) {
return nil, contextSockUnsupported
return nil, errContextSockUnsupported
}
func contextPeerUser(c net.Conn) (string, error) {
return "", contextSockUnsupported
return "", errContextSockUnsupported
}
var _ = os.Remove
+1 -4
View File
@@ -859,10 +859,7 @@ func sttIsNonSpeechMark(text string) bool {
// ONE group, not two. "(yes) and (no)" ends in a bracket and is speech; the
// opening bracket must be closed only at the very end.
inner := t[1 : len(t)-1]
if strings.IndexByte(inner, closer) >= 0 {
return false
}
return true
return strings.IndexByte(inner, closer) < 0
}
type sttSegment struct {
+1 -3
View File
@@ -26,9 +26,7 @@ import (
"github.com/gorilla/websocket"
)
// uvicornWSMaxSize is the host's WebSocket frame ceiling: the server default,
// which nothing on the host overrides, so this is the live value.
const uvicornWSMaxSize = 16 << 20
// uvicornWSMaxSize (harness.go) is the host's WebSocket frame ceiling.
func TestMeasureCtxPutCapacity(t *testing.T) {
if os.Getenv("MEASURE_CTX_PUT") == "" {
+1
View File
@@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) {
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
"store_id": storeID, "path": path, "decision": decision,
})
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
-18
View File
@@ -296,13 +296,6 @@ func nonceSave(m map[string]string) error {
return atomicWrite(p, raw)
}
func nonceSeen(nonce string) bool {
deviceMu.Lock()
defer deviceMu.Unlock()
_, ok := nonceLoad()[nonce]
return ok
}
// burnNonce records the nonce until its approval's expiry (after which a
// replay fails on expiry anyway).
func burnNonce(a *deviceApproval) {
@@ -331,17 +324,6 @@ func approvalOK(dir, approval, purpose string) error {
return stepupCheckApproval(dir, approval, purpose)
}
// approvalKind names how an approval was given, for the audit line.
func approvalKind(approval string) string {
switch {
case strings.TrimSpace(approval) == "":
return ""
case isDeviceApproval(approval):
return "device"
}
return "console"
}
// approvalRequired: does a change on this box need the person's approval?
// Always on an anchored box (writes.go); on a local box only when the person
// enrolled a factor here — the rule the DCN lanes have had since stepup.go.
+211 -56
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
@@ -63,12 +64,12 @@ func handleEgressConn(client net.Conn, dialOut egressDialer, self string) {
defer cancel()
if strings.EqualFold(method, "CONNECT") {
dialAddr, why := egressVettedAddr(ctx, ensurePort(target, "443"))
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(target, "443"))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
}
upstream, err := dialOut(ctx, "tcp", dialAddr)
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
@@ -94,12 +95,12 @@ func handleEgressConn(client net.Conn, dialOut egressDialer, self string) {
if host == "" {
return
}
dialAddr, why := egressVettedAddr(ctx, ensurePort(host, "80"))
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(host, "80"))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
}
upstream, err := dialOut(ctx, "tcp", dialAddr)
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
@@ -145,47 +146,65 @@ func splice(clientReader io.Reader, client, upstream net.Conn) {
_ = client.Close()
}
// egressInternalRefusal: on an ANCHORED box the egress lane reaches the public
// internet and nothing else. Loopback, link-local and private addresses — this
// machine's own services, the LAN it sits on, the mesh — are refused before
// any dial, whether named by address or by a name that resolves to one. The
// lane is USE of the machine's connection (writes.go), and that holds only
// egressInternalRefusal is the refusal egressVettedAddrs gives, or "".
//
// The lane is USE of the machine's connection (writes.go), and that holds only
// while it cannot be turned inward: `POST http://127.0.0.1:11434/api/pull`
// down this lane would reach ollama around the forward's filter, and anything
// on the LAN around every other gate. So is any address the machine takes as
// its own (ownaddr.go): a public one on its NIC, or a range a `local` route
// delivers here. A local box keeps the lane it had.
// Resolution failures refuse too — a name this box cannot resolve is not one
// down this lane would reach ollama around the forward's filter and around the
// `models` switch, and this machine's other services around theirs. So on
// every box the lane refuses this machine: `localhost` names, loopback and
// unspecified addresses, link-local ones (the cloud metadata address
// included), and any address the machine takes as its own (ownaddr.go), such
// as a public one on its NIC or a range a `local` route delivers here.
//
// What differs is the network around it. On an ANCHORED box the lane reaches
// the public internet and nothing else: private, CGNAT and reserved addresses
// (the LAN, the mesh) are refused too. On a LOCAL box the LAN stays reachable:
// the host there is the one the person paired, and their own network is
// theirs to reach.
//
// Resolution failures refuse too: a name this box cannot resolve is not one
// it can vouch for.
//
// THE ADDRESS THE CHECK VETTED IS THE ADDRESS DIALLED. egressVettedAddr
// returns "ip:port" on an anchored box, and the dial takes that, never the
// name: a name resolved once for the check and again for the dial is a
// rebinding hole (a public answer for the check, 127.0.0.1 for the dial).
// THE ADDRESS THE CHECK VETTED IS THE ADDRESS DIALLED. egressVettedAddrs
// returns "ip:port" forms, and the dial takes those, never the name: a name
// resolved once for the check and again for the dial is a rebinding hole (a
// public answer for the check, 127.0.0.1 for the dial).
func egressInternalRefusal(ctx context.Context, hostport string) string {
_, why := egressVettedAddr(ctx, hostport)
_, why := egressVettedAddrs(ctx, hostport)
return why
}
// egressResolve is how the anchored lane resolves a name. A seam so a test can
// answer for a name without DNS.
// egressResolve is how the lane resolves a name. A seam so a test can answer
// for a name without DNS.
var egressResolve = net.DefaultResolver.LookupIPAddr
// egressVettedAddr: (the address to dial, "") or ("", the refusal). On a
// local box the input is returned as it came (the OS resolves at the dial,
// as before); on an anchored box every address the name resolves to is
// checked and the FIRST one is what the dial gets, as "ip:port".
// egressVettedAddr: (the first address to dial, "") or ("", the refusal).
func egressVettedAddr(ctx context.Context, hostport string) (string, string) {
if currentAnchor() == nil {
return hostport, ""
all, why := egressVettedAddrs(ctx, hostport)
if why != "" {
return "", why
}
return all[0], ""
}
// egressVettedAddrs: every address the name resolved to, as "ip:port" in the
// resolver's order, or (nil, the refusal). Any one refused address refuses the
// name. The dial tries them in order (dialEach), so a dual-stack site still
// answers from a machine whose IPv6 goes nowhere, as it did when the OS dialled
// the name.
func egressVettedAddrs(ctx context.Context, hostport string) ([]string, string) {
anchored := currentAnchor() != nil
host, port, err := net.SplitHostPort(hostport)
if err != nil {
host, port = hostport, ""
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
return "", "refused: egress to " + host + " — this lane reaches the public internet only on an anchored machine"
if anchored {
return nil, "refused: egress to " + host + " — this lane reaches the public internet only on an anchored machine"
}
return nil, "refused: egress to " + host + " — this lane never reaches this machine"
}
var addrs []net.IP
if ip := net.ParseIP(host); ip != nil {
@@ -193,50 +212,150 @@ func egressVettedAddr(ctx context.Context, hostport string) (string, string) {
} else {
resolved, err := egressResolve(ctx, host)
if err != nil || len(resolved) == 0 {
return "", "refused: egress to " + host + " — could not resolve it on this machine"
return nil, "refused: egress to " + host + " — could not resolve it on this machine"
}
for _, r := range resolved {
addrs = append(addrs, r.IP)
}
}
for _, ip := range addrs {
if internalIP(ip) {
return "", "refused: egress to " + host + " (" + ip.String() + ") — this lane reaches the public " +
if anchored && internalIP(ip) {
return nil, "refused: egress to " + host + " (" + ip.String() + ") — this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
}
if !anchored && thisMachineOrLinkIP(ip) {
return nil, "refused: egress to " + host + " (" + ip.String() + ") — this lane never reaches " +
"this machine or a link-local address"
}
}
// This machine is not only loopback: a public address on its NIC, or a
// range the kernel delivers locally through a `local` route, reaches its
// 0.0.0.0 services too (ownaddr.go). This lane has no allow list, so the
// refusal has no exception; a local box returned above and is unchanged.
// This machine is not only loopback: a public address on its NIC, a LAN
// address on it, or a range the kernel delivers locally through a `local`
// route, reaches its 0.0.0.0 services too, and a container's or VM's
// address on a network made here reaches a service on it (ownaddr.go). This
// lane has no allow list, so the refusal has no exception on either kind of
// box.
own, err := ownDestination(addrs)
if err != nil {
return "", "refused: egress to " + host + " — this machine could not tell whether that is itself, so it cannot be vetted"
return nil, "refused: egress to " + host + " — this machine could not tell whether that is itself, so it cannot be vetted"
}
if own {
return "", "refused: egress to " + host + " — that is this machine; this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
if anchored {
return nil, "refused: egress to " + host + " — that is this machine; this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
}
return nil, "refused: egress to " + host + " — that is this machine; this lane never reaches this machine"
}
return net.JoinHostPort(addrs[0].String(), port), ""
out := make([]string, 0, len(addrs))
for _, ip := range addrs {
out = append(out, net.JoinHostPort(ip.String(), port))
}
return out, ""
}
// internalIP: loopback, link-local, private (RFC 1918, ULA), CGNAT, unspecified.
func internalIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
return true
// dialEachStagger is how long an attempt has to answer before the next
// address is tried beside it: the connection attempt delay of Happy Eyeballs
// (RFC 8305 recommends 250 ms), which the OS dialler used when it was given
// the name.
var dialEachStagger = 300 * time.Millisecond
// dialEach dials vetted addresses, in order, and returns the first that
// answers. The next address starts as soon as an attempt fails, or when the
// attempts in flight have not answered within dialEachStagger, so an address
// whose packets go nowhere costs a fraction of a second, not the dial budget.
// Only these addresses are ever dialled. The attempts still in flight when one
// answers are cancelled, and any that connects anyway is closed.
func dialEach(ctx context.Context, dialOut egressDialer, addrs []string) (net.Conn, error) {
if len(addrs) == 0 {
return nil, errors.New("no address")
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type attempt struct {
c net.Conn
err error
}
results := make(chan attempt, len(addrs))
next, inFlight := 0, 0
start := func() {
a := addrs[next]
next++
inFlight++
go func() {
c, err := dialOut(ctx, "tcp", a)
results <- attempt{c, err}
}()
}
stagger := time.NewTimer(dialEachStagger)
defer stagger.Stop()
start()
var err error
for inFlight > 0 {
var tick <-chan time.Time
if next < len(addrs) {
tick = stagger.C
}
select {
case r := <-results:
inFlight--
if r.err == nil {
go func(n int) {
for ; n > 0; n-- {
if late := <-results; late.c != nil {
_ = late.c.Close()
}
}
}(inFlight)
return r.c, nil
}
err = r.err
if next < len(addrs) {
start()
stagger.Reset(dialEachStagger)
}
case <-tick:
start()
stagger.Reset(dialEachStagger)
}
}
return nil, err
}
// ipClass is what an address reaches, read by every lane that dials for
// someone else. The address rules live here once: internalIP is "anything but
// public", and the local egress lane refuses the classes that are this machine
// or its link.
type ipClass int
const (
ipPublic ipClass = iota // the public internet
ipThisMachine // loopback, unspecified, 0.0.0.0/8
ipLink // link-local unicast and multicast (the cloud metadata address)
ipNetwork // private (RFC 1918, ULA), CGNAT, multicast, benchmarking, reserved
ipUnreadable // a translated form whose IPv4 address this box cannot read
ipObsolete // IPv4-compatible ::/96, IPv4-translated ::ffff:0:0/96, site-local fec0::/10
)
func classifyIP(ip net.IP) ipClass {
switch {
case ip.IsLoopback(), ip.IsUnspecified():
return ipThisMachine
case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast():
return ipLink
case ip.IsPrivate(), ip.IsMulticast():
return ipNetwork
}
if v4 := ip.To4(); v4 != nil {
// 100.64.0.0/10 (carrier NAT) is as internal as 10/8 for this purpose.
if v4[0] == 100 && v4[1]&0xc0 == 64 {
return true
// 0.0.0.0/8 is "this host on this network".
if v4[0] == 0 {
return ipThisMachine
}
// 0.0.0.0/8 ("this network"), 198.18.0.0/15 (benchmarking) and
// 240.0.0.0/4 (reserved) are never a public host.
if v4[0] == 0 || (v4[0] == 198 && v4[1]&0xfe == 18) || v4[0] >= 240 {
return true
// 100.64.0.0/10 (carrier NAT) is as internal as 10/8 for this purpose;
// 198.18.0.0/15 (benchmarking) and 240.0.0.0/4 (reserved) are never a
// public host.
if (v4[0] == 100 && v4[1]&0xc0 == 64) || (v4[0] == 198 && v4[1]&0xfe == 18) || v4[0] >= 240 {
return ipNetwork
}
return false
return ipPublic
}
// IPv6 forms that carry an IPv4 address inside them reach that address, so
// they are judged by it: NAT64 64:ff9b::/96 (the last four bytes — how an
@@ -245,15 +364,51 @@ func internalIP(ip net.IP) bool {
if len(ip) == net.IPv6len {
if ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b {
if ip[4]|ip[5]|ip[6]|ip[7]|ip[8]|ip[9]|ip[10]|ip[11] == 0 {
return internalIP(net.IPv4(ip[12], ip[13], ip[14], ip[15]))
return classifyIP(net.IPv4(ip[12], ip[13], ip[14], ip[15]))
}
// The rest of 64:ff9b::/32 (local-use translators, RFC 8215) maps
// to IPv4 in ways this box cannot read: refused.
return true
// to IPv4 in ways this box cannot read.
return ipUnreadable
}
if ip[0] == 0x20 && ip[1] == 0x02 {
return internalIP(net.IPv4(ip[2], ip[3], ip[4], ip[5]))
return classifyIP(net.IPv4(ip[2], ip[3], ip[4], ip[5]))
}
// Deprecated forms that are no destination of their own: IPv4-compatible
// ::a.b.c.d (RFC 4291 2.5.5.1), IPv4-translated ::ffff:0:a.b.c.d (SIIT)
// and site-local fec0::/10 (RFC 3879). Where each is delivered depends on
// the platform, so no lane dials them. (:: and ::1 are matched above.)
if isZeros(ip[0:12]) ||
(isZeros(ip[0:8]) && ip[8] == 0xff && ip[9] == 0xff && ip[10] == 0 && ip[11] == 0) ||
(ip[0] == 0xfe && ip[1]&0xc0 == 0xc0) {
return ipObsolete
}
}
return ipPublic
}
func isZeros(b []byte) bool {
for _, c := range b {
if c != 0 {
return false
}
}
return true
}
// internalIP: loopback, link-local, private (RFC 1918, ULA), CGNAT,
// unspecified, multicast and reserved, also inside a NAT64 or 6to4 address,
// and the deprecated IPv4-compatible, IPv4-translated and site-local forms.
func internalIP(ip net.IP) bool {
return classifyIP(ip) != ipPublic
}
// thisMachineOrLinkIP: what the local egress lane refuses by address class.
// An address it cannot read is refused, since it could be either, and so is a
// deprecated form, which is no destination of its own.
func thisMachineOrLinkIP(ip net.IP) bool {
switch classifyIP(ip) {
case ipThisMachine, ipLink, ipUnreadable, ipObsolete:
return true
}
return false
}
+122
View File
@@ -0,0 +1,122 @@
package main
// dialEach, which both egress lanes dial through: an address whose packets go
// nowhere must not hold up the next one for its share of the dial budget, the
// way the OS dialler's Happy Eyeballs never did when it was given the name.
import (
"context"
"errors"
"net"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
type closeRecordingConn struct {
net.Conn
closed atomic.Bool
}
func (c *closeRecordingConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
// MUTATION: dial the addresses one after another, each with an even share of
// what is left of the budget.
// RESULT: red — the second address waits out the first one's share (2 s here).
func TestDialEachDoesNotWaitOutAnAddressThatGoesNowhere(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
var mu sync.Mutex
var dialled []string
abandoned := make(chan struct{})
dial := func(ctx context.Context, _, addr string) (net.Conn, error) {
mu.Lock()
dialled = append(dialled, addr)
mu.Unlock()
if addr == "[2001:db8::30]:443" {
<-ctx.Done() // no answer and no error: the packets go nowhere
close(abandoned)
return nil, ctx.Err()
}
a, b := net.Pipe()
go func() { _ = b.Close() }()
return a, nil
}
began := time.Now()
c, err := dialEach(ctx, dial, []string{"[2001:db8::30]:443", "198.51.100.30:443"})
took := time.Since(began)
if err != nil {
t.Fatalf("the second address answers: %v", err)
}
_ = c.Close()
if took > 1500*time.Millisecond {
t.Fatalf("the second address waited %v for the first", took)
}
select {
case <-abandoned:
case <-time.After(2 * time.Second):
t.Fatal("the attempt left in flight must be cancelled once another answers")
}
mu.Lock()
defer mu.Unlock()
if want := []string{"[2001:db8::30]:443", "198.51.100.30:443"}; !reflect.DeepEqual(dialled, want) {
t.Fatalf("only the vetted addresses, in order: got %v, want %v", dialled, want)
}
}
// A connection that answers after another already won is closed, not leaked;
// an address that fails at once hands over to the next without waiting; and
// when every address fails the last error is returned.
func TestDialEachClosesALateWinnerAndReturnsTheLastError(t *testing.T) {
late := make(chan *closeRecordingConn, 1)
dial := func(ctx context.Context, _, addr string) (net.Conn, error) {
switch addr {
case "198.51.100.31:443":
time.Sleep(2 * dialEachStagger) // answers, but after the second address did
a, b := net.Pipe()
go func() { _ = b.Close() }()
rc := &closeRecordingConn{Conn: a}
late <- rc
return rc, nil
case "198.51.100.32:443":
a, b := net.Pipe()
go func() { _ = b.Close() }()
return a, nil
}
return nil, errors.New("refused: " + addr)
}
c, err := dialEach(context.Background(), dial, []string{"198.51.100.31:443", "198.51.100.32:443"})
if err != nil {
t.Fatal(err)
}
_ = c.Close()
select {
case rc := <-late:
deadline := time.Now().Add(2 * time.Second)
for !rc.closed.Load() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !rc.closed.Load() {
t.Fatal("a connection that answered after the winner must be closed")
}
case <-time.After(3 * time.Second):
t.Fatal("the slow attempt never answered")
}
began := time.Now()
_, err = dialEach(context.Background(), dial, []string{"198.51.100.33:443", "198.51.100.34:443"})
if err == nil || err.Error() != "refused: 198.51.100.34:443" {
t.Fatalf("every address failed, so the last error: %v", err)
}
if took := time.Since(began); took >= dialEachStagger {
t.Fatalf("a failed address hands over at once, not after the stagger: %v", took)
}
if _, err := dialEach(context.Background(), dial, nil); err == nil {
t.Fatal("no address is an error")
}
}
+228
View File
@@ -0,0 +1,228 @@
//go:build !windows
// Tagged !windows: it borrows setupBox (anchor_test.go) and stubOwnAddrs
// (ownaddr_test.go).
package main
// The egress lane on a local (unanchored) box. It refuses this machine and
// its link-local addresses, so a host cannot reach this machine's services
// (ollama above all) around the switches that govern them, and it keeps the
// person's LAN. An anchored box is unchanged: the public internet only.
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
)
// answerEgressNames answers the egress lane's name lookups from m.
func answerEgressNames(t *testing.T, m map[string][]string) {
t.Helper()
old := egressResolve
t.Cleanup(func() { egressResolve = old })
egressResolve = func(_ context.Context, host string) ([]net.IPAddr, error) {
ips, ok := m[strings.ToLower(host)]
if !ok {
return nil, errors.New("no such host")
}
var out []net.IPAddr
for _, s := range ips {
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
}
return out, nil
}
}
// A machine with a LAN address of its own and an AnyIP range, and names that
// answer with this machine, with the LAN and with the public internet.
func localEgressFixture(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if currentAnchor() != nil || boxMode() != modeLocal {
t.Fatal("the fixture must be a local, unanchored box")
}
stubOwnAddrs(t, []string{"127.0.0.1/8", "::1/128", "192.168.50.7/24", "fe80::7/64"}, localRange("203.0.113.0/24"))
answerEgressNames(t, map[string][]string{
"ollama.example.test": {"127.0.0.1"},
"mixed.example.test": {"198.51.100.8", "127.0.0.1"},
"self.example.test": {"192.168.50.7"},
"nas.example.test": {"192.168.50.30"},
"dual.example.test": {"2001:db8::8", "198.51.100.8"},
})
}
// MUTATION: restore the early return for an unanchored box in egressVettedAddrs.
// RESULT: red — 127.0.0.1:11434 is handed to the dial, and a name is dialled
// as a name instead of the address that was vetted.
func TestLocalEgressRefusesThisMachineAndKeepsTheLAN(t *testing.T) {
localEgressFixture(t)
ctx := context.Background()
for _, hp := range []string{
"127.0.0.1:11434", "127.9.9.9:80", "localhost:11434", "ollama.localhost:80", "[::1]:11434",
"0.0.0.0:11434", "0.1.2.3:80", "[::]:80",
"[::ffff:127.0.0.1]:11434", "[64:ff9b::7f00:1]:11434", "[2002:7f00:1::]:80", "[64:ff9b:1::5]:80",
"169.254.169.254:80", "[fe80::1]:80",
"192.168.50.7:11434", // this machine's own LAN address
"203.0.113.5:80", // a range a `local` route delivers here
"ollama.example.test:11434", "mixed.example.test:443", "self.example.test:11434",
"unknown.example.test:443",
} {
if addr, why := egressVettedAddr(ctx, hp); why == "" || addr != "" {
t.Errorf("%s must be refused on a local box, got dial %q", hp, addr)
}
}
for hp, want := range map[string][]string{
"192.168.50.20:8080": {"192.168.50.20:8080"},
"10.1.2.3:443": {"10.1.2.3:443"},
"172.16.4.4:22": {"172.16.4.4:22"},
"[fd12:3456::9]:80": {"[fd12:3456::9]:80"},
"100.64.0.9:80": {"100.64.0.9:80"},
"198.51.100.8:443": {"198.51.100.8:443"},
"nas.example.test:443": {"192.168.50.30:443"},
"dual.example.test:443": {"[2001:db8::8]:443", "198.51.100.8:443"},
} {
got, why := egressVettedAddrs(ctx, hp)
if why != "" || !reflect.DeepEqual(got, want) {
t.Errorf("%s is the LAN or the internet, so a local box dials %v: got %v %q", hp, want, got, why)
}
}
}
// The anchored lane keeps its own wording and still refuses the LAN that a
// local box reaches.
func TestAnchoredEgressStillRefusesTheLAN(t *testing.T) {
setupBox(t)
stubOwnAddrs(t, []string{"127.0.0.1/8", "::1/128", "192.168.50.7/24"}, localRange("203.0.113.0/24"))
ctx := context.Background()
for _, hp := range []string{"192.168.50.20:8080", "10.1.2.3:443", "[fd12:3456::9]:80", "100.64.0.9:80", "127.0.0.1:11434"} {
if addr, why := egressVettedAddr(ctx, hp); addr != "" || !strings.Contains(why, "only on an anchored machine") {
t.Errorf("%s must be refused on an anchored box as before, got %q %q", hp, addr, why)
}
}
if addr, why := egressVettedAddr(ctx, "198.51.100.8:443"); why != "" || addr != "198.51.100.8:443" {
t.Fatalf("the public internet passes: %q %q", addr, why)
}
}
// Through the lane itself, with a real dialer: a request for this machine is
// answered 403 and never reaches the listener; a LAN request is dialled at its
// vetted address; a dual-stack name falls back to its second address.
func TestLocalEgressLaneNeverDialsThisMachine(t *testing.T) {
localEgressFixture(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
var hits atomic.Int32
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
hits.Add(1)
c.Close()
}
}()
ask := func(dial egressDialer, req string) string {
srv, cli := net.Pipe()
go handleEgressConn(srv, dial, "home.host.example")
_ = cli.SetDeadline(time.Now().Add(5 * time.Second))
_, _ = io.WriteString(cli, req)
line, _ := bufio.NewReader(cli).ReadString('\n')
cli.Close()
return line
}
netDial := (&net.Dialer{Timeout: 2 * time.Second}).DialContext
target := ln.Addr().String()
for _, req := range []string{
"CONNECT " + target + " HTTP/1.1\r\n\r\n",
"GET http://" + target + "/api/tags HTTP/1.1\r\nHost: " + target + "\r\n\r\n",
"CONNECT localhost:" + strings.Split(target, ":")[1] + " HTTP/1.1\r\n\r\n",
} {
if line := ask(netDial, req); !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("a local box must refuse %q, answered %q", req, line)
}
}
time.Sleep(50 * time.Millisecond)
if n := hits.Load(); n != 0 {
t.Fatalf("the lane reached this machine's listener %d times", n)
}
var asked []string
recording := func(_ context.Context, _, addr string) (net.Conn, error) {
asked = append(asked, addr)
return nil, errors.New("stop here")
}
if line := ask(recording, "CONNECT nas.example.test:443 HTTP/1.1\r\n\r\n"); !strings.HasPrefix(line, "HTTP/1.1 502") {
t.Fatalf("a LAN destination is dialled (and this dial fails): %q", line)
}
if !reflect.DeepEqual(asked, []string{"192.168.50.30:443"}) {
t.Fatalf("the vetted LAN address, never the name: %v", asked)
}
asked = nil
fallback := func(_ context.Context, _, addr string) (net.Conn, error) {
asked = append(asked, addr)
if strings.HasPrefix(addr, "[") {
return nil, errors.New("network is unreachable")
}
a, b := net.Pipe()
go func() { _, _ = io.Copy(b, b) }()
return a, nil
}
if line := ask(fallback, "CONNECT dual.example.test:443 HTTP/1.1\r\n\r\n"); !strings.HasPrefix(line, "HTTP/1.1 200") {
t.Fatalf("a dual-stack name answers from its second address: %q", line)
}
if want := []string{"[2001:db8::8]:443", "198.51.100.8:443"}; !reflect.DeepEqual(asked, want) {
t.Fatalf("each vetted address in turn: got %v, want %v", asked, want)
}
}
// What docs/models.md tells a person with a local box to do: switch `models`
// off for the host. The forward then filters store-changing requests, and the
// egress lane is no way around it, since it never reaches this machine's
// ollama, on loopback or on the machine's own LAN address.
func TestLocalModelsSwitchLeavesNoEgressRouteToOllama(t *testing.T) {
localEgressFixture(t)
const host = "home.host.example"
writePairingState(t, host, pairingActive)
if inferenceFilterNeeded(host, laneOllama, pairingModelScope(host)) {
t.Fatal("an unscoped local pairing is a raw splice before the switch, so this test would prove nothing")
}
if err := localDenySet(host, "models", true); err != nil {
t.Fatal(err)
}
if !inferenceFilterNeeded(host, laneOllama, pairingModelScope(host)) {
t.Fatal("with `models` off the ollama forward must filter /api/pull")
}
var dialled []string
dial := func(_ context.Context, _, addr string) (net.Conn, error) {
dialled = append(dialled, addr)
return nil, errors.New("stop here")
}
for _, dest := range []string{"127.0.0.1:11434", "localhost:11434", "192.168.50.7:11434", "[::1]:11434"} {
srv, cli := net.Pipe()
go handleEgressConn(srv, dial, host)
_ = cli.SetDeadline(time.Now().Add(5 * time.Second))
fmt.Fprintf(cli, "POST http://%s/api/pull HTTP/1.1\r\nHost: %s\r\n\r\n", dest, dest)
line, _ := bufio.NewReader(cli).ReadString('\n')
cli.Close()
if !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("egress to %s must be refused: %q", dest, line)
}
}
if len(dialled) != 0 {
t.Fatalf("nothing for this machine may be dialled: %v", dialled)
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build !windows
// Tagged !windows: it borrows setupBox (anchor_test.go) and stubOwnAddrs
// (ownaddr_test.go).
package main
// The deprecated IPv6 forms are no destination of their own, and where each
// is delivered depends on the platform, so every lane refuses them: the
// anchored egress lane, the local one, and a coder run's proxy, which refused
// them first.
import (
"context"
"testing"
)
var obsoleteForms = []string{
"[::c633:6408]:443", // IPv4-compatible ::198.51.100.8
"[::7f00:1]:11434", // IPv4-compatible ::127.0.0.1
"[::c0a8:3214]:80", // IPv4-compatible ::192.168.50.20, a LAN address
"[::ffff:0:c633:6408]:443", // IPv4-translated ::ffff:0:198.51.100.8
"[::ffff:0:7f00:1]:80", // IPv4-translated ::ffff:0:127.0.0.1
"[fec0::5]:80", // site-local
"[feff:1::9]:443", // the top of fec0::/10
"compat.example.test:443", // a name with one such answer among public ones
"sitelocal.example.test:80",
}
func answerObsoleteNames(t *testing.T) {
answerEgressNames(t, map[string][]string{
"compat.example.test": {"198.51.100.8", "::c633:6408"},
"sitelocal.example.test": {"fec0::5"},
"public.example.test": {"198.51.100.8", "2001:db8::8"},
})
}
// MUTATION: classify the three forms as public in classifyIP.
// RESULT: red — both egress lanes hand them to the dial.
func TestEveryLaneRefusesTheDeprecatedIPv6Forms(t *testing.T) {
ctx := context.Background()
refusedOn := func(t *testing.T, lane string) {
t.Helper()
for _, hp := range obsoleteForms {
if addrs, why := egressVettedAddrs(ctx, hp); why == "" || addrs != nil {
t.Errorf("%s must be refused on the %s egress lane, got dial %v", hp, lane, addrs)
}
}
for _, hp := range []string{"198.51.100.8:443", "public.example.test:443"} {
if _, why := egressVettedAddrs(ctx, hp); why != "" {
t.Errorf("%s is the public internet and passes the %s egress lane: %s", hp, lane, why)
}
}
}
t.Run("anchored", func(t *testing.T) {
setupBox(t)
stubOwnAddrs(t, loopbackOnly, localRange())
answerObsoleteNames(t)
refusedOn(t, "anchored")
})
t.Run("local", func(t *testing.T) {
localEgressFixture(t)
answerObsoleteNames(t)
refusedOn(t, "local")
})
t.Run("a coder run's proxy agrees", func(t *testing.T) {
stubOwnAddrs(t, loopbackOnly, localRange())
for _, hp := range obsoleteForms[:7] {
if addrs, why := coderVettedAddrs(ctx, hp, nil); why == "" || addrs != nil {
t.Errorf("%s must be refused on a coder run's proxy, got dial %v", hp, addrs)
}
}
if _, why := coderVettedAddrs(ctx, "198.51.100.8:443", nil); why != "" {
t.Errorf("the public internet passes a coder run's proxy: %s", why)
}
})
}
+23 -6
View File
@@ -38,6 +38,16 @@ func dialHandler(t *testing.T, dialOut egressDialer) (net.Conn, func()) {
return cli, func() { cli.Close(); ln.Close() }
}
// redirectDial stands in for the user's internet: the lane asks for a public
// destination, and this dials the loopback backend instead, recording what the
// lane asked for. The lane never reaches loopback itself.
func redirectDial(backend string, asked *[]string) egressDialer {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
*asked = append(*asked, addr)
return (&net.Dialer{}).DialContext(ctx, network, backend)
}
}
// CONNECT establishes a raw tunnel; bytes flow both ways through it.
func TestEgressConnConnectTunnel(t *testing.T) {
backend, err := net.Listen("tcp", "127.0.0.1:0")
@@ -54,9 +64,10 @@ func TestEgressConnConnectTunnel(t *testing.T) {
_, _ = io.Copy(c, c) // echo
}()
cli, cleanup := dialHandler(t, (&net.Dialer{}).DialContext)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(backend.Addr().String(), &asked))
defer cleanup()
fmt.Fprintf(cli, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", backend.Addr(), backend.Addr())
fmt.Fprint(cli, "CONNECT 198.51.100.20:443 HTTP/1.1\r\nHost: 198.51.100.20:443\r\n\r\n")
br := bufio.NewReader(cli)
status, err := br.ReadString('\n')
if err != nil || !strings.Contains(status, "200") {
@@ -82,6 +93,9 @@ func TestEgressConnConnectTunnel(t *testing.T) {
if string(buf) != want {
t.Fatalf("tunnel echo mismatch: got %q", string(buf))
}
if len(asked) != 1 || asked[0] != "198.51.100.20:443" {
t.Fatalf("the lane must dial the address it vetted: %v", asked)
}
}
func TestEgressConnPlainHTTP(t *testing.T) {
@@ -89,15 +103,18 @@ func TestEgressConnPlainHTTP(t *testing.T) {
fmt.Fprint(w, "plain-http-ok")
}))
defer backend.Close()
host := strings.TrimPrefix(backend.URL, "http://")
cli, cleanup := dialHandler(t, (&net.Dialer{}).DialContext)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(strings.TrimPrefix(backend.URL, "http://"), &asked))
defer cleanup()
fmt.Fprintf(cli, "GET http://%s/ HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", host, host)
fmt.Fprint(cli, "GET http://198.51.100.21/ HTTP/1.1\r\nHost: 198.51.100.21\r\nConnection: close\r\n\r\n")
body, _ := io.ReadAll(cli)
if !strings.Contains(string(body), "plain-http-ok") {
t.Fatalf("plain HTTP forward: got %q", string(body))
}
if len(asked) != 1 || asked[0] != "198.51.100.21:80" {
t.Fatalf("the lane must dial the address it vetted: %v", asked)
}
}
func TestEgressConnDialFailure(t *testing.T) {
@@ -106,7 +123,7 @@ func TestEgressConnDialFailure(t *testing.T) {
}
cli, cleanup := dialHandler(t, failDial)
defer cleanup()
fmt.Fprint(cli, "CONNECT unreachable.example:443 HTTP/1.1\r\n\r\n")
fmt.Fprint(cli, "CONNECT 198.51.100.22:443 HTTP/1.1\r\n\r\n")
status, _ := bufio.NewReader(cli).ReadString('\n')
if !strings.Contains(status, "502") {
t.Fatalf("expected 502 on dial failure, got %q", status)
+230
View File
@@ -0,0 +1,230 @@
//go:build !windows
// Uses fakeToken from pairing_redeem_test.go and setupBox/driveOp from
// anchor_test.go, both unix-only.
package main
// A pairing whose file holds an endpoint this build refuses loads as invalid
// and is never served (endpoint_test.go). These are the paths that could
// still act on it: anchoring the box to it, an approval from the anchor host,
// what the hosts are told about it, and the two ways a person clears it.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// unservedEndpoint is one the endpoint check refuses (badTunnelEndpoints).
const unservedEndpoint = "203.0.113.7:51820\nallowed_ip=::/0"
// writeUnservedPairing writes a pairing file in the given state whose
// endpoint this build refuses, and returns its path.
func writeUnservedPairing(t *testing.T, host, state string) string {
t.Helper()
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "fd12:9::a:1",
State: state, Endpoint: unservedEndpoint})
p := filepath.Join(pd, host+".json")
if err := os.WriteFile(p, b, 0o600); err != nil {
t.Fatal(err)
}
return p
}
// fileState is the state field as written in host's pairing file.
func fileState(t *testing.T, host string) string {
t.Helper()
pd, _ := pairingsDir()
raw, err := os.ReadFile(filepath.Join(pd, host+".json"))
if err != nil {
t.Fatal(err)
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
return c.State
}
// claim-anchor names the reason and writes nothing: the pairing keeps its
// state, and the box is neither anchored nor claimed. As the sole pairing and
// named with --host among others, in the legacy active state and pending.
func TestClaimAnchorRefusesAPairingThatIsNotServed(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("claim-anchor needs machine authority, which needs root")
}
for _, c := range []struct {
name string
others []string
host string
}{{"sole pairing", nil, ""}, {"named with --host", []string{tAnchor}, tMember}} {
for _, st := range []string{"", pairingPending} {
t.Run(c.name+"/"+st, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
if err := setBoxMode(modeNetworked); err != nil {
t.Fatal(err)
}
for _, o := range c.others {
writePairing(t, o)
}
p := writeUnservedPairing(t, tMember, st)
err := cmdClaimAnchor("", false, "", c.host)
if err == nil || !contains(err.Error(), "not a host:port") || !contains(err.Error(), p) {
t.Fatalf("claim-anchor must refuse the pairing, naming its file and why: %v", err)
}
if a := currentAnchor(); a != nil {
t.Fatalf("a refused claim anchored the box: %+v", a)
}
if oc := currentOwnerClaim(); oc != nil {
t.Fatalf("a refused claim wrote the owner claim: %+v", oc)
}
if got := fileState(t, tMember); got != st {
t.Fatalf("a refused claim changed the pairing's state from %q to %q", st, got)
}
})
}
}
}
// The anchor host's approval of such a pairing is refused: it is not made
// active, no grant is kept, and the refusal names no file of this machine.
// The copy updatePairingOnDisk hands back carries the same mark, so a caller
// that starts a pairing from it starts nothing.
func TestAnApprovalOfAPairingThatIsNotServedIsRefused(t *testing.T) {
priv := setupBox(t)
writeUnservedPairing(t, tMember, pairingPending)
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
_, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig})
if code != 126 || !contains(errOut, "not served") {
t.Fatalf("the approval must be refused (126, not served): %d %s", code, errOut)
}
pd, _ := pairingsDir()
if contains(errOut, pd) {
t.Fatalf("the refusal sent to the host names a file of this machine: %s", errOut)
}
if got := fileState(t, tMember); got != pairingPending {
t.Fatalf("a refused approval changed the state to %q", got)
}
if _, err := loadPairingGrant(tMember); err == nil {
t.Fatal("a refused approval kept the grant")
}
c, err := updatePairingOnDisk(tMember, func(d *Config) { d.State = pairingActive })
if err != nil {
t.Fatal(err)
}
if c.invalid == "" || c.active() {
t.Fatalf("the copy written back must still be invalid and never active: invalid=%q active=%v", c.invalid, c.active())
}
if startPairing(c) {
t.Fatal("a pairing that is not served was started")
}
}
// What the hosts hear: neither the peer list every host gets, nor the
// pairing list the anchor host gets (in the heartbeat and from pairing_list),
// names such a pairing, and every state they do carry is one a host knows.
// The machine's own status still shows it invalid (endpoint_test.go).
func TestNoHostIsToldOfAPairingThatIsNotServed(t *testing.T) {
setupBox(t)
const beta, pend = "beta.host.privatae.ai", "pend.host.privatae.ai"
writePairing(t, beta)
writeUnservedPairing(t, tMember, "")
writeUnservedPairing(t, pend, pairingPending)
known := map[string]bool{pairingActive: true, pairingPending: true, pairingRevoked: true}
peers := peersFor(tAnchor)
if len(peers) != 1 || peers[0].HostName != beta {
t.Fatalf("the peers must be only %s, got %+v", beta, peers)
}
for _, p := range peers {
if !known[p.State] {
t.Fatalf("a peer was sent with a state a host does not know: %+v", p)
}
}
list := pairingsReport()
var names []string
for _, e := range list {
names = append(names, e.HostName)
if !known[e.State] {
t.Fatalf("a pairing was sent with a state a host does not know: %+v", e)
}
}
if got := strings.Join(names, ","); got != tAnchor+","+beta {
t.Fatalf("the anchor host's list must be %s,%s, got %s", tAnchor, beta, got)
}
out, errOut, code := driveOp(t, tAnchor, "pairing_list", nil)
if code != 0 || contains(out, tMember) || contains(out, pend) || contains(out, pairingInvalid) {
t.Fatalf("pairing_list must leave the pairing out: %d %s %s", code, out, errOut)
}
if u := unapprovedAttempts(time.Now().Add(24 * time.Hour)); len(u) != 0 {
t.Fatalf("a pairing that is not served is no unapproved attempt: %+v", u)
}
cfgs, err := loadPairings()
if err != nil {
t.Fatal(err)
}
if c := pairingFor(cfgs, tMember); c == nil || c.stateName() != pairingInvalid {
t.Fatalf("this machine still lists it invalid: %+v", c)
}
}
// pair --replace over such a pairing pairs it again, served; unpair removes
// one. Both through the commands a person runs.
func TestPairReplaceAndUnpairClearAPairingThatIsNotServed(t *testing.T) {
uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
const host, good = "acme.host.privatae.ai", "203.0.113.7:51820"
redeem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"operator_crt": "CERT", "ca_crt": "CA", "bearer": "b",
"endpoint": good, "endpoints": []string{good},
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
"host_tunnel_ip": "2001:db8::e:3", "host_name": host,
})
}))
defer redeem.Close()
old := redeemURLBase
redeemURLBase = redeem.URL
t.Cleanup(func() { redeemURLBase = old })
writeUnservedPairing(t, host, "")
if err := cmdPair(fakeToken("acme"), "", "", true); err != nil {
t.Fatalf("pair --replace over a pairing that is not served: %v", err)
}
cfgs, err := loadPairings()
if err != nil || len(cfgs) != 1 {
t.Fatalf("after pair --replace one pairing loads: %v %v", cfgs, err)
}
if c := cfgs[0]; c.HostName != host || c.invalid != "" || !c.active() || c.Endpoint != good {
t.Fatalf("pair --replace must leave a served pairing: %q invalid=%q active=%v endpoint=%q",
c.HostName, c.invalid, c.active(), c.Endpoint)
}
writeUnservedPairing(t, host, "")
if err := cmdUnpair(host); err != nil {
t.Fatalf("unpair of a pairing that is not served: %v", err)
}
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 0 {
t.Fatalf("after unpair nothing is left: %v %v", cfgs, err)
}
pd, _ := pairingsDir()
if _, err := os.Stat(filepath.Join(pd, host+".json")); !os.IsNotExist(err) {
t.Fatalf("the pairing file is still there: %v", err)
}
}
+207
View File
@@ -0,0 +1,207 @@
//go:build !windows
// Uses fakeToken from pairing_redeem_test.go, unix-only.
package main
// A tunnel endpoint arrives from the host three ways: in the pairing
// response, in the rotate directive on a heartbeat answer, and from the
// pairing file those were saved to. Each one refuses an endpoint that is
// not one host:port, the file by never serving that pairing (the
// device-level checks are in claims_tunnel_test.go).
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// A pairing response whose endpoint, or any entry of its endpoints list, is
// not one host:port is refused and nothing is saved. An IPv4, an IPv6 and a
// host name endpoint pair.
func TestAPairingResponseWithAnUnsafeEndpointSavesNothing(t *testing.T) {
cfgHome := uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
var endpoint, hostName string
var endpoints []string
redeem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"operator_crt": "CERT", "ca_crt": "CA", "bearer": "b",
"endpoint": endpoint, "endpoints": endpoints,
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
"host_tunnel_ip": "2001:db8::e:3", "host_name": hostName,
})
}))
defer redeem.Close()
old := redeemURLBase
redeemURLBase = redeem.URL
t.Cleanup(func() { redeemURLBase = old })
hostName = "acme.host.privatae.ai"
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // a missing endpoint has its own refusal
}
for _, c := range []struct {
endpoint string
list []string
}{{bad, nil}, {"203.0.113.7:51820", []string{"[2001:db8::7]:51820", bad}}} {
endpoint, endpoints = c.endpoint, c.list
if _, _, err := pairWithToken(fakeToken("acme"), false, false); err == nil || !strings.Contains(err.Error(), "not a host:port") {
t.Errorf("%q in %+v: the pairing must be refused for its endpoint, got %v", bad, c, err)
}
}
}
_ = filepath.Walk(cfgHome, func(path string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() {
t.Errorf("a refused pairing left a file behind: %s", path)
}
return nil
})
for _, good := range goodTunnelEndpoints {
uiTestConfigDir(t) // a local bastion serves one host: a fresh box each
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
endpoint, endpoints = good, []string{good}
cfg, _, err := pairWithToken(fakeToken("acme"), false, false)
if err != nil {
t.Fatalf("%q must pair: %v", good, err)
}
if cfg.Endpoint != good || len(cfg.Endpoints) != 1 || cfg.Endpoints[0] != good {
t.Fatalf("%q: saved %q %q", good, cfg.Endpoint, cfg.Endpoints)
}
}
}
// A pairing file that already holds an unsafe endpoint (written by an older
// build, or by hand) loads as invalid, naming the file and why: it is never
// served, while every other pairing still loads, status shows it, and unpair
// removes it. A machine that upgrades is never stopped by one such file.
func TestAPairingFileWithAnUnsafeEndpointLoadsButIsNeverServed(t *testing.T) {
uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
supervised(t)
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
write := func(c *Config) string {
t.Helper()
b, _ := json.Marshal(c)
p := filepath.Join(pd, c.HostName+".json")
if err := os.WriteFile(p, b, 0o600); err != nil {
t.Fatal(err)
}
return p
}
const host, other = "acme.host.privatae.ai", "other.host.privatae.ai"
write(&Config{HostName: other, Endpoint: goodTunnelEndpoints[0]})
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // a pairing over a socket has no endpoint
}
for _, c := range []*Config{
{HostName: host, Endpoint: bad},
{HostName: host, Endpoint: "203.0.113.7:51820", Endpoints: []string{bad}},
} {
p := write(c)
cfgs, err := loadPairings()
if err != nil || len(cfgs) != 2 {
t.Fatalf("%q: one bad file must not fail the load: %v %v", bad, cfgs, err)
}
got := pairingFor(cfgs, host)
if got.active() || got.stateName() != "invalid" {
t.Errorf("%q: the pairing must load invalid: active=%v %q", bad, got.active(), got.stateName())
}
if out := statusOutput(t); !strings.Contains(out, host+" [invalid]") ||
!strings.Contains(out, "not a host:port") || !strings.Contains(out, p) {
t.Errorf("%q: status must show the pairing invalid, naming its file and why:\n%s", bad, out)
}
if !pairingFor(cfgs, other).active() {
t.Errorf("%q: the other pairing must still be served", bad)
}
if startPairing(got) {
t.Errorf("%q: an invalid pairing was started", bad)
}
}
}
// The supervisor starts nothing for it either (the other pairing is
// removed first: starting it would raise a real tunnel).
if err := os.Remove(filepath.Join(pd, other+".json")); err != nil {
t.Fatal(err)
}
superviseOnce()
supervisor.Lock()
running := len(supervisor.runs)
supervisor.Unlock()
if running != 0 {
t.Fatalf("the supervisor started %d run(s) for an invalid pairing", running)
}
if h, err := unpairHost(host); err != nil || h != host {
t.Fatalf("unpair must remove an invalid pairing: %q %v", h, err)
}
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 0 {
t.Fatalf("after unpair nothing is left: %v %v", cfgs, err)
}
for _, good := range goodTunnelEndpoints {
write(&Config{HostName: host, Endpoint: good, Endpoints: goodTunnelEndpoints})
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 1 || cfgs[0].Endpoint != good || !cfgs[0].active() {
t.Fatalf("%q must load and be served: %v %v", good, cfgs, err)
}
}
write(&Config{HostName: host, Transport: transportUnix})
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 1 || !cfgs[0].active() {
t.Fatalf("a pairing with no endpoint must load and be served: %v %v", cfgs, err)
}
}
// statusOutput is what `status` prints.
func statusOutput(t *testing.T) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
stdout := os.Stdout
os.Stdout = w
serr := cmdStatus()
os.Stdout = stdout
_ = w.Close()
out, _ := io.ReadAll(r)
if serr != nil {
t.Fatalf("status: %v", serr)
}
return string(out)
}
// A rotate directive that names any unsafe endpoint is not acted on; one
// that names only host:port entries is.
func TestARotateDirectiveWithAnUnsafeEndpointIsIgnored(t *testing.T) {
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // an empty entry is no entry
}
for _, d := range [][]byte{
directive(bad, nil, "PK="),
directive("203.0.113.7:51820", []string{bad, "[2001:db8::7]:51820"}, "PK="),
} {
if c, pk := parseRotateTarget(d); len(c) != 0 || pk != "" {
t.Errorf("%q: the directive must be ignored, got %q %q", bad, c, pk)
}
}
}
c, pk := parseRotateTarget(directive("203.0.113.7:51820", goodTunnelEndpoints, "PK="))
if pk != "PK=" || !sameList(c, goodTunnelEndpoints) {
t.Fatalf("a directive of host:port entries must be taken: %q %q", c, pk)
}
}
+60 -6
View File
@@ -60,6 +60,27 @@ type freeagentConfig struct {
Cert string // PEM: the client identity this box presents
Key string
CA string // PEM: the root the local agent's server cert must chain to
// Mode is the sanitize mode this bastion wrote into the agent's manifest
// (faManagedSanitizeMode for every agent it deploys), or "" for an agent
// someone runs by hand, whose manifest this bastion did not write.
Mode string
}
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
// only: it replaces e-mail addresses, phone numbers, card, bank and ID
// numbers, IP and MAC addresses and keys, and does not find names, street
// addresses or dates of birth.
const faManagedSanitizeMode = "flag"
// faSanitizeNotes says, for each mode a reply can carry, what personal data
// may still be in the text, in the words the host relays. A mode with no entry
// here is reported as not known to remove personal data.
var faSanitizeNotes = map[string]string{
"flag": "rules only: e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses " +
"and keys were replaced; names, street addresses and dates of birth were not",
"sterilize": "",
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
}
func freeagentEnv() (freeagentConfig, bool) {
@@ -252,6 +273,16 @@ func freeagentProbe() (bool, bool) {
// local agent's own 403 (grant/mask) and 503 (manifest not
// serving) are refusals with the reason it gave; 404 on read_range is gone.
func freeagentMap(op string, status int, body []byte, err error) (map[string]any, string, bool) {
return freeagentMapMode(op, "", status, body, err)
}
// freeagentMapMode is freeagentMap for an agent whose manifest this bastion
// wrote with sanitize mode `mode` ("" when it did not write it). A successful
// reply says how its text was sanitized: `sanitize_mode` is the mode this
// bastion wrote, else the one the agent reports, else "unknown"; `sanitized`
// is true only for "sterilize", the mode that looks for names; and
// `sanitize_note` says what the mode leaves in.
func freeagentMapMode(op, mode string, status int, body []byte, err error) (map[string]any, string, bool) {
if err != nil {
return map[string]any{"state": "offline", "detail": "freeagent: " + err.Error()}, "", true
}
@@ -267,7 +298,7 @@ func freeagentMap(op string, status int, body []byte, err error) (map[string]any
return nil, "freeagent: malformed reply", false
case status == http.StatusOK:
payload["state"] = "ok"
payload["sanitized"] = true
faStampSanitize(payload, mode)
return payload, "", true
case status == http.StatusNotFound && op == "read_range":
return map[string]any{"state": "gone", "detail": reason}, "", true
@@ -286,10 +317,33 @@ func freeagentMap(op string, status int, body []byte, err error) (map[string]any
}
}
// faStampSanitize writes the three sanitize fields onto a successful reply.
// The mode this bastion wrote wins over the one the reply carries: a reply
// cannot make text it never sterilized read as sterilized.
func faStampSanitize(payload map[string]any, mode string) {
if mode == "" {
mode, _ = payload["sanitize_mode"].(string)
}
if mode == "" {
mode = "unknown"
}
note, known := faSanitizeNotes[mode]
if !known {
note = "the local agent did not say how this text was sanitized; it may contain personal data"
}
payload["sanitize_mode"] = mode
payload["sanitized"] = mode == "sterilize"
delete(payload, "sanitize_note")
if note != "" {
payload["sanitize_note"] = note
}
}
// handleDcnSearch — op `dcn_search`: args = <store_id> <json {query, k}>. Gated
// by the synced grant with the 'search' mask, then forwarded to the
// local agent's /search. Hits are sanitized windows with document ids the host
// feeds back to read_range.
// local agent's /search. Hits are windows of the agent's sanitized text, with
// document ids the host feeds back to read_range; faStampSanitize says which
// sanitize mode produced them.
func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
storeID, a, ok := dcnParseArgs(ws, args)
if !ok {
@@ -319,7 +373,7 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
}
status, body, err := freeagentCall(c, http.MethodPost, "/search",
map[string]any{"query": q, "k": k})
payload, why, ok := freeagentMap("search", status, body, err)
payload, why, ok := freeagentMapMode("search", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "search", storeID, "", why)
return
@@ -394,7 +448,7 @@ func handleDcnCatalog(ws *websocket.Conn, dir string, args []string) {
"state": "offline", "detail": "no catalog yet — the local agent's first sweep has not finished; try again later"})
return
}
payload, why, ok := freeagentMap("catalog", status, body, err)
payload, why, ok := freeagentMapMode("catalog", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, why)
return
@@ -427,7 +481,7 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
}
status, body, err := freeagentCall(c, http.MethodPost, "/read_range",
map[string]any{"doc": a.Doc, "start": start, "end": end})
payload, why, ok := freeagentMap("read_range", status, body, err)
payload, why, ok := freeagentMapMode("read_range", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, why)
return
+9 -1
View File
@@ -81,11 +81,19 @@ type faOwner struct {
gid uint32
name string
home string
// plat is what this platform needs beyond the above: nothing on unix, the
// plat is what this platform needs beyond the above: nothing on unix (an
// owner there is fully described by its uid, gid, name and home), the
// owner's SID on Windows (freeagent_drop_windows.go).
plat faOwnerPlat
}
// faOwnerPlat holds the owner's SID, as text, and their bare user name. Only
// Windows fills it; it stays zero on unix.
type faOwnerPlat struct {
sid string
user string
}
// faResolveOwner decides the identity for a store's agent. Where this
// platform drops and the daemon holds machine authority it is the granted
// folder's owner, refused for the machine's own accounts and for anyone who
-3
View File
@@ -20,9 +20,6 @@ func faDropSupported() bool { return true }
// own 0700 leaf.
func faRootStateBase() string { return "/var/lib/privatae/freeagent" }
// faOwnerPlat: a unix owner is fully described by its uid, gid, name and home.
type faOwnerPlat struct{}
// faHandsOverByChown: on unix every file root writes for the child is handed
// over by changing its owner.
const faHandsOverByChown = true
+6 -21
View File
@@ -71,12 +71,6 @@ func faRootStateBase() string {
return filepath.Join(d, "freeagent-state")
}
// faOwnerPlat holds the owner's SID, as text, and their bare user name.
type faOwnerPlat struct {
sid string
user string
}
// faHandsOverByChown: on Windows the hand-over is a permission entry on the
// state leaf, inherited by everything later created in it.
const faHandsOverByChown = false
@@ -98,15 +92,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
if err != nil || sid == nil {
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
}
name, domain, typ, err := sid.LookupAccount("")
// Bounded and cached (sidnames.go); no answer in time refuses the start.
acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane)
if err != nil {
return nil, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid.String())
return nil, err
}
full := name
if domain != "" {
full = domain + `\` + name
}
if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" {
name, full := acct.Account, sidAccountLabel(acct)
if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
return nil, errors.New(why)
}
home := faWinProfileDir(sid.String())
@@ -432,12 +424,6 @@ func (c *winChild) Wait() error {
return nil
}
func faPathOwner(string) (uint32, uint32, error) {
return 0, 0, errors.New("ownership is not read as a uid on windows")
}
func faChown(string, uint32, uint32) error { return nil }
// sysProcAttr: the Windows drop does not go through a credential; faStartChild
// starts the child under the owner's token itself.
func (o *faOwner) sysProcAttr() *syscall.SysProcAttr { return nil }
@@ -459,5 +445,4 @@ func faFdSane(f *os.File) error {
return nil
}
func faInfoOwner(os.FileInfo) (uint32, uint32, bool) { return 0, 0, false }
func faInfoLinks(os.FileInfo) (uint64, bool) { return 0, false }
func faInfoLinks(os.FileInfo) (uint64, bool) { return 0, false }
+26
View File
@@ -5,12 +5,38 @@ package main
// that gather their inputs are in the tagged file.
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode/utf16"
)
// faDropSIDLane bounds the lookup of the granted folder's owner (sidnames.go).
// The start runs on the store's own goroutine and nothing waits on it, so the
// budget is generous enough for a slow directory that does answer. It is a
// bound all the same: the store is marked as ensuring for as long as the start
// runs, and every reconcile skips it while it is, so a lookup that never
// returned would hold the store as "installing" for good. Timed out, the store
// is failed with the reason and the reconcile tries again
// (faReconcileEvery), by when a late answer is in the cache.
var faDropSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 10 * time.Second}
// faWinDropOwnerAccount is the folder owner's account from the directory. It
// fails closed: no account, or no answer in time, and no agent is started.
func faWinDropOwnerAccount(sid string, dir *sidDirectory, lane sidLane) (sidAccount, error) {
acct, outcome := dir.resolve(sid, lane)
switch outcome {
case sidTimedOut:
return sidAccount{}, fmt.Errorf("refusing to run the agent for now: this machine's account directory did not answer "+
"for the granted folder's owner (%s) within %v; it is tried again at the next check", sid, lane.budget)
case sidUnmapped:
return sidAccount{}, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid)
}
return acct, nil
}
// winSidTypeUser is SidTypeUser: the SID names a person's account rather than
// a group, an alias or a well-known identity.
const winSidTypeUser = 1
+3 -2
View File
@@ -500,7 +500,7 @@ func faManifestText(id, cn string, terms *dcnTerms, sweep, recipient, embedModel
fmt.Fprintf(&b, " exclude: [%s]\n", strings.Join(ex, ", "))
}
fmt.Fprintf(&b, "mask: [%s]\nstages: [%s]\n", strings.Join(mask, ", "), strings.Join(stages, ", "))
b.WriteString("sanitize:\n mode: flag\n")
fmt.Fprintf(&b, "sanitize:\n mode: %s\n", faManagedSanitizeMode)
if containsStr(stages, "embed") {
fmt.Fprintf(&b, "embed:\n model: %s\n dim: %d\n chunk: {size_tokens: 512, overlap_tokens: 64}\n", yamlStr(embedModel), embedDim)
}
@@ -677,7 +677,8 @@ func faManagedConfig(storeID int64) (freeagentConfig, bool) {
if err != nil || port <= 0 {
return freeagentConfig{}, false
}
return freeagentConfig{Addr: "127.0.0.1:" + strconv.Itoa(port), Cert: inst.Cert, Key: inst.Key, CA: inst.Cert}, true
return freeagentConfig{Addr: "127.0.0.1:" + strconv.Itoa(port), Cert: inst.Cert, Key: inst.Key, CA: inst.Cert,
Mode: faManagedSanitizeMode}, true
}
// ── ensure / reconcile ──────────────────────────────────────────────────────
+47 -2
View File
@@ -190,8 +190,11 @@ func TestFreeagentProbeAndEnv(t *testing.T) {
func TestFreeagentMap(t *testing.T) {
m, why, ok := freeagentMap("search", 200, []byte(`{"hits":[]}`), nil)
if !ok || m["state"] != "ok" || m["sanitized"] != true || why != "" {
t.Fatalf("200 → ok/sanitized, got %v %q", m, why)
if !ok || m["state"] != "ok" || why != "" {
t.Fatalf("200 → ok, got %v %q", m, why)
}
if m["sanitized"] != false || m["sanitize_mode"] != "unknown" || m["sanitize_note"] == nil {
t.Fatalf("a reply that names no mode is not called sanitized, got %v", m)
}
m, _, ok = freeagentMap("read_range", 404, []byte(`{"error":"no such document"}`), nil)
if !ok || m["state"] != "gone" {
@@ -298,3 +301,45 @@ func TestDcnOpArgsCarryFreeagentFields(t *testing.T) {
t.Fatalf("args not carried: %+v", a)
}
}
// The sanitize fields say what was done to the text. An agent this bastion
// deployed runs in flag mode, which leaves names in: its replies are never
// called sanitized, whatever the reply itself claims. An agent run by hand is
// reported in the mode it names, and only sterilize counts as sanitized.
func TestASanitizeStampNamesTheModeThatRan(t *testing.T) {
for _, c := range []struct {
name, mode, body, wantMode string
wantSanitized, wantNote bool
}{
{"deployed, reply silent", faManagedSanitizeMode, `{"hits":[]}`, "flag", false, true},
{"deployed, reply claims sterilize", faManagedSanitizeMode, `{"hits":[],"sanitize_mode":"sterilize","sanitized":true}`, "flag", false, true},
{"by hand, flag", "", `{"hits":[],"sanitize_mode":"flag"}`, "flag", false, true},
{"by hand, sterilize", "", `{"hits":[],"sanitize_mode":"sterilize","sanitize_note":"x"}`, "sterilize", true, false},
{"by hand, silent", "", `{"hits":[],"sanitized":true}`, "unknown", false, true},
{"by hand, no sanitize stage", "", `{"hits":[],"sanitize_mode":"none"}`, "none", false, true},
{"by hand, a mode this build does not know", "", `{"hits":[],"sanitize_mode":"other"}`, "other", false, true},
} {
for _, op := range []string{"search", "read_range", "catalog"} {
m, why, ok := freeagentMapMode(op, c.mode, 200, []byte(c.body), nil)
if !ok || why != "" || m["state"] != "ok" {
t.Fatalf("%s/%s: %v %q", c.name, op, m, why)
}
note, hasNote := m["sanitize_note"].(string)
if m["sanitize_mode"] != c.wantMode || m["sanitized"] != c.wantSanitized || hasNote != c.wantNote || (hasNote && note == "") {
t.Errorf("%s/%s: got mode %v sanitized %v note %q", c.name, op, m["sanitize_mode"], m["sanitized"], note)
}
}
}
if !strings.Contains(faSanitizeNotes["flag"], "names") {
t.Fatal("the flag note must say names are left in")
}
}
// The mode the stamp reports for a deployed agent is the mode the bastion
// writes into its manifest.
func TestTheDeployedManifestUsesTheStampedMode(t *testing.T) {
text := faManifestText("s1", "cn", &dcnTerms{Root: "/tmp/x", Mask: []string{"search"}}, "", "", "m", 8)
if !strings.Contains(text, "sanitize:\n mode: "+faManagedSanitizeMode+"\n") {
t.Fatalf("manifest sanitize block:\n%s", text)
}
}
+4 -4
View File
@@ -1,14 +1,14 @@
module git.privatae.ai/privatae/privatae-bastion
go 1.25.0
go 1.26.0
require (
filippo.io/age v1.3.2
fyne.io/systray v1.12.2
github.com/creack/pty v1.1.24
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0
golang.org/x/crypto v0.58.0
golang.org/x/sys v0.49.0
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446
gopkg.in/yaml.v3 v3.0.1
rsc.io/qr v0.2.0
@@ -18,7 +18,7 @@ require (
filippo.io/hpke v0.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/btree v1.1.2 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
+8 -8
View File
@@ -14,14 +14,14 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"go/ast"
"go/build"
"go/parser"
"go/token"
"os"
"sort"
"strings"
"testing"
)
// EVERY PLATFORM FUNCTION EXISTS ON EVERY TARGET THAT BUILDS THIS PACKAGE.
//
// A function with one file per platform (contextPeerUID in contextsock_linux.go
// and contextsock_darwin.go) and a caller in a file every unix builds
// (contextsock_unix.go) leaves any other unix with an undefined name, and
// nothing in CI cross-builds one: `GOOS=freebsd go build` failed that way.
// The release targets are linux, darwin and windows (deploy/build.sh);
// freebsd and openbsd are not released, but they build, with what has no
// implementation there refusing (contextsock_other.go and the other
// *_other.go files).
//
// This asks the toolchain which files each target compiles (go/build's
// MatchFile) and checks that every package function called from those files
// is declared in them. It is not a type check, and it does not compile; it
// catches the missing-platform-file mistake, which is the one that happened,
// on the platform the tests already run on.
func TestEveryCalledPlatformFunctionIsDeclaredOnEveryTarget(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
type parsed struct {
name string
file *ast.File
}
var files []parsed
declaredAnywhere := map[string]bool{}
fset := token.NewFileSet()
for _, e := range entries {
n := e.Name()
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, n, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("%s: %v", n, err)
}
files = append(files, parsed{n, f})
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
declaredAnywhere[fd.Name.Name] = true
}
}
}
if len(files) < 50 {
t.Fatalf("parsed only %d files; is the test running in the package directory?", len(files))
}
for _, goos := range []string{"linux", "darwin", "windows", "freebsd", "openbsd"} {
ctx := build.Default
ctx.GOOS = goos
ctx.CgoEnabled = false
declared := map[string]bool{}
var matched []parsed
for _, p := range files {
ok, err := ctx.MatchFile(".", p.name)
if err != nil {
t.Fatalf("%s: %v", p.name, err)
}
if !ok {
continue
}
matched = append(matched, p)
for _, d := range p.file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
declared[fd.Name.Name] = true
}
}
}
missing := map[string]string{}
for _, p := range matched {
ast.Inspect(p.file, func(x ast.Node) bool {
c, ok := x.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := c.Fun.(*ast.Ident); ok && declaredAnywhere[id.Name] && !declared[id.Name] {
missing[id.Name] = p.name
}
return true
})
}
var out []string
for fn, where := range missing {
out = append(out, fn+" (called in "+where+")")
}
sort.Strings(out)
if len(out) > 0 {
t.Errorf("GOOS=%s: called but not declared for this target: %s", goos, strings.Join(out, ", "))
}
}
}
+848 -105
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
package main
// The agent's credential for a harness run is a file in a folder mounted into
// the container. No other local user may reach either: as root the bastion
// hands both to the container's user, owner-only; as anyone else it opens them
// for that user, inside a folder only the bastion can enter.
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestTheHarnessCredentialIsReadableOnlyByTheContainersUser(t *testing.T) {
// A stand-in runtime: on `run` it records the mode and owner of the folder
// the credential folder sits in, of that folder and of the file, then
// fails, so the run stops there.
bin := t.TempDir()
record := filepath.Join(bin, "record")
rt := filepath.Join(bin, "runtime")
script := "#!/bin/sh\n[ \"$1\" = run ] || exit 1\nfor a; do case \"$a\" in *:/home/privatae/.claude) d=${a%:/home/privatae/.claude};; esac; done\n" +
"[ -n \"$d\" ] && stat -c '%a %u %g' \"$(dirname \"$d\")\" \"$d\" \"$d/.credentials.json\" > " + record + "\nexit 1\n"
if err := os.WriteFile(rt, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
spec := harnessSpec{HarnessID: "h1", Image: "harness:test", AgentCreds: `{"claudeAiOauth":{}}`}
if _, err := driveHarnessAgent(rt, t.TempDir(), spec, 1); err == nil {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatalf("the credential folder was not mounted: %v", err)
}
self := strconv.Itoa(os.Geteuid()) + " " + strconv.Itoa(os.Getegid())
want := "700 " + self + "\n777 " + self + "\n666 " + self
if os.Geteuid() == 0 {
want = "700 " + self + "\n700 10005 10005\n600 10005 10005" // the harness image's user
}
if strings.TrimSpace(string(got)) != want {
t.Fatalf("enclosing folder, folder, then file: want %q, got %q", want, strings.TrimSpace(string(got)))
}
}
@@ -0,0 +1,154 @@
package main
// A git run's copy is bounded by how many entries a tree holds as well as by
// its bytes; it never hands git the run's token in an argument or a config
// file; and it uses nothing of the agent's tree while the agent's container
// may still be running.
import (
"encoding/base64"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// Many empty files cost nothing in bytes; each still counts against the
// copy, so a tree of them is refused and nothing is pushed.
func TestAGitRunRefusesATreeOfTooManyEntries(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, `cd "$1"
i=0; while [ $i -lt 40 ]; do mkdir d$i; j=0; while [ $j -lt 100 ]; do : > d$i/f$j; j=$((j+1)); done; i=$((i+1)); done
`)
saved := maxCopiedTreeBytes
maxCopiedTreeBytes = 1 << 20 // 4000 entries at 512 bytes each are 2 MB
t.Cleanup(func() { maxCopiedTreeBytes = saved })
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Fatalf("a tree of 4000 empty files was copied under a 1 MiB limit: %v", res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "too large") {
t.Errorf("the error does not say the tree is too large: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// A container the runtime does not confirm removed fails the run before any
// of its tree is copied.
func TestAGitRunFailsWhenTheAgentsContainerIsNotRemoved(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
docker := filepath.Join(filepath.Dir(f.agentFile), "docker")
b, err := os.ReadFile(docker)
if err != nil {
t.Fatal(err)
}
failing := strings.Replace(string(b), "ps|pull|rm) exit 0;;", "ps|pull) exit 0;;\nrm) echo 'Error: removal stuck' >&2; exit 1;;", 1)
if failing == string(b) {
t.Fatal("the stand-in runtime changed shape")
}
if err := os.WriteFile(docker, []byte(failing), 0o755); err != nil {
t.Fatal(err)
}
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Fatalf("a run whose container was not removed reads ok: %v", res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "could not be removed") || !strings.Contains(e, "removal stuck") {
t.Errorf("the error does not say the container was not removed: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// Against a server that needs the token, the run clones and pushes with the
// token in no git argument and in no clone's config.
func TestAGitRunKeepsTheTokenOutOfEveryArgumentAndConfig(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
realGit, err := exec.LookPath("git")
if err != nil {
t.Skip("no git")
}
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("harness:"+gitRunToken))
backend := &cgi.Handler{Path: realGit, Args: []string{"http-backend"}, Root: "/git",
Env: []string{"GIT_PROJECT_ROOT=" + filepath.Dir(f.origin), "GIT_HTTP_EXPORT_ALL=1",
"REMOTE_USER=harness", "GIT_CONFIG_NOSYSTEM=1", "HOME=" + t.TempDir()}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != want {
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
http.Error(w, "auth", http.StatusUnauthorized)
return
}
backend.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
logs := t.TempDir()
argvLog, cfgLog := filepath.Join(logs, "argv"), filepath.Join(logs, "config")
bin := t.TempDir()
wrapper := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> " + argvLog + "\n" +
"prev=; for a; do if [ \"$prev\" = -C ] && [ -f \"$a/.git/config\" ]; then cat \"$a/.git/config\" >> " + cfgLog + "; fi; prev=$a; done\n" +
"exec " + realGit + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
repoURL := strings.Replace(srv.URL, "http://", "http://harness:"+gitRunToken+"@", 1) + "/git/" + filepath.Base(f.origin)
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: repoURL, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if !branchPushed(f) {
t.Fatal("the branch is not in the origin")
}
argv, _ := os.ReadFile(argvLog)
if !strings.Contains(string(argv), "push origin agent-work") {
t.Fatalf("the stand-in git did not see the push:\n%s", argv)
}
cfg, _ := os.ReadFile(cfgLog)
if len(cfg) == 0 {
t.Fatal("no clone's config was read")
}
for name, body := range map[string][]byte{"an argument": argv, "a clone's config": cfg} {
for _, secret := range []string{gitRunToken, strings.TrimPrefix(want, "Basic ")} {
if strings.Contains(string(body), secret) {
t.Errorf("the token is in %s:\n%s", name, body)
}
}
}
}
// Every git call carries the harness identity: nothing of this machine's user
// or host name reaches the clone the agent is handed, and the commit is the
// harness's.
func TestAGitRunsGitCarriesTheHarnessIdentity(t *testing.T) {
f := newGitRunFixture(t)
reflog := filepath.Join(t.TempDir(), "reflog")
f.setAgent(t, "cp \"$1/.git/logs/HEAD\" "+reflog+" && cd \"$1\" && echo new > edit.txt\n")
res, code := f.run(t)
if code != 0 {
t.Fatalf("the run failed: %v", res)
}
b, err := os.ReadFile(reflog)
if err != nil {
t.Fatal(err)
}
host, _ := os.Hostname()
if !strings.Contains(string(b), " harness <harness@privatae.ai> ") || (host != "" && strings.Contains(string(b), host)) {
t.Errorf("the agent's clone records another identity:\n%s", b)
}
who := strings.TrimSpace(testGit(t, f.origin, "log", "-1", "--format=%an <%ae> / %cn <%ce>", "agent-work"))
if who != "harness <harness@privatae.ai> / harness <harness@privatae.ai>" {
t.Errorf("the commit is by %q", who)
}
}
@@ -0,0 +1,360 @@
package main
// What a git run hands the agent, how it ends when its commit cannot be made,
// and the git-lfs filter it supplies itself.
import (
"bufio"
"bytes"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
const gitRunToken = "tok3n0f7hisrun5a1e"
// The agent's clone names the repository without the run's token, and the
// token is in no file of the tree it is handed; the run still pushes.
func TestAGitRunHandsTheAgentNoToken(t *testing.T) {
f := newGitRunFixture(t)
snap := filepath.Join(t.TempDir(), "agent-saw")
f.setAgent(t, "cp -a \"$1\" "+snap+" && cd \"$1\" && echo new > edit.txt\n")
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: "file://harness:" + gitRunToken + "@" + f.origin, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if bytes.Contains(b, []byte(gitRunToken)) {
t.Errorf("the result carries the token: %s", b)
}
n := 0
err := filepath.WalkDir(snap, func(p string, d fs.DirEntry, err error) error {
if err != nil || !d.Type().IsRegular() {
return err
}
n++
body, err := os.ReadFile(p)
if err == nil && bytes.Contains(body, []byte(gitRunToken)) {
t.Errorf("the token is in the agent's tree, in %s", p[len(snap):])
}
return err
})
if err != nil || n == 0 {
t.Fatalf("the agent's tree was not read (%d files): %v", n, err)
}
if got := testGit(t, f.origin, "show", "agent-work:edit.txt"); got != "new\n" {
t.Errorf("edit.txt = %q", got)
}
}
func TestRepoURLPartsAndLFSPins(t *testing.T) {
bare, secret, err := repoURLParts("https://host-user:" + gitRunToken + "@git.example.invalid/o/r.git")
if err != nil || bare != "https://git.example.invalid/o/r.git" || secret != gitRunToken {
t.Errorf("parts = %q, %q, %v", bare, secret, err)
}
if bare, secret, err := repoURLParts("/srv/x/origin.git"); err != nil || bare != "/srv/x/origin.git" || secret != "" {
t.Errorf("a path = %q, %q, %v", bare, secret, err)
}
pins := strings.Join(lfsEndpointPins("https://u:"+gitRunToken+"@git.example.invalid/o/r"), " ")
for _, k := range []string{"lfs.url", "lfs.pushurl", "remote.origin.lfsurl", "remote.origin.lfspushurl"} {
if !strings.Contains(pins, k+"=https://u:"+gitRunToken+"@git.example.invalid/o/r.git/info/lfs") {
t.Errorf("%s is not pinned to the repository's own LFS address: %s", k, pins)
}
}
if p := lfsEndpointPins("/srv/x/origin.git"); p != nil {
t.Errorf("a path got pins: %v", p)
}
}
// standInGit puts a git on PATH that fails on the subcommand fail and is the
// real git otherwise.
func standInGit(t *testing.T, fail string) {
t.Helper()
real, err := exec.LookPath("git")
if err != nil {
t.Skip("no git")
}
bin := t.TempDir()
script := "#!/bin/sh\nfor a; do [ \"$a\" = " + fail + " ] && { echo 'stand-in: " + fail + " failed' >&2; exit 128; }; done\nexec " + real + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func branchPushed(f gitRunFixture) bool {
return exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Run() == nil
}
// A commit that fails is the run failing: nothing is pushed.
func TestAGitRunWhoseCommitFailsPushesNothing(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
standInGit(t, "commit")
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Errorf("a run whose commit failed reads ok: code %d, %v", code, res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "commit") || !strings.Contains(e, "nothing was pushed") {
t.Errorf("the error does not say the commit failed: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// A run that changed nothing is ok, says so, and pushes no branch.
func TestAGitRunThatChangesNothingPushesNothing(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "true\n")
res, code := f.run(t)
if code != 0 || res["ok"] != true || res["changed"] != false {
t.Errorf("want ok with changed false: code %d, %v", code, res)
}
if _, ok := res["branch"]; ok {
t.Errorf("the result names a branch: %v", res["branch"])
}
if branchPushed(f) {
t.Error("a branch was pushed for a run that changed nothing")
}
}
// Names a file system may open as .git are left out of the copy, and the
// rest of the run goes on.
func TestAGitRunLeavesOutEveryNameThatOpensAsDotGit(t *testing.T) {
f := newGitRunFixture(t)
names := []string{".git.", ".GIT ", "git~1", "GIT~1. ", ".g\u200cit", ".git::$INDEX_ALLOCATION"}
script := "cd \"$1\" && echo new > edit.txt\n"
for _, n := range names {
script += "mkdir '" + n + "' && echo '[core]' > '" + n + "/config'\n"
}
f.setAgent(t, script)
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Fatalf("the run failed: code %d, %v", code, res)
}
files := testGit(t, f.origin, "ls-tree", "-r", "--name-only", "-z", "agent-work")
for _, n := range names {
if strings.Contains(files, n+"/") {
t.Errorf("%q is in the commit", n)
}
}
skipped, _ := res["worktree_skipped"].([]any)
listed := map[string]bool{}
for _, s := range skipped {
listed[fmt.Sprint(s)] = true
}
for _, n := range names {
if !listed[n] {
t.Errorf("%q is not listed as left out: %v", n, skipped)
}
}
for _, n := range []string{".git", ".Git", ".git.", ".git . .", "GiT~1", ".gi\ufefft", ".git:x"} {
if !dotGitName(n) {
t.Errorf("dotGitName(%q) = false", n)
}
}
for _, n := range []string{".gitignore", ".gitattributes", "git", ".github", "git~2", "x.git"} {
if dotGitName(n) {
t.Errorf("dotGitName(%q) = true", n)
}
}
}
// With git-lfs installed, the bastion's own git calls clean and smudge
// through it and push its objects; the agent's .git cannot replace it, and a
// filter of another name the agent's .gitattributes names runs nothing.
func TestAGitRunUsesTheLFSFilterOnlyFromThisProcess(t *testing.T) {
f := newGitRunFixture(t)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
if err := os.WriteFile(filepath.Join(seed, ".gitattributes"), []byte("*.bin filter=lfs -text\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(seed, "big.bin"), []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "lfs")
testGit(t, seed, "push", "-q", "origin", "main")
bin := t.TempDir()
lfsLog := filepath.Join(t.TempDir(), "lfs.log")
wrapper := "#!/bin/sh\nGO_HELPER_GITLFS=1 GO_HELPER_GITLFS_LOG=" + lfsLog + " exec " + os.Args[0] +
" -test.run='^TestHelperGitLFS$' -- \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git-lfs"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
mark := func(name string) string { return "touch " + filepath.Join(f.marks, name) }
f.setAgent(t, `set -e
cd "$1"
git config filter.lfs.process '`+mark("agent-lfs-process")+`; exit 1'
git config filter.lfs.clean '`+mark("agent-lfs-clean")+`; cat'
git config filter.evil.clean '`+mark("evil")+`; cat'
printf '*.bin filter=lfs -text\n*.txt filter=evil\n' > .gitattributes
echo v2 > big.bin
echo new > edit.txt
`)
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Fatalf("the run failed: code %d, %v", code, res)
}
ents, _ := os.ReadDir(f.marks)
for _, e := range ents {
t.Errorf("the agent's %s ran as this process", e.Name())
}
logged, _ := os.ReadFile(lfsLog)
for _, want := range []string{"smudge big.bin", "clean big.bin", "push origin agent-work"} {
if !strings.Contains(string(logged), want+"\n") {
t.Errorf("git-lfs did not %s; it logged:\n%s", want, logged)
}
}
for _, line := range strings.Split(string(logged), "\n") {
if strings.HasSuffix(line, ".txt") {
t.Errorf("git-lfs ran for a file of another filter: %s", line)
}
}
if got := testGit(t, f.origin, "show", "agent-work:big.bin"); got != "v2\n" {
t.Errorf("big.bin = %q", got)
}
}
// TestHelperGitLFS is the stand-in git-lfs: run by the wrapper above, it is
// an identity filter that logs what it was asked to do.
func TestHelperGitLFS(t *testing.T) {
if os.Getenv("GO_HELPER_GITLFS") != "1" {
return
}
args := os.Args
for i, a := range args {
if a == "--" {
args = args[i+1:]
break
}
}
logf, err := os.OpenFile(os.Getenv("GO_HELPER_GITLFS_LOG"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
os.Exit(2)
}
switch {
case len(args) > 0 && args[0] == "filter-process":
if err := helperFilterProcess(logf); err != nil {
fmt.Fprintln(os.Stderr, "stand-in git-lfs:", err)
os.Exit(1)
}
case len(args) > 0 && (args[0] == "clean" || args[0] == "smudge"):
fmt.Fprintf(logf, "%s %s\n", args[0], args[len(args)-1])
_, _ = io.Copy(os.Stdout, os.Stdin)
default:
fmt.Fprintln(logf, strings.Join(args, " "))
}
logf.Close()
os.Exit(0)
}
func pktRead(r *bufio.Reader) ([]byte, bool, error) {
var h [4]byte
if _, err := io.ReadFull(r, h[:]); err != nil {
return nil, false, err
}
n, err := strconv.ParseUint(string(h[:]), 16, 16)
if err != nil {
return nil, false, err
}
if n == 0 {
return nil, true, nil
}
b := make([]byte, n-4)
_, err = io.ReadFull(r, b)
return b, false, err
}
func pktList(r *bufio.Reader) ([]string, error) {
var out []string
for {
b, flush, err := pktRead(r)
if err != nil || flush {
return out, err
}
out = append(out, strings.TrimSuffix(string(b), "\n"))
}
}
func pktWrite(w io.Writer, lines ...string) {
for _, l := range lines {
fmt.Fprintf(w, "%04x%s", len(l)+4, l)
}
_, _ = w.Write([]byte("0000"))
}
// helperFilterProcess speaks git's long-running filter protocol, version 2,
// returning every file as it came.
func helperFilterProcess(logf io.Writer) error {
r, w := bufio.NewReader(os.Stdin), bufio.NewWriter(os.Stdout)
if _, err := pktList(r); err != nil {
return err
}
pktWrite(w, "git-filter-server\n", "version=2\n")
if err := w.Flush(); err != nil {
return err
}
if _, err := pktList(r); err != nil {
return err
}
pktWrite(w, "capability=clean\n", "capability=smudge\n")
if err := w.Flush(); err != nil {
return err
}
for {
head, err := pktList(r)
if err == io.EOF {
return nil
}
if err != nil {
return err
}
cmd, path := "", ""
for _, kv := range head {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "command":
cmd = v
case "pathname":
path = v
}
}
var body bytes.Buffer
for {
b, flush, err := pktRead(r)
if err != nil {
return err
}
if flush {
break
}
body.Write(b)
}
fmt.Fprintf(logf, "%s %s\n", cmd, path)
pktWrite(w, "status=success\n")
data := body.Bytes()
for len(data) > 0 {
n := min(len(data), 65516)
fmt.Fprintf(w, "%04x", n+4)
_, _ = w.Write(data[:n])
data = data[n:]
}
_, _ = w.Write([]byte("0000"))
pktWrite(w)
if err := w.Flush(); err != nil {
return err
}
}
}
+355
View File
@@ -0,0 +1,355 @@
package main
// A git run's tree is the agent's, .git included. These tests stand in for an
// agent that uses that .git against the bastion: hooks, a file system
// monitor, a filter, a signing program, a changed remote and a link to a file
// outside the tree, besides ordinary edits. The container runtime is a
// stand-in script that does the agent's work on the mounted tree; the sidecar
// it would start is a local HTTP server on the sidecar's port.
import (
"encoding/json"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
const gitRunOutsideSecret = "outside-the-tree-7c41"
// gitRunFixture is an origin with one commit, a decoy repository, a file
// outside the tree, a folder the agent's code marks when it runs, and a
// stand-in runtime that runs agent (a shell script, given the tree's path as
// $1) when the run starts its container.
type gitRunFixture struct {
origin, decoy, outside, marks, agentFile string
}
// setAgent writes the script the stand-in runtime runs as the agent.
func (f gitRunFixture) setAgent(t *testing.T, script string) {
t.Helper()
if err := os.WriteFile(f.agentFile, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
func testGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func newGitRunFixture(t *testing.T) gitRunFixture {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
tmp := t.TempDir()
// The test's own git, and git as the code before this change ran it, read
// no config of this machine's.
home := filepath.Join(tmp, "home")
if err := os.Mkdir(home, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_AUTHOR_NAME", "seed")
t.Setenv("GIT_AUTHOR_EMAIL", "seed@example.invalid")
t.Setenv("GIT_COMMITTER_NAME", "seed")
t.Setenv("GIT_COMMITTER_EMAIL", "seed@example.invalid")
f := gitRunFixture{
origin: filepath.Join(tmp, "origin.git"),
decoy: filepath.Join(tmp, "decoy.git"),
outside: filepath.Join(tmp, "outside", "secret"),
marks: filepath.Join(tmp, "marks"),
}
for _, d := range []string{filepath.Dir(f.outside), f.marks} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(f.outside, []byte(gitRunOutsideSecret), 0o600); err != nil {
t.Fatal(err)
}
testGit(t, tmp, "init", "-q", "--bare", "-b", "main", f.origin)
testGit(t, tmp, "init", "-q", "--bare", "-b", "main", f.decoy)
seed := filepath.Join(tmp, "seed")
testGit(t, tmp, "init", "-q", "-b", "main", seed)
for name, body := range map[string]string{"keep.txt": "keep\n", "edit.txt": "old\n", "gone.txt": "bye\n"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(seed, "tool.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "seed")
testGit(t, seed, "push", "-q", f.origin, "main")
bin := t.TempDir()
f.agentFile = filepath.Join(bin, "agent.sh")
agentFile := f.agentFile
runtime := "#!/bin/sh\ncase \"$1\" in\nps|pull|rm) exit 0;;\ninspect) echo 127.0.0.1; exit 0;;\n" +
"run) for a; do case \"$a\" in *:/work) d=${a%:/work};; esac; done\n" +
" /bin/sh " + agentFile + " \"$d\" > " + filepath.Join(bin, "agent.log") + " 2>&1 || exit 1\n" +
" echo standin0123456789; exit 0;;\nesac\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(runtime), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Cleanup(func() {
if t.Failed() {
if b, err := os.ReadFile(filepath.Join(bin, "agent.log")); err == nil {
t.Logf("agent log:\n%s", b)
}
}
})
// The sidecar the run drives: it answers, and /run reports success.
ln, err := net.Listen("tcp", "127.0.0.1:8844")
if err != nil {
t.Fatalf("the sidecar's port is taken: %v", err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/run" {
_, _ = w.Write([]byte(`{"ok":true,"transcript":"done"}`))
}
})}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
return f
}
func (f gitRunFixture) run(t *testing.T) (map[string]any, int) {
t.Helper()
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: f.origin, Branch: "agent-work", MaxMinutes: 1})
var res map[string]any
if err := json.Unmarshal(b, &res); err != nil {
t.Fatalf("result: %v: %s", err, b)
}
return res, code
}
// theAgent turns its tree's .git against whoever runs git in it next, and
// makes ordinary changes to the files.
func theAgent(f gitRunFixture) string {
mark := func(name string) string { return "touch " + filepath.Join(f.marks, name) }
return `set -e
d="$1"; cd "$d"
g="$d/.git"
printf '#!/bin/sh\n` + mark("pre-commit") + `\n' > "$g/hooks/pre-commit"
printf '#!/bin/sh\n` + mark("post-commit") + `\n' > "$g/hooks/post-commit"
chmod +x "$g/hooks/pre-commit" "$g/hooks/post-commit"
printf '#!/bin/sh\n` + mark("fsmonitor") + `\nexit 1\n' > "$g/fsmon.sh"; chmod +x "$g/fsmon.sh"
git config core.fsmonitor "$g/fsmon.sh"
printf '* filter=evil\n' > .gitattributes
git config filter.evil.clean '` + mark("filter") + `; cat ` + f.outside + `'
git config filter.evil.smudge cat
printf '#!/bin/sh\n` + mark("gpg") + `\necho >&2; echo "[GNUPG:] SIG_CREATED D 1 8 00 0 X" >&2\necho "-----BEGIN PGP SIGNATURE-----"; echo x; echo "-----END PGP SIGNATURE-----"\n' > "$g/gpg.sh"
chmod +x "$g/gpg.sh"
git config commit.gpgsign true
git config gpg.program "$g/gpg.sh"
git config remote.origin.url ` + f.decoy + `
ln -s ` + f.outside + ` link-to-outside
echo new > edit.txt
echo added > added.txt
rm gone.txt
mkdir sub; echo deep > sub/deep.txt
`
}
func TestAGitRunNeverRunsGitInTheAgentsTree(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, theAgent(f))
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Errorf("the run failed: code %d, %v", code, res)
}
// Nothing the agent set in its .git ran.
ents, _ := os.ReadDir(f.marks)
for _, e := range ents {
t.Errorf("the agent's %s ran as this process", e.Name())
}
// The push went to the repository the run was given, not the one the
// agent named.
if refs := strings.TrimSpace(testGit(t, f.decoy, "for-each-ref")); refs != "" {
t.Errorf("the push went to the agent's remote: %s", refs)
}
out, err := exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Output()
if err != nil {
t.Fatalf("the branch is not in the origin: %v", err)
}
tip := strings.TrimSpace(string(out))
if n := strings.TrimSpace(testGit(t, f.origin, "rev-list", "--count", "main.."+tip)); n != "1" {
t.Errorf("the branch carries %s commits on main, want 1", n)
}
// Exactly the agent's file changes: the link is not there, and the outside
// file's content is in no file of the commit.
files := strings.Fields(testGit(t, f.origin, "ls-tree", "-r", "--format=%(objectmode) %(path)", tip))
got := map[string]string{}
for i := 0; i+1 < len(files); i += 2 {
got[files[i+1]] = files[i]
}
want := map[string]string{".gitattributes": "100644", "added.txt": "100644", "edit.txt": "100644",
"keep.txt": "100644", "sub/deep.txt": "100644", "tool.sh": "100755"}
if !sameMap(got, want) {
t.Errorf("the commit holds %v, want %v", got, want)
}
for path := range got {
body := testGit(t, f.origin, "show", tip+":"+path)
if strings.Contains(body, gitRunOutsideSecret) {
t.Errorf("%s in the commit holds the file outside the tree", path)
}
}
for path, body := range map[string]string{"edit.txt": "new\n", "added.txt": "added\n", "keep.txt": "keep\n"} {
if _, ok := got[path]; ok {
if b := testGit(t, f.origin, "show", tip+":"+path); b != body {
t.Errorf("%s = %q, want %q", path, b, body)
}
}
}
if s, _ := res["worktree_skipped"].([]any); len(s) != 1 || s[0] != "link-to-outside" {
t.Errorf("left out = %v, want [link-to-outside]", res["worktree_skipped"])
}
for _, k := range []string{"branch", "commit", "commit_out"} {
if _, ok := res[k]; !ok {
t.Errorf("the result has no %s", k)
}
}
if res["commit"] != tip {
t.Errorf("the result names commit %v, the branch is at %s", res["commit"], tip)
}
}
// A link the repository itself holds stays as it is when the agent leaves it
// alone: a run that cannot copy a link must not delete it.
func TestAGitRunKeepsALinkTheRepositoryHolds(t *testing.T) {
f := newGitRunFixture(t)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
if err := os.Symlink("keep.txt", filepath.Join(seed, "alias")); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "a link")
testGit(t, seed, "push", "-q", "origin", "main")
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
res, code := f.run(t)
if code != 0 {
t.Fatalf("the run failed: %v", res)
}
if mode := strings.Fields(testGit(t, f.origin, "ls-tree", "agent-work", "alias")); len(mode) == 0 || mode[0] != "120000" {
t.Errorf("the repository's link is gone from the branch: %v", mode)
}
if diff := strings.Fields(testGit(t, f.origin, "diff", "--name-only", "main", "agent-work")); len(diff) != 1 || diff[0] != "edit.txt" {
t.Errorf("the branch changes %v, want [edit.txt]", diff)
}
}
// A file far larger than the copy may take (sparse, so it costs nothing to
// make) is refused from its size, before a byte of it is read: the run ends
// at once, pushes nothing, and says why.
func TestAGitRunRefusesATreeLargerThanItCopies(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && truncate -s 64G huge.bin && echo new > edit.txt\n")
type out struct {
res map[string]any
code int
}
ch := make(chan out, 1)
start := time.Now()
go func() { r, c := f.run(t); ch <- out{r, c} }()
var o out
select {
case o = <-ch:
case <-time.After(30 * time.Second):
t.Fatal("a run with a 64 GiB file was still going after 30 s")
}
t.Logf("returned in %v", time.Since(start))
if o.code == 0 || o.res["ok"] != false {
t.Fatalf("a tree over the cap was pushed: %v", o.res)
}
if e, _ := o.res["error"].(string); !strings.Contains(e, "huge.bin") || !strings.Contains(e, "too large") {
t.Errorf("the error does not say what was too large: %q", e)
}
if err := exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Run(); err == nil {
t.Error("the branch was pushed")
}
}
// The tree a worktree run returns is refused the same way, and no part of
// it is returned.
func TestAWorktreeRunRefusesATreeLargerThanTheLaneCarries(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && truncate -s 64G huge.bin\n")
ch := make(chan []byte, 1)
start := time.Now()
go func() {
b, _ := runWorktree(harnessSpec{Mode: "worktree", HarnessID: "h1", Image: "harness:test", MaxMinutes: 1})
ch <- b
}()
var b []byte
select {
case b = <-ch:
case <-time.After(30 * time.Second):
t.Fatal("a run with a 64 GiB file was still going after 30 s")
}
t.Logf("returned in %v", time.Since(start))
var res map[string]any
if err := json.Unmarshal(b, &res); err != nil {
t.Fatal(err)
}
if res["ok"] != false || res["worktree_b64"] != nil {
t.Fatalf("a tree over the cap was returned: ok=%v, %d bytes of tree", res["ok"], len(b))
}
if e, _ := res["error"].(string); !strings.Contains(e, "huge.bin") || !strings.Contains(e, "too large") {
t.Errorf("the error does not say what was too large: %q", e)
}
}
func sameMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
ka := make([]string, 0, len(a))
for k := range a {
ka = append(ka, k)
}
sort.Strings(ka)
for _, k := range ka {
if b[k] != a[k] {
return false
}
}
return true
}
// A repository with no commit yet gets the run's commit as its first.
func TestAGitRunIntoAnEmptyRepositoryPushesItsFirstCommit(t *testing.T) {
f := newGitRunFixture(t)
empty := filepath.Join(t.TempDir(), "empty.git")
testGit(t, "", "init", "-q", "--bare", "-b", "main", empty)
f.setAgent(t, "cd \"$1\" && echo first > first.txt\n")
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: empty, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if got := testGit(t, empty, "show", "agent-work:first.txt"); got != "first\n" {
t.Errorf("first.txt = %q", got)
}
}
@@ -0,0 +1,143 @@
package main
// A git run against a repository that keeps a file in Git LFS, with the real
// git-lfs. It runs only where git-lfs is installed; every server it talks to
// is in this test, on loopback.
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
)
func lfsPointer(body string) (oid, pointer string) {
sum := sha256.Sum256([]byte(body))
oid = hex.EncodeToString(sum[:])
return oid, fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", oid, len(body))
}
func TestAGitRunWithTheRealGitLFS(t *testing.T) {
if out, err := exec.Command("git-lfs", "version").Output(); err != nil || !strings.HasPrefix(string(out), "git-lfs/") {
t.Skip("git-lfs is not installed")
}
f := newGitRunFixture(t)
realGit, _ := exec.LookPath("git")
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("harness:"+gitRunToken))
var mu sync.Mutex
store := map[string][]byte{}
v1oid, v1ptr := lfsPointer("v1\n")
store[v1oid] = []byte("v1\n")
var srvURL string
backend := &cgi.Handler{Path: realGit, Args: []string{"http-backend"}, Root: "/git",
Env: []string{"GIT_PROJECT_ROOT=" + filepath.Dir(f.origin), "GIT_HTTP_EXPORT_ALL=1",
"REMOTE_USER=harness", "GIT_CONFIG_NOSYSTEM=1", "HOME=" + t.TempDir()}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/store/") { // the object hrefs the batch answer gives
oid := strings.TrimPrefix(r.URL.Path, "/store/")
mu.Lock()
defer mu.Unlock()
if r.Method == http.MethodPut {
b, _ := io.ReadAll(r.Body)
store[oid] = b
return
}
_, _ = w.Write(store[oid])
return
}
if r.Header.Get("Authorization") != want {
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
http.Error(w, "auth", http.StatusUnauthorized)
return
}
if !strings.Contains(r.URL.Path, "/info/lfs/") {
backend.ServeHTTP(w, r)
return
}
if !strings.HasSuffix(r.URL.Path, "/objects/batch") {
http.NotFound(w, r) // locks: not supported here
return
}
var req struct {
Operation string `json:"operation"`
Objects []struct {
Oid string `json:"oid"`
Size int64 `json:"size"`
} `json:"objects"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
var objs []map[string]any
mu.Lock()
for _, o := range req.Objects {
obj := map[string]any{"oid": o.Oid, "size": o.Size, "authenticated": true}
_, have := store[o.Oid]
if req.Operation == "download" || !have {
obj["actions"] = map[string]any{req.Operation: map[string]any{"href": srvURL + "/store/" + o.Oid}}
}
objs = append(objs, obj)
}
mu.Unlock()
w.Header().Set("Content-Type", "application/vnd.git-lfs+json")
_ = json.NewEncoder(w).Encode(map[string]any{"transfer": "basic", "objects": objs})
}))
t.Cleanup(srv.Close)
srvURL = srv.URL
var foreignHits atomic.Int64
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreignHits.Add(1)
http.Error(w, "no", http.StatusForbidden)
}))
t.Cleanup(foreign.Close)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
for name, body := range map[string]string{".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", "big.bin": v1ptr} {
if err := os.WriteFile(filepath.Join(seed, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "an lfs file")
testGit(t, seed, "push", "-q", "origin", "main")
// The agent changes the large file and names a foreign LFS server.
f.setAgent(t, `set -e
cd "$1"
grep -qx v1 big.bin
echo v2 > big.bin
printf '[lfs]\n\turl = `+foreign.URL+`/x.git/info/lfs\n\tpushurl = `+foreign.URL+`/x.git/info/lfs\n[remote "origin"]\n\tlfsurl = `+foreign.URL+`/y\n' > .lfsconfig
`)
repoURL := strings.Replace(srv.URL, "http://", "http://harness:"+gitRunToken+"@", 1) + "/git/" + filepath.Base(f.origin)
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: repoURL, Branch: "agent-work", MaxMinutes: 1})
t.Logf("result: %s", b)
if code != 0 {
t.Fatalf("the run failed")
}
v2oid, v2ptr := lfsPointer("v2\n")
if got := testGit(t, f.origin, "show", "agent-work:big.bin"); got != v2ptr {
t.Errorf("big.bin on the branch = %q, want the pointer to v2", got)
}
mu.Lock()
got := string(store[v2oid])
mu.Unlock()
if got != "v2\n" {
t.Errorf("the LFS object for v2 was not sent to the repository's own LFS server (%q)", got)
}
if n := foreignHits.Load(); n != 0 {
t.Errorf("the foreign LFS server named in .lfsconfig got %d requests", n)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"errors"
"strconv"
"golang.org/x/sys/unix"
)
// openTreeEntry opens the entry name in the folder dfd with flags, for
// packFolder.
//
// A folder is opened directly: with O_DIRECTORY the kernel refuses anything
// that is not a folder before it opens it. Anything else is first opened as a
// path only (O_PATH), which runs no driver code, so a device, pipe or terminal
// swapped in since the look is never opened: opening some devices does
// something by itself (a watchdog arms, a tape rewinds on close). Only a plain
// file is then opened for reading, through /proc/self/fd, which reaches the
// file the path fd holds and no other.
func openTreeEntry(dfd int, name string, flags int) (int, error) {
if flags&unix.O_DIRECTORY != 0 {
return unix.Openat(dfd, name, flags, 0)
}
pfd, err := unix.Openat(dfd, name, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return -1, err
}
defer unix.Close(pfd)
var st unix.Stat_t
if err := unix.Fstat(pfd, &st); err != nil {
return -1, err
}
if st.Mode&unix.S_IFMT != unix.S_IFREG {
return -1, errNotPlainFile
}
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), flags&^unix.O_NOFOLLOW, 0)
if errors.Is(err, unix.ENOENT) {
// No /proc mounted: open by name as other systems do. packOpened
// still checks that what was opened is the file that was looked at.
return unix.Openat(dfd, name, flags, 0)
}
return fd, err
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/unix"
)
// A name swapped for a special file between the walk's look and its open must
// never be opened: opening a device runs its driver (a watchdog arms, a tape
// rewinds on close, a serial line raises DTR), whatever is read after. A named
// pipe stands in for any of them, since anyone can make one, and inotify
// reports every open of it except one made only as a path (O_PATH), which runs
// no driver code. Both a name looked at as a plain file and one looked at as
// a folder are swapped.
func TestMakeTarNeverOpensASpecialFileSwappedInBeforeTheOpen(t *testing.T) {
for _, swap := range []string{"a.txt", "sub"} {
t.Run(swap, func(t *testing.T) {
_, _, tree := outsideAndTree(t)
in, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
t.Skipf("no inotify: %v", err)
}
defer unix.Close(in)
watched := false
packTreeBeforeOpen = func(rel string) {
if rel != swap {
return
}
p := filepath.Join(tree, swap)
if err := os.RemoveAll(p); err != nil {
t.Errorf("remove %s: %v", p, err)
return
}
if err := unix.Mkfifo(p, 0o644); err != nil {
t.Errorf("mkfifo %s: %v", p, err)
return
}
if _, err := unix.InotifyAddWatch(in, p, unix.IN_OPEN); err != nil {
t.Errorf("watch %s: %v", p, err)
return
}
watched = true
}
defer func() { packTreeBeforeOpen = nil }()
b, skipped, err := packWorktree(tree)
if err != nil {
t.Fatalf("packWorktree: %v", err)
}
if !watched {
t.Fatal("the swap never ran")
}
buf := make([]byte, 4096)
if n, err := unix.Read(in, buf); n > 0 {
t.Fatalf("the special file swapped in for %s was opened (%d bytes of inotify events)", swap, n)
} else if err != unix.EAGAIN {
t.Fatalf("read inotify: %d %v", n, err)
}
if _, ok := tarEntries(t, b)[swap]; ok {
t.Fatalf("%s was a named pipe when it was opened and must be left out", swap)
}
if !strings.Contains(","+strings.Join(skipped, ",")+",", ","+swap+",") {
t.Fatalf("%s must be named as left out, got %v", swap, skipped)
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !linux
package main
import "golang.org/x/sys/unix"
// openTreeEntry opens the entry name in the folder dfd with flags, for
// packFolder. These systems have no open that runs no driver code, so a
// special file swapped in since the look is opened (O_NONBLOCK, O_NOCTTY),
// then closed unread by packOpened.
func openTreeEntry(dfd int, name string, flags int) (int, error) {
return unix.Openat(dfd, name, flags, 0)
}
+114
View File
@@ -0,0 +1,114 @@
//go:build !windows
package main
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
// errNotPlainFile is openTreeEntry's refusal of a name that is no longer a
// plain file when it is opened.
var errNotPlainFile = errors.New("not a plain file")
// packTree hands the tree under root to sink, walking it by open folder
// (packWorktree says why). Each name is looked at with fstatat and opened
// relative to the folder above it (openTreeEntry, per system), never
// following a link, and it goes in only if the opened file is still the plain
// file or folder that was looked at (same type, device and inode). A pipe or
// device seen as one is never opened, so the walk cannot wait on one; on
// Linux one swapped in between is not opened either, and elsewhere O_NONBLOCK
// and O_NOCTTY cover it. Each left-out entry goes to sink.skip.
func packTree(sink treeSink, root string) error {
fd, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return &os.PathError{Op: "open", Path: root, Err: err}
}
d := os.NewFile(uintptr(fd), root)
defer d.Close()
return packFolder(sink, d, fd, "", 0)
}
func packFolder(sink treeSink, d *os.File, dfd int, rel string, depth int) error {
names, err := readTreeNames(sink, d, rel)
if err != nil {
return err
}
if err := sink.folder(rel, names); err != nil {
return err
}
for _, name := range names {
p := name
if rel != "" {
p = rel + "/" + name
}
var st unix.Stat_t
if err := unix.Fstatat(dfd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
sink.skip(p) // gone since the folder was read
continue
}
kind := st.Mode & unix.S_IFMT
if kind == unix.S_IFDIR && name == ".git" {
continue
}
if (kind != unix.S_IFREG && kind != unix.S_IFDIR) || (kind == unix.S_IFDIR && depth >= maxTreeDepth) {
sink.skip(p)
continue
}
if packTreeBeforeOpen != nil {
packTreeBeforeOpen(p)
}
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK | unix.O_NOCTTY
if kind == unix.S_IFDIR {
flags |= unix.O_DIRECTORY
}
cfd, err := openTreeEntry(dfd, name, flags)
if err != nil {
sink.skip(p) // a link or special file now, or gone
continue
}
err = packOpened(sink, cfd, name, st, p, depth)
if err != nil {
return err
}
}
return nil
}
// packOpened hands sink the entry opened as fd, and for a folder what is in it,
// if it is still the entry fstatat saw; it closes fd.
func packOpened(sink treeSink, fd int, name string, seen unix.Stat_t, p string, depth int) error {
var got unix.Stat_t
if err := unix.Fstat(fd, &got); err != nil {
unix.Close(fd)
return fmt.Errorf("stat %q: %w", p, err)
}
kind := got.Mode & unix.S_IFMT
if kind != seen.Mode&unix.S_IFMT || got.Dev != seen.Dev || got.Ino != seen.Ino {
unix.Close(fd)
sink.skip(p) // swapped between the look and the open
return nil
}
// A plain file or folder now: back to blocking reads, so os.File reads it
// as it reads any file it opened itself.
if err := unix.SetNonblock(fd, false); err != nil {
unix.Close(fd)
return fmt.Errorf("open %q: %w", p, err)
}
f := os.NewFile(uintptr(fd), name)
defer f.Close()
fi, err := f.Stat()
if err != nil {
return fmt.Errorf("stat %q: %w", p, err)
}
if err := sink.put(f, fi, p); err != nil {
return err
}
if kind == unix.S_IFDIR {
return packFolder(sink, f, fd, p, depth+1)
}
return nil
}
+204
View File
@@ -0,0 +1,204 @@
//go:build !windows
package main
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
)
const tarOutsideSecret = "outside-the-tree-3f9a"
// tarInsideA is as long as the outside file: a writer who knows a file's size
// can make the one it swaps out the same size, so the tar's header fits.
const tarInsideA = "inside-the-tree-a-000"
// tarEntries reads back what makeTar produced: name -> content.
func tarEntries(t *testing.T, b []byte) map[string]string {
t.Helper()
out := map[string]string{}
tr := tar.NewReader(bytes.NewReader(b))
for {
hdr, err := tr.Next()
if err == io.EOF {
return out
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
data, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read %s: %v", hdr.Name, err)
}
out[hdr.Name] = string(data)
}
}
// outsideAndTree makes a file outside the tree that a run must never return,
// and a tree with one file and one folder.
func outsideAndTree(t *testing.T) (outsideDir, secret, tree string) {
t.Helper()
outsideDir = t.TempDir()
secret = filepath.Join(outsideDir, "secret")
if err := os.WriteFile(secret, []byte(tarOutsideSecret), 0o600); err != nil {
t.Fatal(err)
}
tree = t.TempDir()
if err := os.WriteFile(filepath.Join(tree, "a.txt"), []byte(tarInsideA), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tree, "sub"), 0o755); err != nil {
t.Fatal(err)
}
// Named as the outside file is: a writer who knows the names in a folder
// outside can fill a folder of the tree with the same names, then swap it.
if err := os.WriteFile(filepath.Join(tree, "sub", "secret"), []byte("inside b"), 0o644); err != nil {
t.Fatal(err)
}
return outsideDir, secret, tree
}
func assertNoOutside(t *testing.T, ents map[string]string) {
t.Helper()
for name, data := range ents {
if strings.Contains(data, tarOutsideSecret) {
t.Fatalf("the tar holds a file from outside the tree, as %q", name)
}
}
}
// The tree's writer swaps a file for a link, and a folder for a link, after
// the walk has seen each one as a plain file or folder and before it opens it.
// The hook stands exactly in that gap, so the swap lands there every time.
func TestMakeTarNeverReadsThroughALinkSwappedInBeforeTheOpen(t *testing.T) {
for _, swap := range []string{"a.txt", "sub"} {
t.Run(swap, func(t *testing.T) {
outsideDir, secret, tree := outsideAndTree(t)
packTreeBeforeOpen = func(rel string) {
if rel != swap {
return
}
p := filepath.Join(tree, swap)
_ = os.RemoveAll(p)
if swap == "sub" {
_ = os.Symlink(outsideDir, p)
} else {
_ = os.Symlink(secret, p)
}
}
defer func() { packTreeBeforeOpen = nil }()
b, err := makeTar(tree)
if err != nil {
t.Fatalf("makeTar: %v", err)
}
ents := tarEntries(t, b)
assertNoOutside(t, ents)
if swap == "a.txt" {
if _, ok := ents["a.txt"]; ok {
t.Fatalf("a.txt was a link when it was opened and must be left out, got it in the tar")
}
}
})
}
}
// No hook: a writer flips one name between a plain file and a link to a file
// outside the tree as fast as it can while the tree is packed again and again.
func TestMakeTarNeverReadsThroughALinkUnderAConcurrentWriter(t *testing.T) {
_, secret, tree := outsideAndTree(t)
name := filepath.Join(tree, "a.txt")
tmpLink, tmpFile := filepath.Join(tree, ".l"), filepath.Join(tree, ".f")
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
}
_ = os.WriteFile(tmpFile, []byte(tarInsideA), 0o644)
_ = os.Rename(tmpFile, name)
_ = os.Symlink(secret, tmpLink)
_ = os.Rename(tmpLink, name)
}
}()
defer func() { close(stop); <-done }()
deadline := time.Now().Add(3 * time.Second)
passes := 0
defer func() { t.Logf("%d passes", passes) }()
for i := 0; time.Now().Before(deadline); i++ {
passes = i + 1
b, err := makeTar(tree)
if err != nil {
continue // a name that vanished mid-walk; the next pass tries again
}
for n, data := range tarEntries(t, b) {
if strings.Contains(data, tarOutsideSecret) {
t.Fatalf("pass %d: the tar holds the file outside the tree, as %q", i, n)
}
}
}
}
// A link, a named pipe and a socket in the tree are left out and named as
// left out; the pipe is never opened, so the walk does not wait on it.
func TestMakeTarLeavesOutAndNamesWhatIsNotAPlainFileOrFolder(t *testing.T) {
_, secret, tree := outsideAndTree(t)
if err := os.Symlink(secret, filepath.Join(tree, "link")); err != nil {
t.Fatal(err)
}
if err := unix.Mkfifo(filepath.Join(tree, "sub", "pipe"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tree, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tree, ".git", "config"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
type res struct {
b []byte
skipped []string
err error
}
ch := make(chan res, 1)
go func() { b, s, err := packWorktree(tree); ch <- res{b, s, err} }()
var r res
select {
case r = <-ch:
case <-time.After(10 * time.Second):
t.Fatal("packing the tree waited on the named pipe")
}
if r.err != nil {
t.Fatalf("packWorktree: %v", r.err)
}
ents := tarEntries(t, r.b)
assertNoOutside(t, ents)
for _, want := range []string{"a.txt", "sub", "sub/secret"} {
if _, ok := ents[want]; !ok {
t.Errorf("%s missing from the tar (got %v)", want, ents)
}
}
if ents["a.txt"] != tarInsideA || ents["sub/secret"] != "inside b" {
t.Errorf("file contents changed in the tar: %v", ents)
}
for _, gone := range []string{"link", "sub/pipe", ".git", ".git/config"} {
if _, ok := ents[gone]; ok {
t.Errorf("%s is in the tar and must not be", gone)
}
}
got := strings.Join(r.skipped, ",")
if got != "link,sub/pipe" {
t.Errorf("left out = %q, want link,sub/pipe", got)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
)
// packTree hands the tree under root to sink (packWorktree says why it is
// walked this way). Every name is opened through an os.Root at the tree, so
// no link, junction or name swapped in while it is packed can open a file
// outside the tree; an entry goes in only if the opened file is the plain
// file or folder that was looked at. Each left-out entry goes to sink.skip.
func packTree(sink treeSink, root string) error {
r, err := os.OpenRoot(root)
if err != nil {
return err
}
defer r.Close()
return packRootFolder(sink, r, "", 0)
}
func packRootFolder(sink treeSink, r *os.Root, rel string, depth int) error {
open := rel
if open == "" {
open = "."
}
d, err := r.Open(open)
if err != nil {
return fmt.Errorf("open folder %q: %w", rel, err)
}
names, err := readTreeNames(sink, d, filepath.ToSlash(rel))
d.Close()
if err != nil {
return err
}
if err := sink.folder(filepath.ToSlash(rel), names); err != nil {
return err
}
for _, name := range names {
p := filepath.Join(rel, name)
tp := filepath.ToSlash(p)
seen, err := r.Lstat(p)
if err != nil {
sink.skip(tp)
continue
}
if seen.IsDir() && name == ".git" {
continue
}
if (!seen.Mode().IsRegular() && !seen.IsDir()) || (seen.IsDir() && depth >= maxTreeDepth) {
sink.skip(tp)
continue
}
if packTreeBeforeOpen != nil {
packTreeBeforeOpen(tp)
}
f, err := r.Open(p)
if err != nil {
sink.skip(tp)
continue
}
fi, err := f.Stat()
if err != nil || fi.Mode().Type() != seen.Mode().Type() {
f.Close()
sink.skip(tp)
continue
}
err = sink.put(f, fi, tp)
f.Close()
if err != nil {
return err
}
if fi.IsDir() {
if err := packRootFolder(sink, r, p, depth+1); err != nil {
return err
}
}
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package main
// A harness run's tree is bind-mounted into the container at /work and opened
// to every user, because the agent runs as a uid of its own. What keeps the
// other users of this machine out of it is the folder it sits in, which only
// this process can enter. Opening the tree follows no link in it.
import (
"archive/tar"
"bytes"
"encoding/base64"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// harnessStandIn writes a stand-in container runtime named name into a new
// folder. On `ps` and `pull` it succeeds; on `run` it records, into the
// returned file, the mode and owner of the folder the /work tree sits in, then
// fails, so the run stops there.
func harnessStandIn(t *testing.T, name string) (bin, record string) {
t.Helper()
bin = t.TempDir()
record = filepath.Join(bin, "record")
script := "#!/bin/sh\ncase \"$1\" in ps|pull) exit 0;; run) ;; *) exit 1;; esac\n" +
"for a; do case \"$a\" in *:/work) d=${a%:/work};; esac; done\n" +
"[ -n \"$d\" ] && stat -c '%a %u' \"$(dirname \"$d\")\" > " + record + "\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin, record
}
func TestAWorktreeRunsTreeSitsInAFolderOnlyTheBastionCanEnter(t *testing.T) {
bin, record := harnessStandIn(t, "docker")
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
// The temporary folder every user may write in, as /tmp is.
shared := t.TempDir()
if err := os.Chmod(shared, os.ModeSticky|0o777); err != nil {
t.Fatal(err)
}
t.Setenv("TMPDIR", shared)
var tb bytes.Buffer
tw := tar.NewWriter(&tb)
_ = tw.WriteHeader(&tar.Header{Name: "src/", Typeflag: tar.TypeDir, Mode: 0o755})
_ = tw.WriteHeader(&tar.Header{Name: "src/a.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 2})
_, _ = tw.Write([]byte("a\n"))
_ = tw.Close()
spec := harnessSpec{Mode: "worktree", HarnessID: "h1", Image: "harness:test",
WorktreeB64: base64.StdEncoding.EncodeToString(tb.Bytes())}
if _, code := runWorktree(spec); code == 0 {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatalf("the tree was not mounted: %v", err)
}
if want := "700 " + strconv.Itoa(os.Geteuid()); strings.TrimSpace(string(got)) != want {
t.Fatalf("the folder the tree sits in: want %q, got %q", want, strings.TrimSpace(string(got)))
}
}
func TestOpeningARunsTreeFollowsNoLinkAndKeepsWhatIsExecutable(t *testing.T) {
bin, _ := harnessStandIn(t, "runtime")
rt := filepath.Join(bin, "runtime")
outside := filepath.Join(t.TempDir(), "shadow")
if err := os.WriteFile(outside, []byte("x\n"), 0o600); err != nil {
t.Fatal(err)
}
tree := t.TempDir()
files := map[string]os.FileMode{"run.sh": 0o755, "notes.txt": 0o644}
for name, mode := range files {
if err := os.WriteFile(filepath.Join(tree, name), nil, mode); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(tree, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(tree, "sub", "link")); err != nil {
t.Fatal(err)
}
if _, err := driveHarnessAgent(rt, tree, harnessSpec{HarnessID: "h1", Image: "harness:test"}, 1); err == nil {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
for p, want := range map[string]os.FileMode{
tree: 0o777, filepath.Join(tree, "sub"): 0o777,
filepath.Join(tree, "run.sh"): 0o777, filepath.Join(tree, "notes.txt"): 0o666,
outside: 0o600,
} {
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != want {
t.Errorf("%s: want %04o, got %04o", p, want, fi.Mode().Perm())
}
}
}
func TestARunsTreeInAFolderOtherUsersCanEnterIsRefused(t *testing.T) {
bin, record := harnessStandIn(t, "runtime")
rt := filepath.Join(bin, "runtime")
open := t.TempDir()
if err := os.Chmod(open, 0o755); err != nil {
t.Fatal(err)
}
tree := filepath.Join(open, "tree")
if err := os.Mkdir(tree, 0o755); err != nil {
t.Fatal(err)
}
_, err := driveHarnessAgent(rt, tree, harnessSpec{HarnessID: "h1", Image: "harness:test"}, 1)
if err == nil || !strings.Contains(err.Error(), "open to other users") {
t.Fatalf("want the tree refused for its folder, got %v", err)
}
if _, err := os.Stat(record); err == nil {
t.Fatal("a refused tree must start no container")
}
if fi, _ := os.Stat(tree); fi.Mode().Perm() != 0o755 {
t.Fatalf("a refused tree must not be opened: %04o", fi.Mode().Perm())
}
}
+119
View File
@@ -0,0 +1,119 @@
//go:build !windows
package main
import (
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
// A copy of a tree of very many empty files stops at its limit, and what it
// holds in memory does not grow with the number of files.
func TestACopyOfVeryManyEmptyFilesIsRefusedWithBoundedMemory(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
for i := 0; i < 50; i++ {
d := filepath.Join(src, "d"+strconv.Itoa(i))
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
for j := 0; j < 400; j++ {
if err := os.WriteFile(filepath.Join(d, "f"+strconv.Itoa(j)), nil, 0o644); err != nil {
t.Fatal(err)
}
}
}
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
_, err := mirrorTree(src, dst, 1<<20)
runtime.ReadMemStats(&after)
if !errors.Is(err, errTreeTooLarge) {
t.Fatalf("20000 empty files under a 1 MiB limit: %v", err)
}
got := after.TotalAlloc - before.TotalAlloc
t.Logf("refused: %v; %d KiB allocated", err, got>>10)
if got > 16<<20 {
t.Errorf("the copy allocated %d MiB", got>>20)
}
n := 0
_ = filepath.WalkDir(dst, func(string, os.DirEntry, error) error { n++; return nil })
if n > (1<<20)/512+1 {
t.Errorf("%d entries were written under a limit of %d", n, (1<<20)/512)
}
}
// Left-out entries are counted in full, and only the first
// maxSkippedReported names are kept.
func TestLeftOutEntriesAreCountedAndTheirNamesBounded(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
for i := 0; i < 3*maxSkippedReported; i++ {
if err := os.Symlink("x", filepath.Join(src, "l"+strconv.Itoa(i))); err != nil {
t.Fatal(err)
}
}
skips, err := mirrorTree(src, dst, maxCopiedTreeBytes)
if err != nil {
t.Fatal(err)
}
if skips.count != 3*maxSkippedReported || len(skips.names) != maxSkippedReported {
t.Errorf("count %d, %d names kept", skips.count, len(skips.names))
}
res := map[string]interface{}{}
skips.report(res)
if res["worktree_skipped_count"] != 3*maxSkippedReported {
t.Errorf("reported %v", res)
}
}
func TestRepoGitAuthPutsTheTokenOnlyInTheEnvironment(t *testing.T) {
bare, env, err := repoGitAuth("https://host-user:tok3n0f7hisrun5a1e@git.example.invalid/o/r.git")
if err != nil || bare != "https://git.example.invalid/o/r.git" {
t.Fatalf("bare = %q, %v", bare, err)
}
joined := strings.Join(env, "\n")
if !strings.Contains(joined, "GIT_CONFIG_KEY_0=http.https://git.example.invalid/o/r.git.extraHeader") ||
!strings.Contains(joined, "GIT_CONFIG_VALUE_0=Authorization: Basic ") {
t.Errorf("env = %q", env)
}
if _, env, _ := repoGitAuth("/srv/x/origin.git"); env != nil {
t.Errorf("a path got credentials: %v", env)
}
}
// One folder holding very many names is refused once the names read pass
// the limit, without the whole folder being read first.
func TestAFolderOfVeryManyNamesIsRefusedWithoutReadingItWhole(t *testing.T) {
src := t.TempDir()
long := strings.Repeat("n", 190)
for i := 0; i < 30000; i++ {
if err := os.WriteFile(filepath.Join(src, long+strconv.Itoa(i)), nil, 0o644); err != nil {
t.Fatal(err)
}
}
saved := maxReturnedTreeBytes
maxReturnedTreeBytes = 1 << 20
t.Cleanup(func() { maxReturnedTreeBytes = saved })
for name, walk := range map[string]func() error{
"copy": func() error { _, err := mirrorTree(src, t.TempDir(), 1<<20); return err },
"tar": func() error { _, _, err := packWorktree(src); return err },
} {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
err := walk()
runtime.ReadMemStats(&after)
got := after.TotalAlloc - before.TotalAlloc
t.Logf("%s: %v; %d KiB allocated", name, err, got>>10)
if !errors.Is(err, errTreeTooLarge) {
t.Errorf("%s: a folder of 30000 names under a 1 MiB limit: %v", name, err)
}
if got > 4<<20 {
t.Errorf("%s: %d KiB allocated; the 30000 names alone are about 6 MiB", name, got>>10)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build !windows
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// A file far larger than the tree a run may return (sparse, so it costs
// nothing to make) is refused from its size: packing returns at once, with
// an error and no tar, having read none of it.
func TestPackingRefusesAFileLargerThanTheLaneCarriesWithoutReadingIt(t *testing.T) {
tree := t.TempDir()
if err := os.WriteFile(filepath.Join(tree, "a.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(tree, "huge.bin"))
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(64 << 30); err != nil {
t.Skipf("no sparse file here: %v", err)
}
f.Close()
type res struct {
b []byte
err error
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
ch := make(chan res, 1)
start := time.Now()
go func() { b, _, err := packWorktree(tree); ch <- res{b, err} }()
var r res
select {
case r = <-ch:
case <-time.After(20 * time.Second):
t.Fatal("packing a tree with a 64 GiB file was still going after 20 s")
}
runtime.ReadMemStats(&after)
t.Logf("returned in %v, %d MiB allocated", time.Since(start), (after.TotalAlloc-before.TotalAlloc)>>20)
if r.err == nil || !strings.Contains(r.err.Error(), "huge.bin") || !strings.Contains(r.err.Error(), "too large") {
t.Fatalf("want the tree refused for huge.bin, got %v", r.err)
}
if r.b != nil {
t.Fatal("a tar came back with the refusal")
}
if got := after.TotalAlloc - before.TotalAlloc; got > 64<<20 {
t.Fatalf("packing allocated %d MiB", got>>20)
}
}
// A result larger than one message on the lane is replaced by a refusal that
// says so, never sent to be dropped by the host; one that fits goes as it is.
func TestAResultTheLaneCannotCarryIsRefusedNotSent(t *testing.T) {
small := []byte(`{"ok":true,"mode":"worktree"}`)
if b, code := fitLane(small, 0); string(b) != string(small) || code != 0 {
t.Fatalf("a result that fits was changed: %s %d", b, code)
}
big := append([]byte(`{"ok":true,"mode":"worktree","worktree_b64":"`), make([]byte, uvicornWSMaxSize)...)
b, code := fitLane(big, 0)
if code == 0 || len(b) > 1024 || !strings.Contains(string(b), `"result_too_large":true`) ||
!strings.Contains(string(b), `"ok":false`) {
t.Fatalf("an oversized result was not refused: %d bytes, code %d: %.200s", len(b), code, b)
}
}
+9 -6
View File
@@ -484,7 +484,7 @@ func TestRepeatedRefusedWritesQuarantineThePairing(t *testing.T) {
for i := 0; i < quarantineStrikes; i++ {
driveJob(t, tAnchor, putJob(""), ctx, "")
}
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || u.Sub(time.Now()) > quarantineBase+time.Minute {
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || time.Until(u) > quarantineBase+time.Minute {
t.Fatalf("a cleared hold is forgiven — the next one is the base length again: %v", u)
}
// Strikes double when the hold lapses on its own.
@@ -494,7 +494,7 @@ func TestRepeatedRefusedWritesQuarantineThePairing(t *testing.T) {
for i := 0; i < quarantineStrikes; i++ {
driveJob(t, tAnchor, putJob(""), ctx, "")
}
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || u.Sub(time.Now()) < 2*quarantineBase-time.Minute {
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || time.Until(u) < 2*quarantineBase-time.Minute {
t.Fatalf("a repeat hold doubles: %v", u)
}
}
@@ -1010,12 +1010,15 @@ func TestAnchoredEgressRefusesLoopbackAndPrivateDestinations(t *testing.T) {
if addr, why := egressVettedAddr(ctx, "localhost:80"); why == "" || addr != "" {
t.Fatalf("a refused name yields no address: %q %q", addr, why)
}
// A local box refuses this machine too, and keeps its LAN
// (egress_local_test.go has the rest).
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if why := egressInternalRefusal(ctx, "127.0.0.1:11434"); why != "" {
t.Fatalf("a local box keeps its lane: %s", why)
stubOwnAddrs(t, loopbackOnly, localRange())
if why := egressInternalRefusal(ctx, "127.0.0.1:11434"); why == "" {
t.Fatal("a local box must refuse its own ollama on the egress lane")
}
if addr, _ := egressVettedAddr(ctx, "example.com:443"); addr != "example.com:443" {
t.Fatalf("a local box dials the name it was given: %q", addr)
if addr, why := egressVettedAddr(ctx, "192.168.1.1:80"); why != "" || addr != "192.168.1.1:80" {
t.Fatalf("a local box keeps its LAN: %q %q", addr, why)
}
}
+76
View File
@@ -0,0 +1,76 @@
//go:build !windows
package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"testing"
)
// installShFunc returns one shell function's text from deploy/install.sh.
func installShFunc(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}\n`).Find(raw)
if m == nil {
t.Fatalf("install.sh defines no %s()", name)
}
return string(m)
}
// TestInstallShKeepsAPairedLocalMachineLocal: a root reinstall defaults to
// networked, except on a machine already paired in local mode, which keeps
// its mode (paired_local). Switched, its pairing would sit on an unanchored
// networked machine, which refuses every op until claim-anchor.
func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
fn := installShFunc(t, "paired_local")
run := func(dirs ...string) bool {
t.Helper()
args := append([]string{"-c", fn + `paired_local "$@"`, "sh"}, dirs...)
err := exec.Command("sh", args...).Run()
if _, ok := err.(*exec.ExitError); err != nil && !ok {
t.Fatal(err)
}
return err == nil
}
mk := func(t *testing.T, files map[string]string) string {
t.Helper()
d := t.TempDir()
for name, body := range files {
p := filepath.Join(d, name)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return d
}
missing := filepath.Join(t.TempDir(), "none")
if !run(mk(t, map[string]string{"pairings/a.host.example.json": "{}"}), missing) {
t.Fatal("a paired machine with no mode file is local and must stay local")
}
if !run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": `{"mode":"local"}`})) {
t.Fatal("a paired machine in local mode must stay local")
}
if !run(mk(t, map[string]string{"config.json": "{}"})) {
t.Fatal("a pre-0.10 single pairing (config.json) is a pairing")
}
if !run(missing, mk(t, map[string]string{"pairings/a.json": "{}"})) {
t.Fatal("a pairing in the second folder counts")
}
if run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": "{\n \"mode\": \"networked\"\n}"})) {
t.Fatal("a networked machine is not kept local")
}
if run(mk(t, map[string]string{"pairings/.keep": ""}), missing) {
t.Fatal("a machine with no pairing gets the networked default")
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ var procIdentity = osProcIdentity
// walked. The tree kill used to test only the root, so stopping a finding
// took everything under it — a replaced sshd took every ssh session with it,
// accounting entries included, which is exactly what the guards on the root
// exist to refuse (review of #100). Killing a parent does not kill its
// exist to refuse. Killing a parent does not kill its
// children, so a spared subtree simply survives, re-parented.
//
// A PID IS NOT A PROCESS. Each pid's identity is read before it is stopped and
@@ -111,7 +111,7 @@ func killTree(root int, spare func(int) bool) int { return killTreeExpect(root,
// killTreeExpect is killTree for a root whose identity was read when it was
// chosen (the finding walk): if the pid no longer carries that identity, the
// number has changed hands since and nothing is signalled. That closes the gap
// between choosing the root and the first read inside the kill (review of #100).
// between choosing the root and the first read inside the kill.
func killTreeExpect(root int, spare func(int) bool, rootID string) int {
if spare(root) {
return 0
+3 -3
View File
@@ -208,7 +208,7 @@ func identityRig(t *testing.T, script map[int][]string, stopped map[int]bool) {
}
}
// A PID IS NOT A PROCESS (review of #100). 202's number changes hands between
// A PID IS NOT A PROCESS. 202's number changes hands between
// the read and the stop: the stranger is resumed and never killed. 203 dies
// while frozen and its number is reused before the kill: not killed, and not
// resumed either (the frozen process is gone; the new one was never stopped).
@@ -239,7 +239,7 @@ func TestKillTreeNeverSignalsAStrangerUnderARecycledPid(t *testing.T) {
}
// A PROCESS SOMEONE ELSE HAD STOPPED is killed with the tree but never resumed
// by us when the kill fails (review of #100, N4): its stopped state was not
// by us when the kill fails: its stopped state was not
// ours to undo.
//
// MUTATION: resume on kill failure regardless of wasStopped. RESULT: red.
@@ -311,7 +311,7 @@ func TestRecoverFrozenTreesThawsOnlyWhatItFroze(t *testing.T) {
}
}
// THE FROZEN SET IS JOURNALLED AS IT IS FROZEN (review of #100): a bastion that
// THE FROZEN SET IS JOURNALLED AS IT IS FROZEN: a bastion that
// dies during the freeze rounds still leaves a record of what it stopped.
//
// MUTATION: write the journal only after the freeze rounds. RESULT: red — the
-849
View File
@@ -1,849 +0,0 @@
package main
// lab.go — mode=lab: the Lab Harness.
//
// A lab run builds a sealed lab of N hardened hosts on THIS box's own k3s cluster (the
// containment lab), puts the application under test into every host, places ONE coding
// harness pod inside the lab and drives the test runbook through it, collects the
// transcript + per-step verdicts + the runbook worktree, and tears everything down.
//
// THREE STRUCTURAL WALLS (the product claim "spun up in the lab, nowhere else"):
// 1. This mode only answers on a LAB bastion: the lab marker, the inert egress wall file
// and the lab operator on loopback must all be present (labAvailable). A user's home
// bastion returns exit 126 in the same shape as an ungranted op.
// 2. The host-side grant carries a `mode:lab` token, so a grant for a home
// box never covers a lab run and a lab grant never covers a home box.
// 3. The lab cluster has no path out (cloud firewall + labguard per run + tripwire).
//
// What runs where: the lab HOSTS do not think — they hold and run the app under test. The
// coding harness pod thinks, with the per-run credential the host delivered, and its
// tools reach the lab hosts in-cluster. Nothing here talks to the platform.
//
// Gate: only gate="auto" is supported today (typed deliberately, exactly like the old bench's
// --auto). gate="ask" is the App lane (per-call y/n from outside the lab) and is not built
// yet; until then it is refused, never silently downgraded to auto.
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
labMarkerPath = "/etc/privatae-lab/marker"
labGuardPath = "/etc/nftables-labguard.nft"
labOperatorURL = "http://127.0.0.1:8011/healthz"
labStorageRoot = "/var/lib/rancher/k3s/storage"
labWorkspace = "/var/lib/skema/projects" // the harness image's workspace root (same image, same root)
labHarnessPort = 8844
labHostIDFloor = 9001 // lab host entity ids start here; 900 is the bench's own test entity
labHostIDCeil = 9899
labTemplateEntity = 900 // the lab's bench entity; its registry row is the template for lab hosts
labDefaultHosts = 1
labMaxHostsCap = 8 // per-box cap — protects the LAB BOX, never a user's workflow; marker max_hosts= raises it
)
// labMarker holds the key=value lines of /etc/privatae-lab/marker. Written by the lab
// rebuild, never by this binary.
type labMarker struct {
present bool
hostImage string // host_image= — the host image lab hosts run (must already be on the node)
maxHosts int // max_hosts= — optional
}
func readLabMarker() labMarker {
m := labMarker{}
b, err := os.ReadFile(labMarkerPath)
if err != nil {
return m
}
m.present = true
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "host_image":
m.hostImage = strings.TrimSpace(v)
case "max_hosts":
m.maxHosts, _ = strconv.Atoi(strings.TrimSpace(v))
}
}
return m
}
// labAvailable is wall #1. Every check is a thing only the lab box has.
func labAvailable() (labMarker, error) {
m := readLabMarker()
if !m.present {
return m, fmt.Errorf("no lab marker")
}
if _, err := os.Stat(labGuardPath); err != nil {
return m, fmt.Errorf("no egress wall file")
}
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get(labOperatorURL)
if err != nil {
return m, fmt.Errorf("lab operator not on loopback")
}
resp.Body.Close()
if resp.StatusCode != 200 {
return m, fmt.Errorf("lab operator unhealthy (%d)", resp.StatusCode)
}
if _, err := runCmd(10*time.Second, "k3s", "kubectl", "version", "--client=true"); err != nil {
return m, fmt.Errorf("no cluster tooling")
}
if m.hostImage == "" {
return m, fmt.Errorf("marker has no host_image=")
}
return m, nil
}
// kc runs `k3s kubectl …` with a timeout.
func kc(timeout time.Duration, args ...string) (string, error) {
return runCmd(timeout, "k3s", append([]string{"kubectl"}, args...)...)
}
// kcApply applies a manifest from a temp file (runCmd has no stdin).
func kcApply(manifest string) (string, error) {
f, err := os.CreateTemp("", "lab-manifest-*.yaml")
if err != nil {
return "", err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(manifest); err != nil {
f.Close()
return "", err
}
f.Close()
return kc(60*time.Second, "apply", "-f", f.Name())
}
func labFail(res map[string]interface{}, code int, msg string) int {
res["ok"], res["error"] = false, msg
return code
}
// runLab is the mode=lab entry point (harness.go runHarness). The result is marshalled
// AFTER the inner run's deferred teardown has recorded its log, so the caller always sees
// what happened including the cleanup.
func runLab(spec harnessSpec) ([]byte, int) {
res := map[string]interface{}{"mode": "lab", "ran_on": orUnknown(hostnameSafe())}
code := runLabInner(spec, res)
b, _ := json.Marshal(res)
return b, code
}
func runLabInner(spec harnessSpec, res map[string]interface{}) int {
marker, err := labAvailable()
if err != nil {
// Same shape + exit as an ungranted op on the host side (exit 126).
return labFail(res, 126, "mode 'lab' not available on this server — "+err.Error())
}
// ── validate the spec ─────────────────────────────────────────────────────
if spec.Gate != "auto" {
return labFail(res, 1, "gate='ask' (per-call y/n from the App) is not available yet — pass gate='auto', typed deliberately")
}
if spec.Image == "" {
return labFail(res, 1, "image required for mode=lab (the coding harness that drives the tests)")
}
if len(spec.Tests) == 0 {
return labFail(res, 1, "tests required for mode=lab (the runbook: ordered steps)")
}
hosts := spec.Hosts
if hosts <= 0 {
hosts = labDefaultHosts
}
maxHosts := labMaxHostsCap
if marker.maxHosts > 0 {
maxHosts = marker.maxHosts
}
if hosts > maxHosts {
return labFail(res, 1, fmt.Sprintf("hosts=%d exceeds this lab box's max_hosts=%d", hosts, maxHosts))
}
app := spec.App
if app == "" {
app = "app"
}
if !validWSName(app) {
return labFail(res, 1, "app must be a plain directory name")
}
engine := spec.Engine
if engine == "" {
engine = spec.HarnessID
}
maxMin := spec.MaxMinutes
if maxMin <= 0 {
maxMin = 10
}
var rb [4]byte
_, _ = rand.Read(rb[:])
runID := hex.EncodeToString(rb[:])
res["run_id"] = runID
start := time.Now()
var log []string
note := func(f string, a ...interface{}) {
log = append(log, time.Now().UTC().Format("15:04:05 ")+fmt.Sprintf(f, a...))
}
// ── 0. one run at a time, and reap whatever a previous run left ──────────
// The teardown below is a defer, so it covers every ordinary exit — but NOT a
// SIGKILL or a power loss (measured: a killed self-test left its two
// entity_registry rows behind). "The lab keeps nothing" has to hold after a crash
// too, so a run starts by removing anything in the lab's own id range / name
// prefixes. The lock is what makes that safe: without it a second concurrent run
// would reap the first one's hosts.
unlock, err := labLock()
if err != nil {
return labFail(res, 1, err.Error())
}
defer unlock()
if reaped := labReapOrphans(); reaped != "" {
note("reaped a previous run's leftovers: %s", reaped)
res["reaped"] = reaped
}
// ── 1. lab hosts: N hardened Entity CRs on the lab cluster ────────────────
ids, err := labAllocateIDs(hosts)
if err != nil {
return labFail(res, 1, "allocate host ids: "+err.Error())
}
res["hosts"] = ids
// ALWAYS tear down, whatever happens below — the lab keeps nothing between runs.
defer func() {
note("teardown: %d hosts + namespace lab-%s", len(ids), runID)
labTeardown(runID, ids)
res["log"] = strings.Join(log, "\n")
}()
for i, id := range ids {
name := fmt.Sprintf("lab-%s-%d", runID, i+1)
if err := labRegistryRow(id, name); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d registry row: %v", id, err))
}
manifest := fmt.Sprintf(`apiVersion: privatae.ai/v1
kind: Entity
metadata:
name: entity-%d
namespace: default
labels:
privatae.ai/lab-run: "%s"
spec:
entityId: %d
lifecycleTier: always_on
image: %s
hardened: true
`, id, runID, id, marker.hostImage)
if out, err := kcApply(manifest); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d apply: %s", id, truncate(out, 200)))
}
note("host %d (%s) applied", id, name)
}
for _, id := range ids {
// The operator builds the namespace + StatefulSet asynchronously after the CR lands;
// `kubectl wait` on a pod that does not exist yet fails at once, so poll for the pod
// first, then wait for Ready.
ns, pod := fmt.Sprintf("entity-%d", id), fmt.Sprintf("pod/entity-%d-0", id)
appeared := false
for i := 0; i < 60; i++ {
if _, err := kc(15*time.Second, "-n", ns, "get", pod); err == nil {
appeared = true
break
}
time.Sleep(3 * time.Second)
}
if !appeared {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, fmt.Sprintf("host %d: the operator never created its pod (3 min): %s", id, truncate(ev, 300)))
}
if out, err := kc(300*time.Second, "-n", ns, "wait", "--for=condition=Ready", pod, "--timeout=270s"); err != nil {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, fmt.Sprintf("host %d never became Ready: %s | %s", id, truncate(out, 160), truncate(ev, 300)))
}
}
hostIPs := map[int]string{}
for _, id := range ids {
ip, _ := kc(20*time.Second, "-n", fmt.Sprintf("entity-%d", id), "get", "pod",
fmt.Sprintf("entity-%d-0", id), "-o", "jsonpath={.status.podIP}")
hostIPs[id] = strings.TrimSpace(ip)
}
res["host_ips"] = hostIPs
note("%d hosts Ready", len(ids))
// ── 2. the app under test + its settings into every host ─────────────────
if spec.WorktreeB64 != "" {
tarIn, err := base64.StdEncoding.DecodeString(spec.WorktreeB64)
if err != nil {
return labFail(res, 1, "bad worktree_b64: "+err.Error())
}
for _, id := range ids {
dir, err := labHostProjectDir(id, app)
if err != nil {
return labFail(res, 1, fmt.Sprintf("host %d projects dir: %v", id, err))
}
if err := extractTar(tarIn, dir); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d app extract: %v", id, err))
}
if err := labWriteEnv(dir, spec.Envs); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d envs: %v", id, err))
}
_ = filepath.Walk(dir, func(p string, _ os.FileInfo, werr error) error {
if werr == nil {
_ = os.Chown(p, 1000, 1000) // the entity uid — the host owns its own project
}
return nil
})
}
note("app %q + %d settings placed in every host", app, len(spec.Envs))
}
// ── 3. the ONE harness pod inside the lab (same image + env as the pool) ──
ns := "lab-" + runID
if out, err := kc(30*time.Second, "create", "namespace", ns); err != nil {
return labFail(res, 1, "namespace: "+truncate(out, 200))
}
if spec.AgentCreds != "" {
f, err := os.CreateTemp("", "lab-creds-*")
if err != nil {
return labFail(res, 1, "creds temp: "+err.Error())
}
_ = os.WriteFile(f.Name(), []byte(spec.AgentCreds), 0o600)
f.Close()
out, err := kc(30*time.Second, "-n", ns, "create", "secret", "generic", "harness-creds",
"--from-file=.credentials.json="+f.Name())
_ = os.Remove(f.Name()) // per-run: nothing durable on the box
if err != nil {
return labFail(res, 1, "creds secret: "+truncate(out, 200))
}
}
if out, err := kcApply(labHarnessPodManifest(ns, spec.Image, engine, spec.AgentCreds != "")); err != nil {
return labFail(res, 1, "harness pod: "+truncate(out, 200))
}
if out, err := kc(240*time.Second, "-n", ns, "wait", "--for=condition=Ready", "pod/harness", "--timeout=210s"); err != nil {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, "harness pod never Ready: "+truncate(out, 160)+" | "+truncate(ev, 400))
}
hip, _ := kc(20*time.Second, "-n", ns, "get", "pod", "harness", "-o", "jsonpath={.status.podIP}")
hip = strings.TrimSpace(hip)
if hip == "" {
return labFail(res, 1, "harness pod has no IP")
}
base := fmt.Sprintf("http://[%s]:%d", hip, labHarnessPort)
if !strings.Contains(hip, ":") {
base = fmt.Sprintf("http://%s:%d", hip, labHarnessPort)
}
if ok, _ := waitReachable(base+"/", 60*time.Second); !ok {
return labFail(res, 1, "harness sidecar not reachable on :"+strconv.Itoa(labHarnessPort))
}
note("harness pod %s Ready at %s", engine, hip)
// ── 4. the runbook: one governed /run per step ────────────────────────────
// The workspace the harness works in carries the runbook + a hosts file, so the
// agent knows what it is testing and where each lab host answers.
wsName := "lab-run"
wsTar, err := labRunbookTar(app, spec.Tests, ids, hostIPs, spec.Envs)
if err != nil {
return labFail(res, 1, "runbook tar: "+err.Error())
}
if code, body := labPost(base+"/workspace/load?dir="+wsName, "application/x-tar", wsTar, 60*time.Second); code != 200 {
return labFail(res, 1, fmt.Sprintf("workspace/load %d: %s", code, truncate(body, 200)))
}
projectDir := labWorkspace + "/" + wsName
client := &http.Client{Timeout: time.Duration(maxMin+2) * time.Minute}
steps := make([]map[string]interface{}, 0, len(spec.Tests))
allOK := true
for i, step := range spec.Tests {
prompt := labStepPrompt(i+1, len(spec.Tests), step, app, ids, hostIPs)
reqBody, _ := json.Marshal(map[string]interface{}{
"prompt": prompt, "project_dir": projectDir, "max_minutes": maxMin,
})
t0 := time.Now()
resp, err := client.Post(base+"/run", "application/json", bytes.NewReader(reqBody))
st := map[string]interface{}{"step": i + 1, "test": step}
if err != nil {
st["ok"], st["error"] = false, "harness /run: "+err.Error()
steps = append(steps, st)
allOK = false
note("step %d: /run failed: %v", i+1, err)
break
}
rbody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var r map[string]interface{}
_ = json.Unmarshal(rbody, &r)
okv, _ := r["ok"].(bool)
st["ok"] = okv
st["exit_code"] = r["exit_code"]
st["elapsed_s"] = round2(time.Since(t0).Seconds())
if tr, _ := r["transcript"].(string); tr != "" {
st["transcript"] = tr
}
if e, _ := r["error"].(string); e != "" {
st["error"] = e
}
steps = append(steps, st)
if !okv {
allOK = false
}
note("step %d: ok=%v (%.0fs)", i+1, okv, time.Since(t0).Seconds())
}
res["steps"] = steps
// ── 5. collect: the runbook worktree after (the agent's notes/results live there) ──
if code, body := labGet(base+"/workspace/export?dir="+wsName, 60*time.Second); code == 200 {
res["worktree_b64"] = base64.StdEncoding.EncodeToString([]byte(body))
}
res["meter_s"] = round2(time.Since(start).Seconds())
res["ok"] = allOK
if allOK {
return 0
}
return 1
}
// ── helpers ──────────────────────────────────────────────────────────────────
func validWSName(s string) bool {
if s == "" || len(s) > 64 || strings.ContainsAny(s, "/\\ ") || strings.HasPrefix(s, ".") {
return false
}
return true
}
// labDeleteRows removes the lab's OWN entity_registry rows and reports how many.
// Three independent scopes, all required, so this can never reach a real entity:
// the `lab-` display-name prefix, the lab id range, and (for a run's own teardown)
// that run's id list. runID=="" + ids==nil is the orphan reap: every lab row in the range.
// Returns "" when it removed nothing.
func labDeleteRows(runID string, ids []int) string {
where := fmt.Sprintf("display_name LIKE 'lab-%s%%' AND id BETWEEN %d AND %d",
runID+map[bool]string{true: "-", false: ""}[runID != ""], labHostIDFloor, labHostIDCeil)
if len(ids) > 0 {
var in []string
for _, id := range ids {
in = append(in, strconv.Itoa(id)) // ints: nothing quotable reaches the SQL
}
where += " AND id IN (" + strings.Join(in, ",") + ")"
} else if runID != "" {
return "" // a run's teardown with no ids has nothing of its own to remove
}
sql := "DELETE FROM entity_registry WHERE " + where + ";"
f, err := os.CreateTemp("", "lab-del-*.sql")
if err != nil {
return ""
}
defer os.Remove(f.Name())
_ = os.Chmod(f.Name(), 0o644) // the postgres user reads it
_, _ = f.WriteString(sql)
f.Close()
out, err := runCmd(20*time.Second, "sudo", "-u", "postgres", "psql", "-d", "parallax", "-q",
"-v", "ON_ERROR_STOP=1", "-c", "\\set QUIET on", "-f", f.Name())
if err != nil {
return ""
}
out = strings.TrimSpace(out)
if out == "" || strings.HasSuffix(out, "DELETE 0") {
return ""
}
return out
}
// labLock serialises lab runs on this box. Returns the release func. An ordinary exit
// releases it; a killed run leaves a stale lock, so a lock whose pid is gone is taken over
// (and the takeover is what makes the reap below safe rather than racy).
func labLock() (func(), error) {
const path = "/var/tmp/lab-run.lock"
take := func() error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
fmt.Fprintf(f, "%d\n", os.Getpid())
return f.Close()
}
if err := take(); err != nil {
if b, rerr := os.ReadFile(path); rerr == nil {
if pid, perr := strconv.Atoi(strings.TrimSpace(string(b))); perr == nil {
if p, ferr := os.FindProcess(pid); ferr != nil || p.Signal(syscall.Signal(0)) != nil {
_ = os.Remove(path) // stale: the holder is gone
if err2 := take(); err2 == nil {
return func() { _ = os.Remove(path) }, nil
}
} else {
return nil, fmt.Errorf("another lab run is in flight on this box (pid %d) — labs are one at a time", pid)
}
}
}
return nil, fmt.Errorf("could not take the lab run lock: %v", err)
}
return func() { _ = os.Remove(path) }, nil
}
// labReapOrphans removes anything a PREVIOUS lab run left behind. Scoped three ways so it
// can only ever touch lab leavings: the `lab-` namespace prefix, the lab entity id range
// (the bench's own entity 900 is deliberately below it), and the `lab-` display-name prefix.
// Returns a short description of what it removed, or "".
func labReapOrphans() string {
var did []string
if out, err := kc(30*time.Second, "get", "ns", "-o", "name"); err == nil {
for _, l := range strings.Split(out, "\n") {
ns := strings.TrimPrefix(strings.TrimSpace(l), "namespace/")
if strings.HasPrefix(ns, "lab-") {
_, _ = kc(60*time.Second, "delete", "namespace", ns, "--ignore-not-found", "--wait=false")
did = append(did, "ns "+ns)
}
}
}
var ids []int
if out, err := kc(30*time.Second, "get", "entity", "-A",
"-o", "jsonpath={range .items[*]}{.spec.entityId}{\"\\n\"}{end}"); err == nil {
for _, l := range strings.Split(out, "\n") {
if v, e := strconv.Atoi(strings.TrimSpace(l)); e == nil && v >= labHostIDFloor && v <= labHostIDCeil {
ids = append(ids, v)
}
}
}
if len(ids) > 0 {
did = append(did, fmt.Sprintf("%d host(s) %v", len(ids), ids))
}
// Volumes + registry rows are cleaned for the id range even when no CR remains
// (a crash can leave either half).
labTeardown("", ids)
orphans, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-9[0-9][0-9][0-9]_*", labStorageRoot))
for _, p := range orphans {
_ = os.RemoveAll(p)
did = append(did, "volume "+filepath.Base(p))
}
if n := labDeleteRows("", nil); n != "" {
did = append(did, "registry rows "+n)
}
if len(did) == 0 {
return ""
}
return strings.Join(did, ", ")
}
// labAllocateIDs picks N free entity ids in the lab range by reading the Entity CRs that exist.
func labAllocateIDs(n int) ([]int, error) {
out, err := kc(30*time.Second, "get", "entity", "-A", "-o", "jsonpath={range .items[*]}{.spec.entityId}{\"\\n\"}{end}")
if err != nil {
return nil, fmt.Errorf("list entities: %s", truncate(out, 160))
}
used := map[int]bool{}
for _, l := range strings.Split(out, "\n") {
if v, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[v] = true
}
}
ids := []int{}
for id := labHostIDFloor; id <= labHostIDCeil && len(ids) < n; id++ {
if !used[id] {
ids = append(ids, id)
}
}
if len(ids) < n {
return nil, fmt.Errorf("only %d free lab host ids", len(ids))
}
return ids, nil
}
// labRegistryRow gives the lab operator the registry row it resolves names from
// (the operator reads display_name off entity_registry). Lab-local postgres. entity_type is
// CHECK-constrained to parallax_native|operator, so lab rows are marked by their display_name
// prefix "lab-<run>-" (and confined to the lab id range) — teardown deletes exactly those.
func labRegistryRow(id int, name string) error {
// Clone the lab's TEMPLATE entity row (entity 900, the bench's own test entity, restored
// with the lab DB) rather than invent values: the table carries CHECKs and FKs
// (mastercode_version → mastercode_versions) that a hand-written row keeps tripping.
sql := fmt.Sprintf(`INSERT INTO entity_registry (id, entity_type, display_name, master_directive, license_tier,
seed_weights, current_weights, envelope_bounds, mastercode_version, status)
SELECT %d, entity_type, '%s', 'lab host under test', 'standard',
seed_weights, current_weights, envelope_bounds, mastercode_version, 'active'
FROM entity_registry WHERE id = %d
ON CONFLICT (id) DO NOTHING;`, id, name, labTemplateEntity)
f, err := os.CreateTemp("", "lab-row-*.sql")
if err != nil {
return err
}
defer os.Remove(f.Name())
_ = os.Chmod(f.Name(), 0o644)
if _, err := f.WriteString(sql); err != nil {
f.Close()
return err
}
f.Close()
if out, err := runCmd(20*time.Second, "sudo", "-u", "postgres", "psql", "-d", "parallax", "-v", "ON_ERROR_STOP=1", "-q", "-f", f.Name()); err != nil {
return fmt.Errorf("%s", truncate(out, 200))
}
return nil
}
// labHostProjectDir resolves the host's entity-apps PVC on the node's local-path storage and
// returns <pvc>/projects/<app>, created. The node is root over its own storage — the lab box's
// privilege, and exactly why this mode is lab-only.
func labHostProjectDir(id int, app string) (string, error) {
matches, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-%d_entity-apps-entity-%d-0", labStorageRoot, id, id))
if len(matches) != 1 {
return "", fmt.Errorf("expected one entity-apps volume, found %d", len(matches))
}
dir := filepath.Join(matches[0], "projects", app)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// labWriteEnv writes the app settings as <dir>/.env (KEY=value, sorted, values single-quoted).
func labWriteEnv(dir string, envs map[string]string) error {
if len(envs) == 0 {
return nil
}
keys := make([]string, 0, len(envs))
for k := range envs {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if !validEnvKey(k) {
return fmt.Errorf("bad env key %q", k)
}
b.WriteString(k + "='" + strings.ReplaceAll(envs[k], "'", "'\\''") + "'\n")
}
return os.WriteFile(filepath.Join(dir, ".env"), []byte(b.String()), 0o640)
}
func validEnvKey(k string) bool {
if k == "" || len(k) > 128 {
return false
}
for i, r := range k {
ok := r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (i > 0 && r >= '0' && r <= '9')
if !ok {
return false
}
}
return true
}
// labHarnessPodManifest mirrors the pool's harness pod (same image, same env, same floor:
// non-root uid, drop ALL, no privilege escalation, RO rootfs, the personal seccomp profile)
// with emptyDirs where the harness must write.
func labHarnessPodManifest(ns, image, engine string, withCreds bool) string {
credsVol, credsMount, credsInit := "", "", ""
if withCreds {
credsVol = ` - name: creds-src
secret:
secretName: harness-creds
`
credsMount = ""
credsInit = ` initContainers:
- name: creds
image: busybox:1.36
command: ["sh", "-c", "cp /src/.credentials.json /dst/.credentials.json && chown 10005:10005 /dst/.credentials.json && chmod 0644 /dst/.credentials.json"]
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"], add: ["CHOWN", "FOWNER", "DAC_OVERRIDE"]}
volumeMounts:
- {name: creds-src, mountPath: /src, readOnly: true}
- {name: home-claude, mountPath: /dst}
`
}
return fmt.Sprintf(`apiVersion: v1
kind: Pod
metadata:
name: harness
namespace: %s
labels: {app: lab-harness, privatae.ai/lab-run: "%s"}
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
seccompProfile: {type: Localhost, localhostProfile: seccomp-personal.json}
%s containers:
- name: harness
image: %s
imagePullPolicy: IfNotPresent
env:
- {name: PORT, value: "%d"}
- {name: PRIVATAE_SIDECAR_BIND, value: "::"}
- {name: PRIVATAE_APP_ID, value: "%s"}
- {name: PRIVATAE_WORKSPACE, value: "%s"}
# Both spellings, on purpose: the harness image is published on its own
# schedule, so a lab run must work against one that reads either name.
# The older three come out once no published image reads them.
- {name: SKEMA_SIDECAR_BIND, value: "::"}
- {name: SKEMA_APP_ID, value: "%s"}
- {name: SKEMA_WORKSPACE, value: "%s"}
- {name: HOME, value: "/home/privatae"}
securityContext:
runAsUser: 10005
runAsGroup: 10005
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {memory: 2Gi}
volumeMounts:
- {name: workspace, mountPath: %s}
- {name: home-claude, mountPath: /home/privatae/.claude}
- {name: tmp, mountPath: /tmp}
%s volumes:
- {name: workspace, emptyDir: {}}
- {name: home-claude, emptyDir: {}}
- {name: tmp, emptyDir: {}}
%s`, ns, strings.TrimPrefix(ns, "lab-"), credsInit, image, labHarnessPort,
engine, labWorkspace, engine, labWorkspace,
labWorkspace, credsMount, credsVol)
}
// labRunbookTar packs the runbook the harness works from: a README naming the app, the hosts
// and their addresses, the settings, and the ordered tests; plus a results/ dir for the agent.
func labRunbookTar(app string, tests []string, ids []int, ips map[int]string, envs map[string]string) ([]byte, error) {
dir, err := os.MkdirTemp("", "lab-runbook-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
var b strings.Builder
b.WriteString("# Lab run\n\nYou are the test harness inside a sealed lab. Nothing here can reach the internet or the platform.\n\n")
b.WriteString("## Application under test\n\n`" + app + "` — placed in every lab host under " + labWorkspace + "/" + app + "\n\n")
b.WriteString("## Lab hosts\n\n| host | pod address | kernel API |\n|---|---|---|\n")
for i, id := range ids {
b.WriteString(fmt.Sprintf("| lab host %d (entity-%d) | %s | http://[%s]:8800 |\n", i+1, id, ips[id], ips[id]))
}
if len(envs) > 0 {
b.WriteString("\n## Settings (also written to every host's " + app + "/.env)\n\n")
keys := make([]string, 0, len(envs))
for k := range envs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString("- " + k + "\n")
}
}
b.WriteString("\n## Tests (run in order; write each verdict to results/<n>.md)\n\n")
for i, t := range tests {
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, t))
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(b.String()), 0o644); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "results"), 0o755); err != nil {
return nil, err
}
_ = os.WriteFile(filepath.Join(dir, "results", ".keep"), nil, 0o644)
return makeTar(dir)
}
func labStepPrompt(n, total int, step, app string, ids []int, ips map[int]string) string {
var hosts []string
for i, id := range ids {
hosts = append(hosts, fmt.Sprintf("lab host %d = entity-%d at %s", i+1, id, ips[id]))
}
return fmt.Sprintf("Lab test step %d of %d for the application `%s`.\n\nTEST: %s\n\nLab hosts: %s.\n\n"+
"Read README.md first. Run the test against the lab hosts, then write a short verdict to results/%d.md "+
"(first line PASS or FAIL, then the evidence). Do not modify the application; only test it.",
n, total, app, step, strings.Join(hosts, "; "), n)
}
func labPost(url, ctype string, body []byte, timeout time.Duration) (int, string) {
c := &http.Client{Timeout: timeout}
resp, err := c.Post(url, ctype, bytes.NewReader(body))
if err != nil {
return 0, err.Error()
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(rb)
}
func labGet(url string, timeout time.Duration) (int, string) {
c := &http.Client{Timeout: timeout}
resp, err := c.Get(url)
if err != nil {
return 0, err.Error()
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(rb)
}
// labTeardown removes everything a run created: the harness namespace, the lab hosts (Entity
// CRs → the operator deletes their namespaces), their volumes on local-path storage, and the
// lab-only registry rows. Best-effort, always runs, never raises.
func labTeardown(runID string, ids []int) {
if runID != "" { // "" = the orphan reap, which deletes lab- namespaces itself
_, _ = kc(120*time.Second, "delete", "namespace", "lab-"+runID, "--ignore-not-found", "--wait=false")
}
for _, id := range ids {
_, _ = kc(60*time.Second, "-n", "default", "delete", "entity", fmt.Sprintf("entity-%d", id), "--ignore-not-found", "--wait=false")
}
// wait for the CR to go (finalizers) and the operator to drop the namespace before wiping storage
for _, id := range ids {
for i := 0; i < 40; i++ {
if _, err := kc(15*time.Second, "-n", "default", "get", "entity", fmt.Sprintf("entity-%d", id)); err != nil {
break
}
time.Sleep(3 * time.Second)
}
for i := 0; i < 40; i++ {
if _, err := kc(15*time.Second, "get", "namespace", fmt.Sprintf("entity-%d", id)); err != nil {
break
}
time.Sleep(3 * time.Second)
}
for _, m := range []string{"entity-apps", "entity-data"} {
matches, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-%d_%s-entity-%d-0", labStorageRoot, id, m, id))
for _, p := range matches {
_ = os.RemoveAll(p)
}
}
}
_ = labDeleteRows(runID, ids)
}
// cmdLabSelftest — `privatae-bastion lab-selftest <spec.json>`: run mode=lab from a spec file on
// THIS box without the host relay, print the result. Operator tooling for the lab box; on any
// other box it prints the same refusal the relay would (wall #1).
func cmdLabSelftest(path string) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var spec harnessSpec
if err := json.Unmarshal(raw, &spec); err != nil {
return fmt.Errorf("spec: %w", err)
}
spec.Mode = "lab"
if spec.HarnessID == "" {
spec.HarnessID = "lab"
}
out, code := runLab(spec)
var pretty bytes.Buffer
if json.Indent(&pretty, out, "", " ") == nil {
fmt.Println(pretty.String())
} else {
fmt.Println(string(out))
}
if code != 0 {
return fmt.Errorf("lab run exit %d", code)
}
return nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !lab
package main
// lab_off.go: mode=lab and `lab-selftest` in every release build. The lab
// harness is test tooling for one test lab and is built only with -tags lab,
// so a release bastion answers a lab run the way any bastion without a lab
// answers it: exit 126, in the same shape as an ungranted op.
import (
"encoding/json"
"errors"
"fmt"
"os"
)
const labUnavailable = "mode 'lab' not available on this server — this build has no lab harness"
func runLab(_ harnessSpec) ([]byte, int) {
b, _ := json.Marshal(map[string]any{"mode": "lab", "ran_on": orUnknown(hostnameSafe()), "ok": false, "error": labUnavailable})
return b, 126
}
// cmdLabSelftest prints the same refusal the relay would give.
func cmdLabSelftest(path string) error {
if _, err := os.Stat(path); err != nil {
return err
}
fmt.Fprintln(os.Stderr, labUnavailable)
return errors.New("lab run exit 126")
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !lab
package main
import (
"encoding/json"
"testing"
)
// A release build has no lab harness: a lab run is refused with exit 126, the
// shape of an ungranted op, and touches nothing.
func TestAReleaseBuildRefusesALabRun(t *testing.T) {
out, code := runHarness(harnessSpec{Mode: "lab", HarnessID: "lab-1", Gate: "auto", Image: "x", Tests: []string{"a"}})
if code != 126 {
t.Fatalf("exit %d, want 126", code)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil || m["ok"] != false || m["error"] != labUnavailable {
t.Fatalf("reply %s", out)
}
}
+11
View File
@@ -229,6 +229,7 @@ func main() {
// Started by the Windows Service Control Manager: the service runs `run`
// itself and never reaches the argument parsing below.
if maybeRunAsWindowsService() {
auditFlush(auditFlushOnExit)
return
}
if len(os.Args) < 2 {
@@ -565,6 +566,13 @@ func main() {
os.Exit(125)
}
os.Exit(opSandboxChildMain(os.Args[2]))
case opSandboxScopeSubcommand:
// A root op's scope (ops_taskcap_linux.go): never typed by a person;
// systemd-run starts it for the daemon.
if len(os.Args) < 3 {
os.Exit(125)
}
os.Exit(opSandboxScopeMain(os.Args[2]))
case opActionSubcommand, opActionChildSubcommand, opActionProbeSubcommand:
// An action op's unit and its sandbox's pid 1 (ops_action_linux.go):
// never typed by a person; the daemon starts them.
@@ -618,6 +626,9 @@ func main() {
usage()
os.Exit(2)
}
// The command's audit rows reach the system log before the process ends
// (auditlog.go queues them off the op's own path).
auditFlush(auditFlushOnExit)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
+8
View File
@@ -45,6 +45,9 @@ func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == opSandboxSubcommand {
os.Exit(opSandboxChildMain(os.Args[2]))
}
if len(os.Args) >= 3 && os.Args[1] == opSandboxScopeSubcommand {
os.Exit(opSandboxScopeMain(os.Args[2]))
}
if len(os.Args) >= 3 && os.Args[1] == opActionSubcommand {
os.Exit(opActionOuterMain(os.Args[2]))
}
@@ -115,8 +118,13 @@ func TestMain(m *testing.M) {
// /proc/101 on the machine running the suite and the decision would depend
// on whatever process holds that number. The real-signal test opts back in.
procIdentity = func(int) (string, bool, bool) { return "", false, false }
// THE SWEEP IS ONE TOO. A test runs the ops' startup sweep for real, and it
// stops every op unit its names match: the suite's names are its own, so
// it never stops an op the machine's own bastion is running.
opIsolateOpsUnits()
code := m.Run()
opIsolatedOpsUnitsDone()
_ = os.RemoveAll(dir)
os.Exit(code)
}
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"fmt"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
// Every value below is invented. Each case is a way a real config carries a
// credential on the command line; the record that leaves the machine must
// carry none of them, in any field.
func TestMCPRecordNeverCarriesASecretValue(t *testing.T) {
cases := []struct {
name string
e mcpEntry
secrets []string
}{
{"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}},
{"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}},
{"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}},
{"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}},
{"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}},
{"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}},
{"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}},
{"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}},
{"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}},
{"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}},
{"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}},
}
for _, c := range cases {
b, _ := json.Marshal(mcpAnalyze(c.e))
for _, s := range c.secrets {
if strings.Contains(string(b), s) {
t.Errorf("%s: the record carries %q: %s", c.name, s, b)
}
}
}
// A running server no file declares goes through the same redaction.
out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js",
"postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true)
b, _ := json.Marshal(out)
if strings.Contains(string(b), "ShadowPw77x") {
t.Errorf("an undeclared server's record carries its password: %s", b)
}
// What is still kept: a reference to where the secret comes from, and
// the URL's host.
o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}})
if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") {
t.Errorf("redaction took more than the secret: %q", o.Command)
}
}
// One record, and the list, stay bounded however large the file's names are
// and however many processes look like servers.
func TestMCPRecordAndListStayBounded(t *testing.T) {
env := map[string]string{}
for i := 0; i < 5000; i++ {
env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = ""
}
o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx",
args: []string{"-y", strings.Repeat("p", 100000)}, env: env})
if b, _ := json.Marshal(o); len(b) > 8192 {
t.Errorf("one stdio record is %d bytes", len(b))
}
o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"})
if b, _ := json.Marshal(o); len(b) > 8192 {
t.Errorf("one remote record is %d bytes", len(b))
}
var procs []mcpProc
for i := 0; i < 3000; i++ {
procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}})
}
if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers {
t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers)
}
}
// A crafted config.toml nested thousands deep is refused, not walked on the
// stack; a realistic depth still parses.
func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) {
for name, src := range map[string]string{
"arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n",
"inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n",
} {
if _, err := parseTOMLSubset(src); err == nil {
t.Errorf("%s nested 5000 deep parsed", name)
}
}
if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil {
t.Errorf("a realistic depth: %v", err)
}
}
// macOS's nobody is uid -2 in its directory; it is a service account, not
// an unread uid.
func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) {
f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes})
if f.Class != ownerClassService {
t.Errorf("uid -2: %+v", f)
}
if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown {
t.Errorf("an unread uid: %+v", f)
}
}
// dscl that was killed by the timeout has not said the record is missing:
// only a real non-zero exit does.
func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("needs sh")
}
_, killed := exec.Command("sh", "-c", "kill -9 $$").Output()
_, missing := exec.Command("sh", "-c", "exit 56").Output()
if dsclRecordMissing(killed) {
t.Error("a killed dscl read as no local record")
}
if !dsclRecordMissing(missing) {
t.Error("dscl exiting 56 did not read as no local record")
}
if dsclRecordMissing(nil) {
t.Error("success read as no local record")
}
}
// A read that stops answering (a home on a network mount) holds neither the
// beat nor a second reader, and a finished read is reused for the cache
// window instead of redone on every 60-second beat.
func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) {
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
block := make(chan struct{})
t.Cleanup(func() {
close(block)
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
resetMCPConfigCache()
})
resetMCPConfigCache()
var calls int32
mcpHomesFn = func() ([]mcpHome, bool) {
atomic.AddInt32(&calls, 1)
<-block
return nil, true
}
mcpSystemFn = func() []mcpSource { return nil }
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil }
mcpReadTimeout = 100 * time.Millisecond
for beat := 0; beat < 3; beat++ {
done := make(chan mcpConfigScan, 1)
go func() { _, s := mcpServersReport(); done <- s }()
select {
case s := <-done:
if !s.TimedOut || !s.Supported {
t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s)
}
case <-time.After(5 * time.Second):
t.Fatalf("beat %d waited on a stuck read", beat)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("%d reads started while one was stuck", n)
}
resetMCPConfigCache()
atomic.StoreInt32(&calls, 0)
home := t.TempDir()
mcpHomesFn = func() ([]mcpHome, bool) {
atomic.AddInt32(&calls, 1)
return []mcpHome{{Dir: home, ConfigDir: home}}, true
}
for beat := 0; beat < 3; beat++ {
if _, s := mcpServersReport(); s.TimedOut {
t.Fatalf("beat %d timed out: %+v", beat, s)
}
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Fatalf("the files were read %d times in one cache window", n)
}
}
// A read stuck on a home that stopped answering delays ONE beat by at most
// mcpReadTimeout; the beats after it do not wait on the same read again. And
// until a read has finished, nothing is known to be declared, so no running
// process may be reported as running_not_declared.
func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) {
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
block := make(chan struct{})
t.Cleanup(func() {
close(block)
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
resetMCPConfigCache()
})
resetMCPConfigCache()
mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true }
mcpSystemFn = func() []mcpSource { return nil }
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) {
return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil
}
mcpReadTimeout = 400 * time.Millisecond
start := time.Now()
out, s := mcpServersReport()
if took := time.Since(start); took < mcpReadTimeout/2 {
t.Fatalf("the first beat did not wait for the read at all (%v)", took)
}
if !s.TimedOut || len(out) != 0 {
t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out)
}
for beat := 1; beat < 4; beat++ {
start := time.Now()
out, s := mcpServersReport()
if took := time.Since(start); took > mcpReadTimeout/2 {
t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took)
}
if !s.TimedOut || len(out) != 0 {
t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build darwin
package main
import (
"os"
"path/filepath"
)
// The accounts whose agent clients mcpconfig.go reads on macOS: the service's
// own home and every directory under /Users except Shared. The per-user
// configuration directory is ~/Library/Application Support.
func mcpHomes() ([]mcpHome, bool) {
var homes []string
if h, err := os.UserHomeDir(); err == nil && h != "" {
homes = append(homes, h)
}
if entries, err := os.ReadDir("/Users"); err == nil {
for _, e := range entries {
if e.IsDir() && e.Name() != "Shared" && e.Name()[0] != '.' {
homes = append(homes, filepath.Join("/Users", e.Name()))
}
}
}
var out []mcpHome
for _, h := range dedupeStrings(homes) {
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "Library", "Application Support")})
}
return out, true
}
func mcpSystemFiles() []mcpSource { return mcpSystemSources("darwin", "") }
+23
View File
@@ -0,0 +1,23 @@
//go:build linux
package main
import (
"path/filepath"
)
// The accounts whose agent clients mcpconfig.go reads on Linux: the same
// homes the app inventory reads (userHomes: the service's own home and every
// directory under /home). A home this process may not enter yields files it
// counts as unreadable, never as empty. The per-user configuration directory
// is the XDG default, ~/.config; another account's XDG_CONFIG_HOME lives in
// its own environment, which a system service does not see.
func mcpHomes() ([]mcpHome, bool) {
var out []mcpHome
for _, h := range userHomes() {
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, ".config")})
}
return out, true
}
func mcpSystemFiles() []mcpSource { return mcpSystemSources("linux", "") }
+38
View File
@@ -0,0 +1,38 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
// A FIFO left where a client keeps its config must not hold the read, and
// with it the heartbeat: it is counted unreadable and the scan moves on.
func TestReadMCPConfigsDoesNotBlockOnAFIFO(t *testing.T) {
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(filepath.Join(home, ".cursor", "mcp.json"), 0o600); err != nil {
t.Skipf("cannot make a FIFO here: %v", err)
}
oldH, oldS := mcpHomesFn, mcpSystemFn
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldH, oldS })
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
mcpSystemFn = func() []mcpSource { return nil }
done := make(chan mcpConfigScan, 1)
go func() { _, scan := readMCPConfigs(); done <- scan }()
select {
case scan := <-done:
if scan.Unreadable != 1 {
t.Fatalf("the FIFO was not counted: %+v", scan)
}
case <-time.After(5 * time.Second):
t.Fatal("reading the config files blocked on a FIFO")
}
}
+42
View File
@@ -0,0 +1,42 @@
//go:build darwin
package main
import (
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// mcpOpenRegular opens path for reading only if what it names, after any
// symlinks, is a regular file, without ever opening anything else. macOS has
// no O_PATH, so the links are resolved first, the resolved path is required
// to be a regular file, and it is then opened with O_NOFOLLOW_ANY: a link
// put in its place since the look makes the open fail rather than lead
// somewhere new, and a device can only sit in devfs, which the look already
// refused. The opened file must be the one looked at.
func mcpOpenRegular(path string) (*os.File, string, error) {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, "", err
}
lst, err := os.Lstat(resolved)
if err != nil {
return nil, "", err
}
if !lst.Mode().IsRegular() {
return nil, "", errMCPNotRegular
}
fd, err := unix.Open(resolved, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_NOFOLLOW_ANY, 0)
if err != nil {
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
}
f := os.NewFile(uintptr(fd), path)
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, lst) {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpFileOwner(fi), nil
}
+42
View File
@@ -0,0 +1,42 @@
//go:build linux
package main
import (
"os"
"strconv"
"golang.org/x/sys/unix"
)
// mcpOpenRegular opens path for reading only if what it names, after any
// symlinks, is a regular file, without ever opening anything else. The first
// open is O_PATH: it resolves the path and pins the inode but does not open
// the file (no driver runs, a FIFO does not wait). Only once fstat says
// regular is that same inode reopened for reading through /proc/self/fd, so
// swapping the path between the look and the read reaches nothing new.
func mcpOpenRegular(path string) (*os.File, string, error) {
pfd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
if err != nil {
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
}
defer unix.Close(pfd)
var st unix.Stat_t
if err := unix.Fstat(pfd, &st); err != nil {
return nil, "", &os.PathError{Op: "fstat", Path: path, Err: err}
}
if st.Mode&unix.S_IFMT != unix.S_IFREG {
return nil, "", errMCPNotRegular
}
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
if err != nil {
return nil, "", &os.PathError{Op: "reopen", Path: path, Err: err}
}
f := os.NewFile(uintptr(fd), path)
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpFileOwner(fi), nil
}
+136
View File
@@ -0,0 +1,136 @@
//go:build linux
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"golang.org/x/sys/unix"
)
// A config path that is, or links to, something other than a regular file
// is never OPENED for reading. Opening is not harmless: a device's driver
// runs on open (a watchdog arms, a serial line toggles DTR), so a symlink at
// ~/.cursor/mcp.json pointing at one would let any account make this root
// service act on hardware. inotify reports every real open of the target;
// the reader's look at what the path is must not be one.
func TestMCPReadFileNeverOpensANonRegularTarget(t *testing.T) {
dir := t.TempDir()
fifo := filepath.Join(dir, "target")
if err := unix.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("cannot make a FIFO here: %v", err)
}
link := filepath.Join(dir, "mcp.json")
if err := os.Symlink(fifo, link); err != nil {
t.Fatal(err)
}
ino, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
t.Skipf("no inotify: %v", err)
}
defer unix.Close(ino)
if _, err := unix.InotifyAddWatch(ino, fifo, unix.IN_OPEN); err != nil {
t.Skipf("cannot watch: %v", err)
}
var scan mcpConfigScan
for _, p := range []string{link, fifo} {
if b, ok := mcpReadFile(p, &scan); ok || b != nil {
t.Fatalf("%s read as a config file", p)
}
}
buf := make([]byte, 4096)
if n, _ := unix.Read(ino, buf); n > 0 {
t.Fatalf("the FIFO was opened %d time(s) by the reader", n/unix.SizeofInotifyEvent)
}
if scan.Unreadable != 2 {
t.Fatalf("scan: %+v", scan)
}
// A regular file through a symlink is still read, as the client would.
real := filepath.Join(dir, "real.json")
if err := os.WriteFile(real, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
t.Fatal(err)
}
link2 := filepath.Join(dir, "link2.json")
if err := os.Symlink(real, link2); err != nil {
t.Fatal(err)
}
if _, ok := mcpReadFile(link2, &scan); !ok {
t.Fatal("a symlink to a regular file was not read")
}
}
// A file in one account's home, or in a project that account listed, is read
// only when that account or root owns it. Otherwise one account could point
// its own config path, or its list of projects, at another account's files
// and have this service read them on its behalf.
func TestReadMCPConfigsSkipsAFileAnotherAccountOwns(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("needs root to make files owned by other uids")
}
const owner, other = 42101, 42102
root := t.TempDir()
if err := os.Chmod(root, 0o755); err != nil {
t.Fatal(err)
}
home := filepath.Join(root, "home", "demo")
victim := filepath.Join(root, "victim")
write := func(p, body string, uid int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chown(p, uid, uid); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(victim, "secret.json"), `{"mcpServers": {"victim-file": {"command": "demo-v"}}}`, other)
write(filepath.Join(victim, ".mcp.json"), `{"mcpServers": {"victim-project": {"command": "demo-w"}}}`, other)
write(filepath.Join(root, "rootowned.json"), `{"mcpServers": {"root-file": {"command": "demo-r"}}}`, 0)
projJSON, _ := json.Marshal(victim)
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"own": {"command": "demo-o"}}, "projects": {`+string(projJSON)+`: {}}}`, owner)
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(victim, "secret.json"), filepath.Join(home, ".cursor", "mcp.json")); err != nil {
t.Fatal(err)
}
// A dotfile manager's link to a root-owned file is the account's own.
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "rootowned.json"), filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")); err != nil {
t.Fatal(err)
}
if err := os.Chown(home, owner, owner); err != nil {
t.Fatal(err)
}
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
mcpGOOS = "linux"
mcpHomesFn = func() ([]mcpHome, bool) {
return []mcpHome{{Dir: home, ConfigDir: filepath.Join(home, ".config")}}, true
}
mcpSystemFn = func() []mcpSource { return nil }
got, scan := readMCPConfigs()
names := map[string]bool{}
for _, o := range got {
names[o.Name] = true
}
if names["victim-file"] || names["victim-project"] {
t.Errorf("read another account's file on this account's behalf: %v", names)
}
if !names["own"] || !names["root-file"] {
t.Errorf("the account's own and root-owned files must still be read: %v", names)
}
if scan.ForeignOwner != 2 {
t.Errorf("scan: %+v", scan)
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !linux && !darwin && !windows
package main
import "os"
// No account list on this platform (mcpconfig_other.go), so the reader never
// gets here; this keeps the package building. A path is still opened only
// when it was a regular file, and the opened file must be the one looked at.
func mcpOpenRegular(path string) (*os.File, string, error) {
st, err := os.Stat(path)
if err != nil {
return nil, "", err
}
if !st.Mode().IsRegular() {
return nil, "", errMCPNotRegular
}
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
fi, err := f.Stat()
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, st) {
f.Close()
return nil, "", errMCPNotRegular
}
return f, mcpOwnerUnknown, nil
}
var mcpAdminOwners = map[string]bool{}
// mcpPathOwner: no owner model here, so no owner check.
func mcpPathOwner(string) string { return mcpNoOwnerCheck }
+122
View File
@@ -0,0 +1,122 @@
//go:build windows
package main
import (
"os"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// The decisions are in mcpconfig_winopen.go; this file makes the calls.
// The values mcpconfig_winopen.go spells out must be Windows' own: each line
// fails to compile if one differs.
var (
_ = [1]struct{}{}[mcpWinFileTypeDisk^windows.FILE_TYPE_DISK]
_ = [1]struct{}{}[mcpWinAttrDirectory^windows.FILE_ATTRIBUTE_DIRECTORY]
_ = [1]struct{}{}[mcpWinAttrDevice^windows.FILE_ATTRIBUTE_DEVICE]
_ = [1]struct{}{}[mcpWinAttrReparse^windows.FILE_ATTRIBUTE_REPARSE_POINT]
)
var mcpAdminOwners = mcpWinAdminOwners
// mcpOpenRegular opens path with CreateFile and keeps it only when the
// handle is a regular file on disk (mcpWinOpenWith). It shares read, write
// and delete with everyone else: the agent client that owns the file must be
// able to save it, including by replacing it, while this reads it.
func mcpOpenRegular(path string) (*os.File, string, error) {
return mcpWinOpenWith(mcpWinSystemOps, path)
}
var mcpWinSystemOps = mcpWinOps{
openPath: mcpWinCreateFile,
handleInfo: mcpWinHandleFacts,
closeHandle: func(h uintptr) { windows.CloseHandle(windows.Handle(h)) },
asFile: func(h uintptr, path string) *os.File { return os.NewFile(h, path) },
}
func mcpWinCreateFile(path string) (uintptr, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err}
}
h, err := windows.CreateFile(p, windows.GENERIC_READ,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil,
windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err}
}
return uintptr(h), nil
}
// mcpWinHandleFacts reads the type, the attributes and the owner from the
// handle. GENERIC_READ carries READ_CONTROL, which GetSecurityInfo needs for
// the owner; an owner that cannot be read is left "" and the file is then
// read only where no owner check applies.
func mcpWinHandleFacts(h uintptr) (mcpWinHandleInfo, error) {
wh := windows.Handle(h)
var i mcpWinHandleInfo
t, err := windows.GetFileType(wh)
if err != nil {
return i, err
}
i.FileType = t
if t != windows.FILE_TYPE_DISK {
return i, nil // refused by mcpWinRegular; nothing more to ask it
}
var bi windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(wh, &bi); err != nil {
return i, err
}
i.Attributes = bi.FileAttributes
if sd, err := windows.GetSecurityInfo(wh, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION); err == nil {
if o, _, err := sd.Owner(); err == nil && o != nil {
i.Owner = o.String()
}
}
return i, nil
}
// mcpPathOwner is the SID whose profile dir is, from the ProfileList the
// User Profile Service keeps
// (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>,
// value ProfileImagePath). A directory it does not name, or names twice,
// gives mcpOwnerUnknown, so only SYSTEM's and the Administrators group's
// files are read there.
func mcpPathOwner(dir string) string {
return mcpWinProfileSID(dir, mcpWinProfiles())
}
func mcpWinProfiles() map[string]string {
out := map[string]string{}
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return out
}
defer k.Close()
sids, err := k.ReadSubKeyNames(-1)
if err != nil {
return out
}
for _, sid := range sids {
sk, err := registry.OpenKey(k, sid, registry.QUERY_VALUE)
if err != nil {
continue
}
p, typ, err := sk.GetStringValue("ProfileImagePath")
sk.Close()
if err != nil {
continue
}
if typ == registry.EXPAND_SZ {
if x, err := registry.ExpandString(p); err == nil {
p = x
}
}
out[sid] = p
}
return out
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package main
// No account list on this platform: the scan says unsupported rather than
// reporting a machine with no tool servers.
func mcpHomes() ([]mcpHome, bool) { return nil, false }
func mcpSystemFiles() []mcpSource { return nil }

Some files were not shown because too many files have changed in this diff Show More