From 71bad07dd9ec6b683f399613155f5eead543285e Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Mon, 6 Jul 2026 16:53:06 +0000 Subject: [PATCH 01/17] =?UTF-8?q?docs(plans):=20add=20moat-init=20shell?= =?UTF-8?q?=E2=86=92Go=20rewrite=20design=20&=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design and implementation plan for replacing the 611-line embedded moat-init.sh container entrypoint with a compiled Go binary (cmd/moat-init). The rewrite lifts the business logic into Go where it is unit-testable while keeping gosu/socat/tar as targeted subprocesses (privilege drop, SSH bridge, volume byte-copy) — not a base-image slimming effort. Includes a 144-item requirements catalog extracted line-by-line from the current script (the acceptance-test basis), 36 adversarial-review additions, the build/embed/release design (per-arch //go:embed, offline build preserved, Homebrew unchanged), the unit/integration/e2e + parity harness test strategy, an example-based probe matrix, a 9-commit plan, and a risk register. Refined over two ce-doc-review passes plus a design-principle retarget. Open Decisions #1 (keep gosu) and #2 (keep tar) resolve to targeted subprocesses; the native-replacement PR-split is moot; --plan scoping remains under Deferred / Open Questions. --- .../2026-07-01-moat-init-go-rewrite-plan.md | 453 ++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 docs/plans/2026-07-01-moat-init-go-rewrite-plan.md diff --git a/docs/plans/2026-07-01-moat-init-go-rewrite-plan.md b/docs/plans/2026-07-01-moat-init-go-rewrite-plan.md new file mode 100644 index 00000000..6a52fac8 --- /dev/null +++ b/docs/plans/2026-07-01-moat-init-go-rewrite-plan.md @@ -0,0 +1,453 @@ +# moat-init: shell → Go rewrite — design & implementation plan + +- **Date:** 2026-07-01 +- **Status:** Proposal (revised after ce-doc-review, 2026-07-06) +- **Scope:** Replace the 611-line embedded `internal/deps/scripts/moat-init.sh` container entrypoint with a compiled Go binary `cmd/moat-init`, with parity as the contract. +- **Shape:** One PR, multiple commits. Blast radius is maximal (every container `exec`s this as PID 1), so the PR dual-ships both entrypoints behind `MOAT_INIT_IMPL` and lands a parity harness that diffs container state between them. + +This plan is anchored by a **144-item behavioral requirements catalog** (Appendix A) extracted line-by-line from the current script, plus **36 adversarial-review additions** (Appendix B). Together they are the acceptance-test suite: the bar for "done" is behavioral parity, including exact error-message wording and every `|| true` / fail-closed distinction. + +--- + +## 1. Why do this + +The entrypoint is the single most under-tested, most brittle-to-modify component in the repo: + +- **Untestable today.** It is verified almost entirely by ~16 `strings.Contains(MoatInitScript, "…")` assertions (grepping the script *text*, not its behavior) plus a handful of e2e container runs. Even the ordering invariant is a `strings.Index` comparison. You cannot unit-test shell behavior in isolation. +- **High blast radius, subtle contract.** 611 lines encode fail-closed `/etc/hosts` handling, a GNU-tar-1.34 exclude quirk, POSIX pipeline exit-code capture, four `chmod 600` security contracts, and gosu privilege-drop semantics — each a landmine for a naive edit. +- **Payoff of Go.** The point of the rewrite is to lift the *business logic* into Go where it is directly unit-testable — env parsing, branch/mode selection, phase ordering, error classification, config/arg assembly, exclude computation, root/moatuser detection — while **delegating the mechanical, security-sensitive, or well-understood system operations to the existing audited tools** (`gosu` for the privilege drop, `socat` for the SSH bridge, `tar` for the volume byte-copy). Each responsibility becomes a table-tested function, and a `moat-init --plan` dry-run makes the computed plan inspectable. This is *not* a base-image slimming effort: `gosu`/`socat`/`tar` stay in the image and stay invoked as targeted subprocesses. (Pure-logic shell utilities that do no real system work — `base64`, `getent`, `stat`, `awk`, `dirname`, `id` — *do* move in-process, since a Go-stdlib replacement (`encoding/base64`, `net.Resolver`, `syscall.Stat_t`, string ops, `os/user`) is cleaner and more testable; that **is** moving logic to Go. These are base-image coreutils, **not** moat apt packages, so they stay in the image regardless; the entrypoint just stops invoking them.) + +## 2. Scope & non-goals + +**In scope:** full behavioral port; the build/embed/release plumbing; the unit/integration/e2e test harness; example-based probes plus new fixtures for the coverage gaps. + +**Non-goals (parity is the contract):** +- No change to entrypoint behavior, ordering, or user-facing wording. Error messages are copied **verbatim** (they are documented contract and are asserted by existing tests). +- No new features except the `--plan` dry-run affordance. +- No `moat.yaml` / daemon-API changes. +- Optional security hardening the critics suggested (e.g. scrubbing `MOAT_*` vars beyond `MOAT_INIT_FILES`, bounding the ancestor-chown walk) is documented as **follow-up**, not folded into the parity port — it would be a deliberate behavior change and must be separable. + +## 3. Architecture + +``` +cmd/moat-init/main.go # thin: parse env → build phase list → run → exec. linux/amd64|arm64, CGO_ENABLED=0. +internal/moatinit/ # the phases, host-testable + phases.go # ordered phase list (mirrors X-ORDER-GLOBAL) + hosts.go ssh.go agents.go initfiles.go git.go docker.go volume.go hooks.go exec.go + sys.go # Sys seam: Root, Geteuid, LookupUser/Group, Chown/Lchown/Chmod, Run(cmd), ResolveIPv4() + *_test.go # unit + integration (injected root) +internal/initbin/ # //go:embed embed/moat-init-linux-{amd64,arm64}; arch selector for writeEntrypoint +``` + +**Testability seam.** All identity/filesystem/subprocess/DNS operations go through a `Sys` interface. Production wires the real OS; tests inject a `t.TempDir()` root, a fake user/group table, a recording chown/chmod, and a stub resolver — so ~all fs and logic behavior is exercised under `go test` with **no container and no root**. Only the true-privileged behaviors (real uid drop, real supplementary groups, real long-lived children) require e2e. + +**Ordered phase list** (exact order is a correctness/security invariant — `X-ORDER-GLOBAL`, `WS-12`, `INIT-11`): + +1. `/etc/hosts` synthetic entries — **first**, before anything resolves `moat-proxy`/`moat-host` +2. SSH agent bridge (detached child) +3. Claude staging → 4. Codex staging → 5. Gemini staging +6. Provider init-files (+ remove `MOAT_INIT_FILES` from the exec env) +7. Clipboard `Xvfb` (detached child) + `DISPLAY=:99` +8. Git `--system` config +9. Docker: dind (detached `dockerd` + readiness) **xor** host-socket group +— tail sequence — +10. `populateWorkspaceVolume` → 11. `setupWorkspaceMCPJSON` → 12. `runPreRunHook` → 13. named-volume chown → 14. **privilege drop + `syscall.Exec`** + +**Two load-bearing architecture decisions (see Risks §10):** + +- **Hand-off is `syscall.Exec`, never fork+wait.** The entrypoint replaces its own image with the user command (or, on the root path, with `gosu moatuser "$@"`), exactly like the shell's `exec`/`exec gosu`. This preserves PID-1 semantics and keeps the detached children (`socat`/`Xvfb`/`dockerd`) **parented identically to today** — after `exec` the user command *is* PID 1, so reaping those children is the user command's responsibility, exactly as under the shell's `exec`. Neither impl reaps them; this is parity, not a new guarantee (a parity probe asserts the post-exec zombie/parent state matches the shell). Fork+wait would change parenting and signal behavior — prohibited. +- **Privilege drop: Go selects the branch, then execs `gosu`.** Go computes the branch from the identity state — `Geteuid() != 0` → `syscall.Exec` the user command directly; `uid == 0` and `moatuser` exists → `syscall.Exec` `gosu moatuser "$@"`; `uid == 0` and no `moatuser` → **fatal** (fail-closed, exact wording preserved). There is **no native `getgrouplist`/`setgroups`/`setgid`/`setuid` reimplementation** — gosu performs the actual identity transition, so we inherit its supplementary-group, saved-set-uid, and signal semantics for free. This is the whole reason gosu exists, and delegating to it **eliminates the P0 privilege-drop-parity risk** and the CGO/`getgrouplist`/files-NSS worry entirely: gosu already computes moatuser's groups fresh from `/etc/group` **at exec time**, so it picks up any mid-run `usermod` (dind/host-socket adding `moatuser` to the docker group) with no work on our side. Because we *are* gosu, no parity oracle is needed — the plan drops the "assert `id -G` **set** == `gosu moatuser id -G`" harness machinery (there is nothing to compare against itself). The testable surface is the *branch selection and ordering* (uid detection, moatuser lookup, fatal-no-moatuser), which is exercised in unit tests via the `Sys` seam. `gosu` stays in the base image **permanently** (not a one-release escape hatch); it is a targeted, security-critical subprocess we deliberately keep. + +## 4. Requirements catalog (the acceptance basis) + +144 requirements across 9 regions, each tagged with a **failure mode** (`fatal` / `best-effort` / `warn` / `info`) and a **test level** (U=unit, I=integration, E=e2e), plus a concrete acceptance assertion. The full catalog is **Appendix A**. The adversarial critics (security / lifecycle / cross-runtime lenses) added 36 items — **Appendix B** — the P0/P1 of which are load-bearing and are reflected in the commit plan and risk register. + +| Region | Reqs | Fatal exits | +|--------|------|-------------| +| HOSTS | 15 | 2 | +| SSH | 13 | 0 | +| AGENT | 32 | 12 | +| INIT | 14 | 5 | +| GIT | 10 | 0 | +| DOCKER | 14 | 4 | +| WS | 13 | 8 | +| EXEC | 14 | 8 | +| X | 19 | 12 | +| **Total** | **144** | **51** | + +The classification directly drives the port: `fatal` → return an error that becomes a non-zero exit (never swallow); `best-effort` → log at debug and continue (mirror `2>/dev/null || true`); `warn` → exact message to stderr, continue; `info` → no-op/observational. + +## 5. Build, bundle & release + +**Embed, symmetric with today.** The script is `//go:embed`'d as a string; the Go entrypoint is `//go:embed`'d as **two prebuilt static linux binaries** (amd64+arm64) in `internal/initbin`. `writeEntrypoint` (`internal/deps/dockerfile.go:601`) COPYs the **arch-matched** binary (image arch = host build arch = `runtime.GOARCH`) instead of the script, keeping the existing `chmod +x` + `ENTRYPOINT`. + +**Bootstrap / build-ordering.** `internal/initbin` won't compile if the embed targets are missing. Commit tiny **stub** blobs so a clean `go build ./...` / fresh clone always resolves; a `//go:generate` cross-compiles the real binaries (`CGO_ENABLED=0 -trimpath -ldflags "-s -w"`) over them, wired into the `Makefile` `build` target and the goreleaser `before.hooks`. Release regenerates real blobs before the host build embeds them. Two things the naive version gets wrong (review): + +- **The embed dir must not match `.gitignore`.** The repo's `.gitignore` has a bare `dist/` (line 51) that matches at *any* depth, so committed stubs under `internal/initbin/dist/` are silently untracked (`git check-ignore` confirms) and a fresh clone fails `go build` on the missing embed target — the exact failure the stub was meant to prevent. Put the blobs under a non-ignored dir (e.g. `internal/initbin/embed/`) and gitignore only the *regenerated real* output there, verified with `git check-ignore`. +- **The stub must fail closed.** A bare `go build` / `go test` (bypassing `make build`, so `go:generate` never runs) embeds the stub, which then ships as PID 1 and silently skips privilege drop, `chmod 600`, `/etc/hosts`, and the scrub — a container that starts as root with secrets exposed and *no error*. So the stub's `main()` must print `FATAL: moat-init stub embedded — build via 'make build'` to stderr and `exit 1`, and `writeEntrypoint` (or a release check) must refuse to generate/ship an image whose embedded bytes match the stub checksum. But checksum-matching only catches the *known* stub — a `go:generate` that silently emits a broken *non-stub* blob (wrong GOARCH, truncated output, an older commit lacking the privilege-drop phase), committed with a regenerated `checksums.txt` that matches it, passes **both** the checksum test and the ship-refusal, and its `main()` is not the fail-loud stub. So add a **positive functional gate**: the release check *execs* the embedded binary (e.g. `moat-init --plan` against a fixed env) and asserts it actually performs the privilege-drop/scrub phases — a regenerated-but-defective PID 1 then fails regardless of its checksum. (This matters more because `checksums.txt` is regenerated on every real rebuild / Go-toolchain bump, so a bad blob ships *green* unless a functional gate is independent of the checksum.) + +**Offline build preserved.** The entrypoint is still materialized from bytes inside the host `moat` binary and `COPY`'d from local context — zero network at image build. Reject `RUN curl …` and `FROM golang` multi-stage (a test asserts the generated Dockerfile contains no network fetch for the entrypoint). + +**Homebrew: unchanged.** The formula still does `bin.install "moat"` — one artifact per os/arch; the init binaries are embedded *inside* `moat`, not separate archive members. Measured cost: ~**+4.3 MB** per host binary (both arches, `-s -w`); a gzip-at-embed fallback (~1.9 MB total) exists if it ever matters. The only release-pipeline change is the `before` hook, invisible to brew users. + +**Reproducible blobs.** `-trimpath` + committed SHA-256 (`internal/initbin/checksums.txt`) + a unit test that hashes the embedded bytes and compares, catching a stale/hand-edited blob. + +**Base-image deltas** — there are **no** moat apt-list changes: `gosu`, `socat`, and `tar` all stay installed and stay invoked as targeted subprocesses, so `dockerfile.go`'s `baseAptPackages`/conditional lists and their companion `strings.Contains` tests are untouched. Everything in the table below that changes is an *entrypoint-invocation* change only (a shell util whose pure logic moved in-process), never a package removal: + +| Binary | Disposition | +|--------|-------------| +| `gosu` | **keep** — the privilege drop stays `exec gosu moatuser "$@"`; Go only selects the branch. Permanent targeted subprocess, no apt change | +| `socat` | **keep** — Go parses `MOAT_SSH_TCP_ADDR` and decides the socket dir/path/mode/chown, then spawns `socat` as the targeted long-lived child (no native TCP↔unix bridge). Permanent, no apt change | +| `base64`, `getent`, `stat`, `awk`, `dirname`, `id` | **no longer invoked** by the entrypoint — pure-logic utilities doing no real system work, so their logic moves in-process (`encoding/base64`, `net.Resolver`, `syscall.Stat_t`, string ops, `os/user`); this *is* moving logic to Go. These are base-image coreutils, **not** moat apt packages — they stay in the image; no apt-list change, no companion test. (`getent` is also used at image *build* time in `writeUserSetup`, so it stays regardless.) | +| `tar` | **keep** — Go computes the excludes, writes the exclude file, assembles the args, then **runs `tar`** for the byte copy and checks both pipe exit codes (§6). Base-image package, stays; the GNU-tar-version fragility is *contained/documented*, not eliminated — a native `filepath.WalkDir` copy is an optional future hardening, out of scope here | +| `git` | keep (feature dep, when git is used) | +| `dockerd` + `docker` CLI | keep (dind) | +| `xvfb`, `xclip` | keep (clipboard) | +| `/bin/sh` | **keep** — `MOAT_PRE_RUN` and provider hooks are user shell strings (`sh -c`) | + +## 6. Workspace-volume copy: Go owns the logic, `tar` does the byte copy + +Keep the shelled `tar` pipe as the byte-copy mechanism; move only the **business logic** into Go. `populate_workspace_volume` becomes: Go resolves the staging source (`WS-03`) and root guard (`WS-02`), computes the `./`-rooted excludes (`WS-13`) and **writes the exclude file** (`WS-04`), assembles the `tar -cf - . | tar -xf - .` argument vectors, **runs** the two `tar` processes (the actual byte copy, symlinks preserved as tar's default — `WS-08`), captures and checks **both** pipe exit codes (`WS-09`, not just the rightmost `$?`), and does the recursive `chown -R moatuser:moatuser /workspace` (`WS-10`). Everything verifiable — exclude computation, exit-code classification, staging resolution, fail-closed on a missing source root (critic P2 — never a silently empty `/workspace`) — is table-tested Go; the mechanical archive copy stays `tar`. `tar` stays in the base image and stays invoked. + +The GNU-tar-1.34 quirks (the `--null`-less `--exclude-from` behavior `WS-05`, the no-`--no-dereference` symlink default `WS-08`) are **preserved deliberately** (parity) and are now **contained and documented** — the exclude file and arg vector are assembled by tested Go, so the quirk lives in one asserted place rather than across a shell pipeline. + +**Optional future hardening (not this plan).** A native `filepath.WalkDir` + `os.Readlink`/`os.Symlink` + `os.Lchown` copy would *shed* the GNU-tar-version fragility entirely (also cross-base-image fragile, since `golang:X` is non-slim and ships a different `tar` than `*-slim` — critic P2). That is a follow-up to eliminate the fragility, not merely contain it; it is out of scope here because it is real system work, not business logic. If pursued it must reproduce the same invariants: `./`-rooted exclude matching (`WS-13`), symlinks copied as symlinks never dereferenced (`WS-08`), fatal on unreadable source root, both-ends error checking (`WS-09`), and `Lchown` (not `Chown`) on the re-own so out-of-tree symlink targets are never re-owned (`WS-10`). + +## 7. Test strategy + +Five layers, mapped to the catalog's test-level tags: + +1. **Unit** (`make test-unit`, no container) — the direct replacement for the brittle `strings.Contains` tests. Table tests for: `TARGET_HOME`/owner resolver (root×moatuser matrix), extra-hosts parse (`SplitN` on **first** colon; `strings.Fields` word-splitting), init-files tab/base64 split, exclude matcher, dind/host mutual-exclusion, git `insteadOf` exact-`"1"` check, and the **failure-mode classification** (each guarded command → continue; each unguarded fatal → non-zero). +2. **Integration** (injected root, no container) — credential-file placement + exact `0600`; init-files tree with dir `0755` + ancestor-chown walk (recorded calls); the workspace-copy logic (recorded exclude-file contents, assembled `tar` arg vectors, both-pipe exit-code classification, and the recursive-chown call — the byte copy itself is exercised e2e where a real `tar` runs); `/etc/hosts` append against a temp file; git config against a temp system file. +3. **True e2e** (`-tags=e2e`, real container) — the privileged/unobservable behaviors: `exec gosu moatuser` drops to uid-5000 **with the docker supplementary group** (Go selected the branch; gosu did the drop, so no native group-set reimplementation to assert — just that the right branch ran and gosu produced the right uid/gid/groups), root-without-moatuser FATAL, the real `tar` byte copy against a staging tree (symlinks preserved, excludes applied, `/workspace` re-owned), `MOAT_INIT_FILES` absent from the child env, exact exec semantics (PID/exit-code passthrough incl. pre-run's literal code). +4. **Long-lived-child probes** — user `Cmd` that runs *after* `exec` and asserts `socat`/`Xvfb`/`dockerd` are still serving (the child must survive the image replacement). Includes an Apple-container leg (different reaping model — critic P1). +5. **Golden + `--plan`** — golden files for the stderr error contract and for a side-effect-free `moat-init --plan` dry-run (prints the ordered actions it *would* take for a given env; a debugging aid shell can't offer). + +**Parity harness** (`internal/e2e/entrypoint_parity_test.go`): dual-ship both entrypoints behind `MOAT_INIT_IMPL` (a tiny dispatcher; default `sh` during the migration commits, flipped to `go` at cutover). The dispatcher accepts a **closed enum** — `MOAT_INIT_IMPL ∈ {sh, go}`, `MOAT_INIT_LEGACY == 1` — and is fatal on any other value; both are **operator-only** controls injected by the host binary (they select which security-critical entrypoint runs, so a user-settable switch is an attack surface). Enforcing operator-only is **net-new code** (commit 1): today the only run-env filter is `isMoatOwnedProxyVar`, which doesn't list these vars *and* is gated on `needsProxy` — so a grantless permissive run passes all env through unfiltered. Commit 1 adds an **always-on reserved-key reject** in `run.Create()` (independent of `needsProxy`) that fails the run if `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` appear in `moat.yaml` env or `-e`, with the companion `DetectMissingGrants`-style validator parity. (This nudges the §2 "no `moat.yaml`/daemon-API changes" non-goal — it's input validation, not a schema change, but call it out.) The dispatcher reads both vars **once** at startup, before phase 1, and freezes them — never re-reading after any user-controlled phase (e.g. the pre-run hook). During migration `writeEntrypoint` COPYs three things — the dispatcher at `/usr/local/bin/moat-init`, the shell script, and the arch-matched Go binary — so one cached image carries both impls (cutover removes the script behind `MOAT_INIT_LEGACY`). + +A fixed **state-dumper** user command emits a machine-parseable manifest, run through `sh` then `go`, asserting the manifests match. The manifest must cover what a naive file+`id` snapshot misses — otherwise a go-vs-sh diff passes while *both* impls are wrong: file set + modes + ownership, `git config --system`, `/etc/hosts`, `/workspace/.mcp.json`; the exec'd process's **`id -G` supplementary set compared as a set** (order-independent) go-vs-sh — a plain go-vs-sh diff suffices because both legs drop via `exec gosu moatuser` (there is no native group-list reimplementation to reconcile against a gosu oracle); the exec'd process's **`environ`** (so the `MOAT_INIT_FILES` scrub and any other `MOAT_*` leak are diffable) **with the `HTTP_PROXY`/`HTTPS_PROXY` auth token redacted to a fixed placeholder** — it carries the per-run proxy token, so a test asserts no token-shaped value survives and the state-dumper output stays test-only stdout (never the `logs.jsonl` / audit pipeline); and a **child-process census** for the known long-lived children (socat/Xvfb/dockerd) — *not* a raw process-table diff — sampled at a **fixed synchronization barrier on both legs** (each expected child signals readiness + a bounded settle), asserting on each child's reparent/zombie *class* so a transient helper's exit can't flip the result (per-runtime: Docker and Apple reparent differently). Masks: **mtimes only**, and compare a **content hash** of the statsig dir rather than masking its contents (masking contents would hide a copy-corruption bug). + +**Cache-key.** Fold the embedded binary bytes into `builder.go`'s `hashInput` so freshly-built images re-key — **and** bump the builder cache-key salt in commit 1, so images cached *before* commit 1 (whose `hashInput` never included the init bytes) also miss and rebuild. Otherwise a warm-cache user who upgrades to the commit-9 `go` default gets a cache hit on a pre-dispatcher image that still `COPY`s the sh entrypoint — the harness (which always builds fresh) never sees this; real users do. A test asserts a pre-commit-1 tag does not satisfy a post-commit-1 lookup. **Caveat:** the salt bump re-keys the `moat run` build/lookup path only — it does not *delete* an already-built image, so any workflow pinning a concrete `moat/run:` tag directly (CI, compose, k8s, scripts) still resolves the old sh-only image; document that pinned tags must be re-tagged/rebuilt at cutover, or gate cutover on a scan for them. + +**Runtime matrix.** Full manifest diff on Docker/Linux for all conditions; runtime-specific scenarios elsewhere — Docker-Desktop-macOS (the `@host.docker.internal` DNS-resolve-with-retry `/etc/hosts` branch), Apple containers (child reaping), dind. **Gotcha:** `.envrc` forces `MOAT_RUNTIME=docker`, masking the Apple/mac legs on a dev Mac — unset before running them. + +## 8. Example-based probes & new fixtures + +The `examples/` configs are the best source of realistic `MOAT_*` combinations. Probe map (assert post-start container state via a non-interactive checker command): + +| Example | Families exercised | Key assertions / negative probe | +|---------|-------------------|--------------------------------| +| `agent-claude` | AGENT + GIT + SSH `insteadOf` | `~/.claude` staged & owned moatuser; `MOAT_GIT_SSH_GITHUB=1` → `url.insteadOf` set (both github+ssh grants) | +| `agent-codex` / `agent-gemini` | AGENT | `auth.json`/`oauth_creds.json` mode **0600**; `.mcp.json` in `/workspace`; settings.json mode **preserved** (not forced 600) | +| `build-hooks` | EXEC | `.pre-run.timestamp` owned moatuser; **negative:** failing pre_run → exact `#372` diagnostic + literal exit code | +| `grant-github-nossh` | GIT + INIT | `verify.sh` already asserts `http.proxyAuthMethod=basic` — keep it green; no `insteadOf` (github only) | +| `grant-graphite` / `grant-github` | INIT + GIT | config file mode **0600**, parent dirs 0755 + owned moatuser | +| `ssh-github` | SSH (platform-split) | mac: socat bridge, socket **0660**; Linux: bind-mounted, **no socat** | +| `service-postgres` / `service-ollama` / `multi-endpoint` | HOSTS (platform-split) | `/etc/hosts` has resolved IP; `getent hosts` works; empty on Linux (`--add-host`) | +| `volume-cache` | WS + EXEC | `.cache` root owned moatuser (non-recursive chown) | +| `openclaw` | DOCKER (dind) | `dockerd` up, `docker info` works, moatuser in docker group | +| `go-dev` / `python-dev` / `interactive-*` | EXEC (plain) | `id -un` == moatuser; baseline privilege-drop path | + +**New fixtures for coverage gaps** (behaviors no example exercises today): +1. `MOAT_DOCKER_GID` host-socket group (openclaw uses dind, not host mode) +2. `MOAT_WORKSPACE_VOLUME` full populate (volume-cache only exercises `MOAT_VOLUME_CHOWN`) +3. `MOAT_CLIPBOARD=1` / `Xvfb` (no example sets the run flag) +4. `MOAT_INIT_FILES` **multi-record** + deep parent-chown walk + base64-with-embedded-newlines +5. Git identity (`MOAT_GIT_USER_NAME`/`EMAIL`) + the companion (host has no identity → not written) + +**Negative / fail-closed probes:** unresolvable extra-host → exit 1; `DIND`+`GID` → exit 1; failing pre_run → exit-code passthrough; root-without-moatuser → exit 1. + +## 9. Commit plan (one PR) + +Each commit builds green and is independently reviewable; behavior does not change until commit 9. + +1. **Scaffolding, no behavior change.** `cmd/moat-init` + `internal/moatinit` skeleton + `Sys` seam; `internal/initbin` embed + `go:generate` + committed stub + Makefile/goreleaser hook; dual-ship dispatcher behind `MOAT_INIT_IMPL` (default `sh`). Fold init bytes into `builder.go` cache key. +2. **Pure-logic phases + unit tests** — hosts parse, target-home/owner resolver, init-files parse, exclude matcher, docker mutex, git decision, failure-mode classification. +3. **Filesystem phases + integration tests (injected root)** — agent staging (four 0600 contracts), init-files (0600 files / 0755 dirs / ancestor-chown), git `--system` config, `/etc/hosts` append. +4. **Workspace-volume copy logic + tests** — Go computes `./`-excludes, writes the exclude file, assembles the `tar` arg vector, runs `tar`, checks both pipe exit codes, and does the recursive chown; tests cover exclude computation, exit-code classification, and fatal-on-missing-source (the real `tar` byte copy and symlink-preservation are exercised e2e). `tar` stays invoked. +5. **Long-lived children** — Go spawns `socat`/`Xvfb`/`dockerd` as targeted detached children (`Setpgid`) — `socat` bridges TCP↔unix as today, not a native bridge — with the SSH socket dir/path/mode/chown decided in Go + readiness polls (per-attempt timeout < loop budget) + post-exec "still serving" probes. Also ports the `root → gosu → sh -c` pre-run hook: Go picks the branch, gosu runs the hook (`EXEC-03`). +6. **Privilege drop: Go selects the branch, execs gosu** — Go computes the dispatch (uid==0? moatuser exists?) and does `exec "$@"` / `exec gosu moatuser "$@"` / fatal-no-moatuser via `syscall.Exec`; gosu owns `setgroups`/`setgid`/`setuid` (supplementary groups picked up fresh after any mid-run `usermod`, exactly as today). Build the exec env by **removing `MOAT_INIT_FILES`** from `os.Environ()` on both paths; e2e for branch selection + identity/groups (gosu's output) + env scrub. Much smaller than a native drop — no `getgrouplist`/`setgroups` reimplementation. +7. **`--plan` dry-run + golden error-message contract.** +8. **Parity harness + example probes + 5 new gap fixtures** (+ negative probes). +9. **Cutover.** Default `MOAT_INIT_IMPL=go`; keep `moat-init.sh` embedded behind `MOAT_INIT_LEGACY=1` (one-release rollback); `gosu`/`socat`/`tar` stay in the base image (they remain targeted subprocesses — no apt deps dropped); convert the old `strings.Contains` script tests to behavioral Go tests / delete; docs (`reference/01-cli.md` for `--plan`, concept page, `CHANGELOG.md` with the PR link). + +## 10. Risk register (P0/P1 from adversarial review first) + +| Risk | Severity | Mitigation | +|------|----------|-----------| +| Detached-child reaping after `exec` (socat/Xvfb/dockerd) | P0 | Pure parity — `socat`/`Xvfb`/`dockerd` are still spawned as targeted long-lived children and the entrypoint still `syscall.Exec`s away (of the user command, or of `gosu` on the root path), so parenting/reaping is unchanged from the shell. `syscall.Exec` only — never fork+wait; post-exec probes assert children serve; Apple-runtime e2e (different reaping model) | +| `MOAT_INIT_FILES` (secret, base64) leaking to the child | P0 | Build exec env by removing it from `os.Environ()` on **both** handoff paths; e2e asserts `env` in child has none | +| Static-linking on arbitrary/custom base images | P0 | `CGO_ENABLED=0` static binary must run on any base image (no dynamic loader/glibc dep); e2e on debian-slim, alpine, and a custom `moatuser` image. Note: root/moatuser **detection** is Go `os/user` (files-NSS), but the privilege **drop** execs `gosu` (which resolves the full group set itself), so this row no longer carries a native group-resolution parity sub-worry — only static-link portability of the detection/logic binary | +| `pipeline`/`base64` fatality — invalid base64 must fail closed, no partial secret + no exec | P1 | Decode to a buffer first; treat decode/mkdir/chmod failure in the record loop as fatal-abort-before-exec | +| IPv6 `::1` fallback written for `moat-proxy` (loopback breaks the proxy) | P1 | Prefer IPv4 (`LookupIP` `ip4`); warn or fail-closed when only a loopback/IPv6 address resolves for a proxy-bearing host | +| `DISPLAY` lost through the root privilege drop | P1 | Go builds the exec env with `DISPLAY=:99` and `HOME=/home/moatuser`, then execs `gosu moatuser` (which preserves that env into the dropped process) — e2e asserts both survive the drop | +| GNU-tar cross-base-image fragility | P2 | The byte copy stays a targeted `tar` invocation (Go computes excludes/args, runs `tar`, checks both pipe exit codes, then chowns — §6); the fragility is **contained and documented**, not eliminated. Pin/assert `tar` behavior in the parity-harness scenario images; a native `filepath.WalkDir` copy that would shed the GNU-tar-version fragility is an **optional future hardening**, out of scope here | +| Warm-cache users run a **stale (pre-dispatcher) image** after cutover; the parity harness (always builds fresh) never sees it | P1 | Fold embedded binary bytes into `builder.go` `hashInput` **and** bump the cache-key salt at commit 1 so pre-existing `moat run` lookups miss (§7); test a pre-commit-1 tag misses a post lookup. Re-keys the lookup path only — images pinned by explicit `moat/run:` tag survive; re-tag/rebuild those at cutover | +| `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are unguarded env switches selecting the entrypoint | P1 | Closed enum, fatal on other values; enforcement is **net-new** — an always-on reserved-key reject in `run.Create()` (ungated by `needsProxy`, unlike `isMoatOwnedProxyVar`) fails the run if they appear in `moat.yaml`/`-e`; dispatcher reads them once early and freezes (§7) | +| Committed stub ships as a fake PID-1 entrypoint via a bare `go build` (bypassing `go:generate`) | P1 | `.gitignore` fix so the stub is trackable; stub `main()` exits 1 loudly; **plus a positive functional gate** — the release execs the embedded binary and asserts it performs the drop/scrub, catching a regenerated-but-defective *non-stub* blob that checksum-matching misses (§5) | +| Docker-Desktop-macOS (`@host` DNS) and Apple (reaping) legs are **untestable in CI** — every runner is `ubuntu-latest`, and `.envrc` masks them on a dev Mac | P1 | Named coverage gap; cutover (commit 9) gated on a documented manual Mac sign-off with `MOAT_RUNTIME` unset for both legs; prefer host-injected resolved IPs to shrink the Mac-only surface | +| `chmod 755` widens a pre-existing restrictive init-file parent dir | P2 | Preserve parity (0755) but document; consider `MkdirAll`-only-when-created hardening as follow-up | + +## 11. Open decisions (recommended answers) + +1. **Native drop vs keep gosu** → *keep `gosu` (and `socat`, `tar`) as targeted subprocesses*, permanently. Go computes the privilege-drop branch (uid==0? moatuser exists? → `exec "$@"` / `exec gosu moatuser "$@"` / fatal-no-moatuser) and execs `gosu` via `syscall.Exec`; there is no native `getgrouplist`/`setgroups`/`setgid`/`setuid` reimplementation. This eliminates the biggest risk outright — the goal is testable business logic in Go, not replacing the audited privilege-drop tool. (The `MOAT_INIT_IMPL` sh→go dual-ship still gates the rewrite; `gosu` is not tied to that window.) +2. **tar-in-Go vs keep tar** → *keep `tar`, targeted*. Go computes the excludes, writes the exclude file, assembles the args, runs the `tar` pipe, checks **both** exit codes, and does the chown; the archive byte copy stays `tar`. A native `filepath.WalkDir` copy is optional future hardening, not part of this plan. +3. **Dual-ship escape hatch vs hard-cut** → *dual-ship one release* (`MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY`), then delete the script. +4. **`--plan`** → ship as a *permanent, documented* debugging feature. +5. **Scrub `MOAT_*` beyond `MOAT_INIT_FILES`** → *parity-only now*; broader scrub is a separable hardening follow-up. + +## Deferred / Open Questions + +### From 2026-07-06 ce-doc-review + +Scope decisions surfaced by review that tension with the stated "one PR" and are left for the operator to decide (not resolved here): + +- **Scope `--plan` out of the parity PR?** (scope-guardian, P1) — `--plan` is a net-new, permanently-documented CLI feature (open decision #4) landing inside a "parity" rewrite: a new code path, a golden-file layer, and `reference/01-cli.md` docs that reviewers must validate alongside the 144-item catalog. Option: wire an internal `moatinit.DryRun()` for the parity harness now, and ship the documented `--plan` flag in a follow-up once the Go entrypoint is the default. Trade-off: keeps the parity PR focused vs. one extra PR. +- **~~Split the native `gosu`/`socat`/`tar` replacements into a follow-up PR-B?~~ (RESOLVED — moot).** The scope-guardian correctly observed that testability only needs the shell **logic** ported, not the external binaries dropped — and that is now the plan's direction: `gosu`/`socat`/`tar` stay as targeted subprocesses (Open Decisions #1 and #2), so there are no risky native replacements to split out. This collapses to a **single-PR logic extraction**: env parsing, branch/mode selection, ordering, error classification, arg/config assembly, and exclude computation move into Go and are unit-tested via the `Sys` seam, while the mechanical, security-sensitive, or well-understood system ops are delegated to the audited tools. The former native-replacement P0/P1 risks (privilege-drop parity, socat bridge reimplementation, tar removal) no longer exist. The separate `--plan` scoping question below is unaffected. + +Related smaller gaps to resolve during implementation (from review residual concerns): + +- **Pre-run hook privilege path** — RESOLVED: the `root → gosu → sh -c` pre-run hook path (`EXEC-03`) **keeps shelling to `gosu`** (`gosu moatuser sh -c "cd /workspace && $MOAT_PRE_RUN"`), consistent with the final privilege drop. Go owns only the branch selection (root+moatuser → gosu path; the other EXEC-04 cases), not a native re-drop. +- **`base64` strictness** — Go's `encoding/base64` `StdEncoding` rejects the embedded-newline/76-col wrapping coreutils `base64 -d` tolerates; if any provider emits wrapped `MOAT_INIT_FILES` payloads, the Go port fails-closed where the shell succeeded. Confirm producers never wrap, or use a whitespace-stripping decoder. (This is a legitimate in-process move: base64 decode is pure logic doing no real system work, so a Go stdlib decoder is a cleaner, more-testable replacement of the shell util — unlike `gosu`/`socat`/`tar`, which stay targeted subprocesses. The only obligation is decode-tolerance parity.) +- **Reproducible-blob checksum pins the Go toolchain** — a Go upgrade changes the `-trimpath` bytes and fails the checksum test until regenerated; note the regeneration step in `§5`. + +--- + +## Appendix A — Full requirements catalog (144) + +Extracted line-by-line from `internal/deps/scripts/moat-init.sh`. `Mode`: fatal / best-eff(ort) / warn / info. `Lvl`: U=unit, I=integration, E=e2e. + + +### HOSTS — /etc/hosts synthetic entries (MOAT_EXTRA_HOSTS) (15 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `HOSTS-01` | info | I | The entire /etc/hosts injection block is gated on MOAT_EXTRA_HOSTS being non-empty (`if [ -n "$MOAT_EXTRA_HOSTS" ]`). When the var is unset or empty string, the block is a complet… | With MOAT_EXTRA_HOSTS unset, running the block leaves /etc/hosts byte-identical and returns success. With MOAT_EXTRA_HOSTS='' same. With MOAT_EXTRA_HOSTS=' ' (spaces onl… | +| `HOSTS-02` | info | U | Entries are separated by whitespace via shell word-splitting on the unquoted `$MOAT_EXTRA_HOSTS` in the for-loop. Default IFS means splitting occurs on spaces, tabs, AND newlines,… | MOAT_EXTRA_HOSTS='a:1 b:2' (double space) yields exactly two processed entries a->1, b->2 (no empty third). MOAT_EXTRA_HOSTS with tab/newline separators splits identical… | +| `HOSTS-03` | info | U | For each entry, name is the prefix before the FIRST ':' (`name=${entry%%:*}`, non-greedy-from-right removes the longest '*:*' suffix i.e. everything from the first colon), and tar… | Parsing 'x:a:b' gives name='x', target='a:b'. Parsing 'x:' gives name='x', target=''. Parsing ':y' gives name='', target='y'. Parsing 'z' (no colon) gives name==target==… | +| `HOSTS-04` | info | U | An entry is skipped (`continue`) when name is empty OR target is empty OR name equals target. This drops malformed entries ('name:', ':target'), colon-less tokens (name==target), … | Entries 'moat-proxy:', ':1.2.3.4', 'foo', 'x:x' each leave /etc/hosts unchanged with success. Entry 'moat-proxy:@' is NOT skipped here (target '@' non-empty) — it advanc… | +| `HOSTS-05` | info | U | Target discrimination via `case "$target" in @*) ... ;; *) ip=$target ;;`: a target beginning with '@' is treated as a hostname to resolve; any other target is taken as a literal … | target='192.168.64.1' -> ip set to '192.168.64.1' with no getent call. target='@foo' enters resolve branch. target='a@b' treated as literal 'a@b'. target='::1' written v… | +| `HOSTS-06` | info | I | For '@'-prefixed targets, hostname is derived by stripping the leading '@' (`hostname=${target#@}`). Resolution loops up to MOAT_DNS_WAIT_ITERS (25) times. Each iteration first tr… | Against a tmp resolver where host.docker.internal has A=192.0.2.10 and AAAA=::1, the resolved ip is 192.0.2.10 (IPv4 preferred, NOT ::1). Against a name with ONLY an A r… | +| `HOSTS-07` | info | I | getent stderr is discarded (`2>/dev/null`) on BOTH the ahostsv4 and hosts calls, so resolver errors (NXDOMAIN, SERVFAIL, getent not found) are invisible — they simply produce an e… | When resolution never succeeds, no getent stderr text leaks to the container's stderr; the only user-visible output is the final fail-closed error block (HOSTS-08). A tr… | +| `HOSTS-08` | **fatal** | I | FAIL-CLOSED on unresolved hostname: after the retry loop, if ip is still empty, print a three-line error to stderr and `exit 1`. Exact wording is the contract: line1 `Error: moat-… | With MOAT_EXTRA_HOSTS='moat-proxy:@nope.invalid moat-host:@nope.invalid' and a resolver that returns nothing, the block exits 1 within ~5s, writes EXACTLY the three line… | +| `HOSTS-09` | info | I | Appending the entry: `printf '%s %s\n' "$ip" "$name" >> /etc/hosts 2>/dev/null`. Note the ORDER is ` ` (IP first, hostname second — standard /etc/hosts format), appended… | MOAT_EXTRA_HOSTS='moat-proxy:192.0.2.5 moat-host:192.0.2.5' against a writable tmp /etc/hosts appends exactly two lines '192.0.2.5 moat-proxy\n' and '192.0.2.5 moat-host… | +| `HOSTS-10` | **fatal** | I | FAIL-CLOSED on /etc/hosts write failure: the append is wrapped in `if ! printf ... >> /etc/hosts 2>/dev/null; then` so a failed redirection/write (e.g. permission denied because t… | With /etc/hosts made unwritable to the current (non-root) user and MOAT_EXTRA_HOSTS='moat-proxy:192.0.2.5 ...', the block exits 1 and writes EXACTLY the three lines with… | +| `HOSTS-11` | info | I | ORDERING: this block is the FIRST feature block in moat-init.sh (immediately after the constants), running before SSH-agent bridge, Claude/Codex/Gemini setup, provider init files,… | In an integration harness that records the sequence of moat-init phases, the /etc/hosts synthetic-entries phase completes before git-config, before pre_run, and before t… | +| `HOSTS-12` | info | U | `set -e` is active script-wide (line 11). Within this block, ordinary command failures that would otherwise abort under set -e are individually neutralized: getent stderr is redir… | A failing getent does not abort the script (loop continues); the only non-zero exits observable are exactly the two exit-1 paths (HOSTS-08, HOSTS-10). Introducing a spur… | +| `HOSTS-13` | info | U | Loop-variable hygiene / independence between entries: `name`, `target`, `hostname`, `ip`, `candidate`, `i` are reassigned per entry, and `ip=""` / `i=0` are reset at the start of … | MOAT_EXTRA_HOSTS='a:@hostA b:@hostB' where hostA->10.0.0.1 and hostB->10.0.0.2 writes '10.0.0.1 a' and '10.0.0.2 b' (no cross-contamination: b does not get 10.0.0.1). A … | +| `HOSTS-14` | info | I | Retry timing/budget: each unresolved iteration does `sleep 0.2` then increments i; the loop condition is `[ "$i" -lt 25 ]`, giving at most 25 sleeps of 0.2s ~= 5s total before fai… | A name that never resolves causes ~25 lookup attempts spaced ~200ms apart (total ~5s) before exit 1. A name resolvable immediately incurs no measurable delay. Timing ass… | +| `HOSTS-15` | info | U | The value written for the resolve branch comes exclusively from getent field 1 of the FIRST matching line (`awk '{print $1; exit}'`). getent ahostsv4 output has the ADDRESS in col… | For a name whose getent ahostsv4 yields lines like '192.0.2.10 STREAM host', the extracted candidate is '192.0.2.10' (column 1), not 'host'. For multiple addresses, the … | + +### SSH — SSH agent bridge (MOAT_SSH_TCP_ADDR / socat) (13 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `SSH-01` | info | I | The entire SSH agent bridge block executes if and only if MOAT_SSH_TCP_ADDR is a non-empty string. Empty string and unset are treated identically (guard is `[ -n "$MOAT_SSH_TCP_AD… | Run entrypoint with MOAT_SSH_TCP_ADDR unset and with ="": assert /run/moat/ssh does not exist, no socat process spawned, no SSH warning on stderr. Then with a valid valu… | +| `SSH-02` | best-eff | I | Create the socket directory /run/moat/ssh recursively (`mkdir -p`). Errors are swallowed (`2>/dev/null \|\| true`): a failure to create the dir is NOT fatal and produces no output… | As root against a tmproot: assert /run/moat/ssh is created. As a mkdir-failing condition (read-only parent): assert mkdir failure does not abort the script (exit stays 0… | +| `SSH-03` | best-eff | I | All socket-setup steps after mkdir are nested under `if [ -d /run/moat/ssh ]`. If the directory does not exist (mkdir failed), chmod/chown/socat/wait/warnings are ALL skipped — no… | Force mkdir to fail (parent read-only) so /run/moat/ssh is absent; run entrypoint; assert no socat process, no 'SSH agent bridge' warning, exit 0. | +| `SSH-04` | best-eff | I | chmod 0755 on /run/moat/ssh so moatuser (a different UID) can traverse/access the directory. Best-effort: `2>/dev/null \|\| true`, non-fatal, silent on failure. | Create /run/moat/ssh with mode 0700, run region, assert final mode is 0755. Then simulate chmod failure and assert script continues without stderr. | +| `SSH-05` | best-eff | I | If a user named `moatuser` exists (`id moatuser` succeeds, output suppressed), chown /run/moat/ssh to moatuser:moatuser. Best-effort, silent, non-fatal. If moatuser does NOT exist… | Case A: moatuser exists -> assert dir owned by moatuser after region (when run as root). Case B: no moatuser -> assert no chown attempted and no error; dir ownership unc… | +| `SSH-06` | best-eff | E | Start socat in the BACKGROUND bridging a forking Unix listener to the host TCP address: `socat UNIX-LISTEN:/run/moat/ssh/agent.sock,fork,mode=0660 TCP:"$MOAT_SSH_TCP_ADDR" &`. Cap… | e2e: with a real host TCP listener, run the container; assert a socat child process exists, /run/moat/ssh/agent.sock is a socket with mode 0660, and two concurrent conne… | +| `SSH-07` | best-eff | U | After spawning socat, poll for the socket file to appear: loop up to SSH_SOCKET_WAIT_ITERS (20) times, breaking as soon as `[ -S /run/moat/ssh/agent.sock ]` is true, sleeping 0.1s… | unit: pure-logic port of the wait loop — with a stub that reports the socket present on the Nth poll, assert it breaks after N iterations and never exceeds 20. integrati… | +| `SSH-08` | warn | I | After the wait loop, if socat is no longer running (`kill -0 "$SOCAT_PID"` fails), print exactly `Warning: SSH agent bridge (socat) failed to start` to stderr. This is a WARNING o… | integration: point MOAT_SSH_TCP_ADDR at a closed/invalid port so socat dies; assert stderr contains the exact line 'Warning: SSH agent bridge (socat) failed to start' an… | +| `SSH-09` | warn | I | Else-if socat is still alive but the socket was NOT created (`[ ! -S /run/moat/ssh/agent.sock ]`), print exactly `Warning: SSH agent socket was not created after 2s` to stderr. Wa… | integration: simulate socat alive but socket never appearing (e.g. stub a long-lived process and prevent socket creation); assert stderr contains exactly 'Warning: SSH a… | +| `SSH-10` | best-eff | I | Else (socat alive AND socket exists = success), if moatuser exists, chown the socket file /run/moat/ssh/agent.sock to moatuser:moatuser. Best-effort (`2>/dev/null \|\| true`), sil… | integration/e2e: on the success path as root with moatuser present, assert /run/moat/ssh/agent.sock is owned by moatuser:moatuser (mode still 0660) and nothing is printe… | +| `SSH-11` | best-eff | E | The socat bridge process MUST outlive the entrypoint. The entrypoint's final action is `exec "$@"` or `exec gosu moatuser "$@"` (lines 595/598), which REPLACES the entrypoint proc… | e2e: start a container whose user command sleeps, then AFTER the user command is running, connect to /run/moat/ssh/agent.sock from inside the container (e.g. `SSH_AUTH_S… | +| `SSH-12` | warn | I | The entire region is non-fatal end-to-end: under `set -e` (line 11) every operation is either backgrounded (socat), guarded by `\|\| true`, wrapped in a tested conditional, or a w… | integration: force each sub-step to fail in turn (unwritable /run, missing socat, bad TCP addr) and assert in every case the entrypoint proceeds to exec the user command… | +| `SSH-13` | best-eff | U | socat is a build-time dependency installed into the image only when SSH grants are present. dockerfile.go appends `openssh-client` and `socat` to apt packages when opts.NeedsSSH. … | unit: BuildDockerfile with NeedsSSH=true -> Dockerfile contains 'socat' and 'openssh-client'; with NeedsSSH=false -> does not add them (companion case). socat stays a targeted subprocess (Open Decision #1), so the apt-append behavior is unchanged. | + +### AGENT — Claude / Codex / Gemini config staging (32 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `AGENT-CLAUDE-GATE` | info | I | The entire Claude staging block executes only when MOAT_CLAUDE_INIT is a non-empty string AND names an existing directory. Both conditions are required (`[ -n "$MOAT_CLAUDE_INIT" … | Run the block with MOAT_CLAUDE_INIT unset: assert $TARGET_HOME/.claude is NOT created. Companion: set MOAT_CLAUDE_INIT=: assert .claude IS created. Compani… | +| `AGENT-CLAUDE-TARGETHOME` | **fatal** | I | TARGET_HOME is recomputed inside the Claude block: if `id -u` == 0 AND `id moatuser` succeeds, TARGET_HOME=/home/moatuser (hardcoded literal, NOT moatuser's actual passwd home); o… | As uid 0 with a moatuser account: assert files land under /home/moatuser/.claude. As uid!=0 with HOME=/tmp/h: assert files land under /tmp/h/.claude. As uid 0 WITHOUT mo… | +| `AGENT-CLAUDE-MKDIR` | **fatal** | I | Creates $TARGET_HOME/.claude with `mkdir -p` (no explicit mode; honors umask, effectively 0755 under the container default). This is unguarded under `set -e`, so a mkdir failure i… | Point TARGET_HOME at a writable tmproot: assert .claude exists after. Pre-create .claude: assert still succeeds (idempotent). Make TARGET_HOME read-only (as non-root): a… | +| `AGENT-CLAUDE-CP-SETTINGS` | best-eff | I | If $MOAT_CLAUDE_INIT/settings.json exists (regular file), copy it to $TARGET_HOME/.claude/ with `cp -p` (preserve mode/ownership/timestamps). Guarded by `[ -f ... ] && cp -p ...`.… | Stage a settings.json: assert it appears at .claude/settings.json with identical bytes and preserved mode. Companion: omit settings.json: assert no settings.json in .cla… | +| `AGENT-CLAUDE-NO-PLUGINS-COPY` | info | I | Plugins are NOT copied by this block. They are baked into the image at build time via `claude plugin install` in the Dockerfile; settings.json (copied above) supplies the marketpl… | Place a bogus plugins/ dir and a stray file in the staging dir: assert neither is copied into .claude (only the named allowlist files are). Assert the script text contai… | +| `AGENT-CLAUDE-CP-CREDENTIALS` | **fatal** | I | If $MOAT_CLAUDE_INIT/.credentials.json exists, `cp -p` it to $TARGET_HOME/.claude/ and then EXPLICITLY `chmod 600` the destination. The explicit chmod exists because cp -p preserv… | Stage a .credentials.json mode 0644: assert dest is .claude/.credentials.json with mode exactly 0600 and identical bytes. Companion: omit it: assert absent and init exit… | +| `AGENT-CLAUDE-CP-REMOTE-SETTINGS` | **fatal** | I | If $MOAT_CLAUDE_INIT/remote-settings.json exists, `cp -p` it to $TARGET_HOME/.claude/ then EXPLICITLY `chmod 600`. This is the server-managed-settings cache (staged by claude/agen… | Stage remote-settings.json: assert dest .claude/remote-settings.json exists, bytes match, mode==0600. Companion: omit: assert absent + exit 0. Security: mode&0o077==0. | +| `AGENT-CLAUDE-CP-STATSIG` | best-eff | I | If $MOAT_CLAUDE_INIT/statsig exists as a DIRECTORY (`[ -d ]`), recursively copy it with `cp -rp` (recursive + preserve) into $TARGET_HOME/.claude/. Guarded by `[ -d ... ] && cp -r… | Stage a statsig/ dir with a nested file: assert .claude/statsig/ exists with preserved perms. Companion: omit statsig: assert absent + exit 0. Companion: make 'sta… | +| `AGENT-CLAUDE-CP-STATSCACHE` | best-eff | I | If $MOAT_CLAUDE_INIT/stats-cache.json exists (regular file), `cp -p` it into $TARGET_HOME/.claude/. Usage-stats cache. `[ -f ] && cp -p` form. Like statsig, not currently staged b… | Stage stats-cache.json: assert copied to .claude/ with preserved mode. Companion: omit: assert absent + exit 0. | +| `AGENT-CLAUDE-CP-CLAUDEMD` | best-eff | I | If $MOAT_CLAUDE_INIT/CLAUDE.md exists (regular file), `cp -p` it into $TARGET_HOME/.claude/. This is the rendered runtime-context file (agent.go writes it 0644 when opts.RuntimeCo… | Stage CLAUDE.md with known bytes: assert .claude/CLAUDE.md matches. Companion: omit: assert absent + exit 0. Assert it is NOT placed at $TARGET_HOME/CLAUDE.md. | +| `AGENT-CLAUDE-CP-CLAUDEJSON-ROOT` | best-eff | I | If $MOAT_CLAUDE_INIT/.claude.json exists (regular file), `cp -p` it to $TARGET_HOME/ (the HOME ROOT), NOT into .claude/. This is the onboarding/trust-state config (WriteClaudeConf… | Stage .claude.json: assert it appears at $TARGET_HOME/.claude.json (home root) and NOT at $TARGET_HOME/.claude/.claude.json. Bytes match, mode 0644. Companion: omit: ass… | +| `AGENT-CLAUDE-CHOWN-DIR` | best-eff | E | After all Claude copies, if uid==0 AND moatuser exists, recursively `chown -R moatuser:moatuser $TARGET_HOME/.claude` with `2>/dev/null \|\| true` (BEST-EFFORT: chown failure is s… | As uid 0 with moatuser: after copies, assert every node under .claude is owned by moatuser:moatuser. Companion: as uid!=0: assert NO chown attempted (ownership unchanged… | +| `AGENT-CLAUDE-CHOWN-JSON` | best-eff | E | Still inside the uid==0 && moatuser guard: if $TARGET_HOME/.claude.json exists, `chown moatuser:moatuser` it (non-recursive) with `2>/dev/null \|\| true` (best-effort). Separate f… | As uid 0 with moatuser and a staged .claude.json: assert $TARGET_HOME/.claude.json owned by moatuser:moatuser. Companion: no .claude.json present: assert no error, exit … | +| `AGENT-CODEX-GATE` | info | I | The Codex staging block executes only when MOAT_CODEX_INIT is non-empty AND a directory (`[ -n ] && [ -d ]`). Independent of the Claude block; both can be evaluated in one run in … | MOAT_CODEX_INIT unset: assert .codex not created. Set to empty tmpdir: assert .codex created. Set to a file: assert skip. | +| `AGENT-CODEX-TARGETHOME` | **fatal** | I | TARGET_HOME recomputed identically inside the Codex block: uid==0 && moatuser -> /home/moatuser else $HOME. Repeated verbatim (the variable is reused, so this reassignment matters… | Same matrix as AGENT-CLAUDE-TARGETHOME but for .codex destination. | +| `AGENT-CODEX-MKDIR` | **fatal** | I | `mkdir -p $TARGET_HOME/.codex` — unguarded, fatal on failure, idempotent, umask mode (~0755). | Assert .codex exists after; idempotent on re-run; fatal when dir unwritable. | +| `AGENT-CODEX-CP-CONFIG` | best-eff | I | If $MOAT_CODEX_INIT/config.toml exists, `cp -p` into $TARGET_HOME/.codex/. `[ -f ] && cp -p` form. This is the Codex CLI config written by WriteCodexConfig. Not a secret, mode pre… | Stage config.toml: assert .codex/config.toml matches bytes+mode. Companion: omit: assert absent + exit 0. | +| `AGENT-CODEX-CP-AUTH` | **fatal** | I | If $MOAT_CODEX_INIT/auth.json exists, `cp -p` into $TARGET_HOME/.codex/ then EXPLICITLY `chmod 600`. Security-contract file (holds the placeholder OPENAI_API_KEY; real key injecte… | Stage auth.json (0644): assert .codex/auth.json mode==0600, bytes match. Companion: omit: assert absent + exit 0. Security: mode&0o077==0. | +| `AGENT-CODEX-CP-AGENTSMD` | best-eff | I | If $MOAT_CODEX_INIT/AGENTS.md exists, `cp -p` into $TARGET_HOME/.codex/. Runtime-context file (agent.go writes it 0644 when RuntimeContext set). Lands in .codex/, not workspace. N… | Stage AGENTS.md: assert .codex/AGENTS.md matches. Companion: omit: assert absent + exit 0. | +| `AGENT-CODEX-CHOWN-DIR` | best-eff | E | If uid==0 && moatuser exists, `chown -R moatuser:moatuser $TARGET_HOME/.codex 2>/dev/null \|\| true` (best-effort recursive). No separate root-level file to chown for Codex (unlik… | As uid 0 + moatuser: assert all .codex nodes owned by moatuser:moatuser. As uid!=0: assert not chowned. Paranoid: chown error -> still exit 0. | +| `AGENT-GEMINI-GATE` | info | I | The Gemini staging block executes only when MOAT_GEMINI_INIT is non-empty AND a directory (`[ -n ] && [ -d ]`). Independent branch. | MOAT_GEMINI_INIT unset: .gemini not created. Empty tmpdir: created. File path: skip. | +| `AGENT-GEMINI-TARGETHOME` | **fatal** | I | TARGET_HOME recomputed identically: uid==0 && moatuser -> /home/moatuser else $HOME. | Same matrix targeting .gemini. | +| `AGENT-GEMINI-MKDIR` | **fatal** | I | `mkdir -p $TARGET_HOME/.gemini` — unguarded, fatal on failure, idempotent, umask mode. | Assert .gemini exists; idempotent; fatal when unwritable. | +| `AGENT-GEMINI-CP-SETTINGS` | best-eff | I | If $MOAT_GEMINI_INIT/settings.json exists, `cp -p` into $TARGET_HOME/.gemini/. `[ -f ] && cp -p`. Gemini settings.json (auth selectedType), staged 0600 by writeSettings — but this… | Stage settings.json (mode 0640): assert .gemini/settings.json preserves that source mode (NO forced 0600). Companion: omit: assert absent + exit 0. | +| `AGENT-GEMINI-CP-OAUTHCREDS` | **fatal** | I | If $MOAT_GEMINI_INIT/oauth_creds.json exists, `cp -p` into $TARGET_HOME/.gemini/ then EXPLICITLY `chmod 600`. Security-contract file (placeholder access/refresh tokens; proxy inje… | Stage oauth_creds.json (0644): assert .gemini/oauth_creds.json mode==0600, bytes match. Companion: omit (API-key mode): assert absent + exit 0. Security: mode&0o077==0. | +| `AGENT-GEMINI-CP-GEMINIMD` | best-eff | I | If $MOAT_GEMINI_INIT/GEMINI.md exists, `cp -p` into $TARGET_HOME/.gemini/. Runtime-context file (agent.go writes 0644 when RuntimeContext set). Lands in .gemini/. Non-secret, mode… | Stage GEMINI.md: assert .gemini/GEMINI.md matches. Companion: omit: assert absent + exit 0. | +| `AGENT-GEMINI-CHOWN-DIR` | best-eff | E | If uid==0 && moatuser exists, `chown -R moatuser:moatuser $TARGET_HOME/.gemini 2>/dev/null \|\| true` (best-effort recursive). No home-root file for Gemini, so this single recursi… | As uid 0 + moatuser: assert .gemini nodes owned by moatuser:moatuser. As uid!=0: not chowned. Paranoid: chown error -> exit 0. | +| `AGENT-CP-PRESERVE-MODE-CONTRACT` | **fatal** | I | Every copy uses `cp -p` (files) or `cp -rp` (statsig dir) — mode, ownership, and timestamps are preserved from source. Because cp -p preserves the SOURCE mode, the four security f… | For each non-secret file, stage with mode 0640 and assert dest mode==0640 (preserved). For each of the four secret files, stage with mode 0644 and assert dest==0600 (for… | +| `AGENT-SET-E-COMPOUND-SEMANTICS` | **fatal** | U | The script runs under `set -e` (line 11). The `[ -f X ] && cp ...` idiom means: a FALSE test (missing file) short-circuits and the line is a benign no-op that does NOT abort (test… | Simulate: (a) missing optional file -> init exits 0, file absent; (b) present settings.json whose dest is unwritable -> init exits non-zero; (c) chown failure -> init ex… | +| `AGENT-BLOCK-INDEPENDENCE-ORDER` | info | I | The three agent blocks run in a fixed order (Claude, then Codex, then Gemini) and are mutually independent — each guarded solely by its own MOAT_*_INIT var. TARGET_HOME is a share… | With only MOAT_CODEX_INIT set: assert .codex populated, .claude and .gemini absent. With none set: assert none created. With two set: assert both populated independently. | +| `AGENT-ROOT-MOATUSER-INVARIANT` | best-eff | E | The uid==0 + moatuser check appears in every TARGET_HOME computation and every chown step, and mirrors the final privilege-drop contract (lines 596-611): when root, the container … | Matrix over {uid 0/nonzero} x {moatuser present/absent}: assert TARGET_HOME and whether chown runs match the four expected outcomes. Confirm root+no-moatuser stages to $… | +| `AGENT-NO-EXTRANEOUS-COPY` | info | I | Across all three blocks, ONLY the explicitly-named files/dirs are copied — an allowlist, never a recursive copy of the staging dir. Claude: settings.json, .credentials.json, remot… | Populate each staging dir with its allowlist files PLUS an extra 'stray.txt' and an 'mcp.json': assert the allowlist files are copied and stray.txt + mcp.json are NOT pr… | + +### INIT — Provider init files (MOAT_INIT_FILES) + workspace .mcp.json (14 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `INIT-01` | info | U | The entire provider-init-files block is skipped when MOAT_INIT_FILES is empty/unset. Trigger check is `[ -n "$MOAT_INIT_FILES" ]` — an empty string means no work, no ownership res… | With MOAT_INIT_FILES unset, run the block: no files created, no chown attempts, and (trivially) the var stays unset. With MOAT_INIT_FILES='' identical. Assert the block … | +| `INIT-02` | best-eff | U | Ownership target resolution: if `id -u` == 0 AND `id moatuser` succeeds, set INIT_OWNER='moatuser:moatuser' and INIT_HOME='/home/moatuser'; otherwise INIT_OWNER='' and INIT_HOME="… | Table test the four branches: assert (uid=0,moatuser=yes)→owner='moatuser:moatuser',home='/home/moatuser'; (uid=0,moatuser=no)→owner='',home=$HOME; (uid!=0,moatuser=yes)… | +| `INIT-03` | best-eff | U | Records are parsed by piping the raw MOAT_INIT_FILES via `printf '%s\n'` into a `while IFS= read -r filepath content` loop. IFS is a literal TAB (from printf '\t'); -r disabl… | Feed 'a\tYWJj\nb\tZГ...' style records; assert filepath/content split on FIRST tab only and remainder (incl. extra tabs) lands in content. Assert a line with no tab yiel… | +| `INIT-04` | info | U | Per-record: if filepath is empty, skip that record and continue (`[ -z "$filepath" ] && continue`). This is what makes the trailing-newline empty line harmless. | Include an empty-path record (leading TAB) and a trailing empty line; assert NO file is written for them and no error. Assert non-empty-path records around them still pr… | +| `INIT-05` | **fatal** | I | Per-record directory creation: dir=$(dirname "$filepath"); `mkdir -p "$dir" && chmod 755 "$dir"`. mkdir -p creates the full parent chain; chmod 755 is applied to the immediate par… | Integration against a tmproot: give path /a/b/c/file; assert a,b,c created and that /a/b/c (the immediate parent) is mode 0755. Assert that mkdir/chmod failu… | +| `INIT-06` | **fatal** | I | Per-record content write: `printf '%s' "$content" \| base64 -d > "$filepath"`. The base64 payload is StdEncoding-decoded and written (truncating) to filepath. printf '%s' emits co… | Integration: encode a known secret string, feed it as content, assert the decoded file bytes == original exactly (no trailing newline added, binary-safe). Companion: emp… | +| `INIT-07` | **fatal** | I | Per-record file permission: `chmod 600 "$filepath"` after the write. The file is always mode 0600 (owner rw only) regardless of umask or prior perms. Runs under set -e (fatal on f… | Integration: after write, assert stat mode == 0600. Companion: pre-existing 0644 file overwritten still ends 0600. Assert this happens for EVERY record (secret files mus… | +| `INIT-08` | best-eff | I | Per-record ownership fixup (ONLY when INIT_OWNER non-empty, i.e. root+moatuser): (1) `chown "$INIT_OWNER" "$filepath" 2>/dev/null \|\| true` — chown the file; (2) walk parent dirs… | Integration under a fake root harness (or unit for the walk logic): with INIT_OWNER set and INIT_HOME=/home/moatuser, path /home/moatuser/.config/graphite/user_config → … | +| `INIT-09` | info | I | When INIT_OWNER is empty (non-root, OR root without moatuser), NO chown of any kind happens for init files — files are left owned by the writing process (root or the current non-r… | Run block as non-root (INIT_OWNER=''): assert files created + 0600 but ownership unchanged (still current user) and NO chown syscall attempted. Companion to INIT-08's po… | +| `INIT-10` | **fatal** | E | After the loop completes, `unset MOAT_INIT_FILES` removes the variable so its (potentially secret, base64) content is NOT inherited by the child process spawned via `exec`/`exec g… | E2E/integration: after the init-files block, assert MOAT_INIT_FILES is absent from the environment handed to the exec'd command (e.g. `env \| grep -c MOAT_INIT_FILES` ==… | +| `INIT-11` | best-eff | I | setup_workspace_mcp_json is DEFINED as a shell function (not run inline) in the init-files region, and INVOKED LATE at line 591 — AFTER populate_workspace_volume (line 590) and BE… | Assert (source-level, like TestMoatInitScriptVolumePopulate) that the index of the setup_workspace_mcp_json CALL is AFTER the populate_workspace_volume call and BEFORE r… | +| `INIT-12` | best-eff | I | setup_workspace_mcp_json Codex branch: if MOAT_CODEX_INIT non-empty AND $MOAT_CODEX_INIT/mcp.json exists as a file, `cp -p "$MOAT_CODEX_INIT/mcp.json" /workspace/.mcp.json` (prese… | Integration: stage a codex mcp.json, set MOAT_CODEX_INIT, run function; assert /workspace/.mcp.json exists with identical bytes and preserved mode (0644). Companion: no … | +| `INIT-13` | best-eff | I | setup_workspace_mcp_json Gemini branch: mirror of INIT-12 for MOAT_GEMINI_INIT — if set AND $MOAT_GEMINI_INIT/mcp.json is a file, cp -p to /workspace/.mcp.json, then best-effort c… | Integration: stage a gemini mcp.json, set MOAT_GEMINI_INIT, run function; assert /workspace/.mcp.json is the gemini content, mode preserved. Companion: assert config loa… | +| `INIT-14` | **fatal** | U | Cross-cutting: the init-files block runs INLINE at its position in the script (before setup_workspace_mcp_json is even defined executes, and well before populate/exec), whereas se… | Source-level ordering assertion: index(MOAT_INIT_FILES block) < index(setup_workspace_mcp_json definition) is trivially true; the load-bearing one is index(populate_work… | + +### GIT — Clipboard (Xvfb) + Git configuration (10 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `GIT-CLIP-01` | best-eff | I | When MOAT_CLIPBOARD equals the exact string "1", start a headless X server with the exact command `Xvfb :99 -screen 0 1x1x8` in the background (stdout+stderr redirected to /dev/nu… | Integration: run the branch with MOAT_CLIPBOARD=1 in a sandbox; assert a process matching `Xvfb :99 -screen 0 1x1x8` is spawned detached (survives the entrypoint process… | +| `GIT-CLIP-02` | info | I | DISPLAY=:99 is exported unconditionally within the clipboard branch even if Xvfb fails to start, and it is NOT unset afterward — it persists into the exec'd user command. | Integration: with MOAT_CLIPBOARD=1 but Xvfb removed from PATH, assert DISPLAY=:99 is still in the exec'd command's environment (export happens regardless of Xvfb success… | +| `GIT-01` | best-eff | I | The entire git-configuration block executes ONLY if a `git` binary is on PATH, gated by `command -v git >/dev/null 2>&1`. If git is absent, none of the git config commands run (no… | Integration: with git absent from PATH, assert /etc/gitconfig is not created/modified and no git output appears. With git present and no MOAT_GIT_* vars, assert safe.dir… | +| `GIT-02` | best-eff | I | Unconditionally (when git present) run `git config --system --add safe.directory /workspace`, suppressing stderr and never failing (`2>/dev/null \|\| true`). Whitelists /workspace… | Integration (tmproot as root): after running the block, `git config --system --get-all safe.directory` includes `/workspace`. Assert `--add` (idempotent append) and `--s… | +| `GIT-03` | best-eff | I | When MOAT_GIT_USER_NAME is non-empty, run `git config --system user.name "$MOAT_GIT_USER_NAME"` (value quoted, best-effort `2>/dev/null \|\| true`). Sets the container's system-le… | Integration: set MOAT_GIT_USER_NAME="Ada Lovelace" as root, run block, assert `git config --system user.name` == "Ada Lovelace" (spaces preserved). Companion: unset it a… | +| `GIT-04` | best-eff | I | When MOAT_GIT_USER_EMAIL is non-empty, run `git config --system user.email "$MOAT_GIT_USER_EMAIL"` (quoted, best-effort). Independent guard from user.name — either may be set with… | Integration: set only MOAT_GIT_USER_EMAIL (leave NAME unset), assert user.email written and user.name NOT written — proving independence. Companion: unset email, assert … | +| `GIT-05` | best-eff | I | Unconditionally (git present) run `git config --system http.proxyAuthMethod basic` (best-effort `2>/dev/null \|\| true`). Preemptively authenticates git to the moat proxy with Bas… | Integration: run block as root, assert `git config --system http.proxyAuthMethod` == "basic". Also assert it is set even with no MOAT_GIT_* env (unconditional). Unit/str… | +| `GIT-06` | best-eff | I | When MOAT_GIT_SSH_GITHUB equals the exact string "1", run `git config --system url."git@github.com:".insteadOf "https://github.com/"` (best-effort). Rewrites all HTTPS github.com … | Integration: with MOAT_GIT_SSH_GITHUB=1 as root, assert `git config --system --get url."git@github.com:".insteadOf` == "https://github.com/". Companion cases: =0 => inst… | +| `GIT-07` | best-eff | I | Cross-cutting: every git config command in this region is --system scope and best-effort — each is suffixed `2>/dev/null \|\| true` so a failure (permission denied, read-only /etc… | Integration: run the entire git block as a NON-root user against a tmproot where /etc/gitconfig is unwritable; assert the block completes with exit 0 and the entrypoint … | +| `GIT-08` | info | U | Ordering & isolation: the clipboard branch runs BEFORE the git block, and the git block runs before Docker/workspace setup. Neither block uses a subshell `cd`, unsets its consumed… | Unit/structural: assert this region contains no `unset`, no `cd`, no `set -f`, no subshell around git config; assert clipboard branch precedes git block in source order.… | + +### DOCKER — Docker access: dind + host-socket modes (14 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `DOCKER-01` | **fatal** | U | If BOTH MOAT_DOCKER_DIND and MOAT_DOCKER_GID are non-empty, abort immediately with exit 1, printing three exact stderr lines: 'Error: MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutu… | Run entrypoint with both vars non-empty; assert exit 1 and stderr contains the three exact lines in order. Companion: with only one set, assert no such error and the pro… | +| `DOCKER-02` | best-eff | U | DIND mode activates ONLY when MOAT_DOCKER_DIND == "1" AND id -u == 0 (root). A non-root process with DIND=1 silently skips dind setup entirely (no dockerd, no error). DIND=0 or DI… | Matrix {DIND=1,0,true,''}×{uid=0,non-0}: assert dockerd start attempted iff DIND=='1' && uid==0. Unit-test the guard predicate; e2e confirms real activation only in the … | +| `DOCKER-03` | best-eff | I | On DIND activation, print 'Starting Docker daemon (dind mode)...' to stderr, then create the run dir with 'mkdir -p /var/run' (idempotent; NO error swallow on this line — under se… | Integration against a tmproot: assert /var/run exists after this step and 'Starting Docker daemon (dind mode)...' was written to stderr. Companion: pre-existing /var/run… | +| `DOCKER-04` | best-eff | E | Start dockerd as a BACKGROUND, DETACHED child: `dockerd --storage-driver=vfs --log-level=warn >/var/log/dockerd.log 2>&1 &`, capturing PID in DOCKERD_PID ($!). Both stdout+stderr … | e2e in a privileged container: after moat-init exec's the user command, assert dockerd still running and /var/run/docker.sock live. Integration: assert launch string is … | +| `DOCKER-05` | best-eff | I | Readiness poll: print 'Waiting for Docker daemon to be ready...' then loop up to DIND_TIMEOUT_SECONDS=30 iterations, sleeping 1s each. Ready requires BOTH: /var/run/docker.sock is… | Integration with a fake: (a) socket present + info rc0 → loop breaks, prints 'Docker daemon is ready (took Ns)'; (b) socket present but info rc!=0 → not ready; (c) info … | +| `DOCKER-06` | **fatal** | I | Inside the poll, each iteration checks dockerd liveness with `kill -0 $DOCKERD_PID`. If dead, abort exit 1 printing 'Error: Docker daemon failed to start', 'Check /var/log/dockerd… | Integration: launch a 'dockerd' stub that exits immediately; assert exit 1, stderr contains 'Error: Docker daemon failed to start' and 'Check /var/log/dockerd.log for de… | +| `DOCKER-07` | **fatal** | I | If the poll completes without readiness (DIND_WAITED >= 30) and dockerd is still alive, abort exit 1 printing 'Error: Docker daemon did not become ready within 30 seconds', a live… | Integration: dockerd stub stays alive but never creates a working socket/info; assert exit 1, stderr contains 'did not become ready within 30 seconds' and a 'Socket exis… | +| `DOCKER-08` | best-eff | I | After dockerd is ready, add moatuser to the docker group so it can use docker without sudo. Runs only if `id moatuser` succeeds. Ensure the group: if `getent group docker` fails, … | Integration with a fake group db (or e2e in real container): moatuser present, no docker group → assert group 'docker' created and moatuser is a member. Companion: moatu… | +| `DOCKER-09` | best-eff | U | HOST mode activates ONLY when ALL of: MOAT_DOCKER_GID non-empty AND uid==0 AND /var/run/docker.sock is a socket ([ -S ]). If GID is set but the socket is missing or the process is… | Matrix {GID set/unset}×{root/non-root}×{socket present/absent}: assert host-mode body runs iff all three true. Unit-test the guard predicate. Note parity: in the non-roo… | +| `DOCKER-10` | warn | I | Detect the docker socket GID as seen INSIDE the container via `SOCKET_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null) \|\| true`. Uses GNU `stat -c` (Linux-only format; accep… | Integration: create a socket/file owned by a known GID under a tmproot; assert detection yields that numeric GID as a string. Companion: point at a nonexistent path → de… | +| `DOCKER-11` | warn | I | If SOCKET_GID detection returned empty, print WARNING (not fatal): 'Warning: Failed to detect docker socket GID, docker access may not work' to stderr and skip the rest of host-mo… | Integration: force empty detection (nonexistent socket after the -S guard passed via a race stub) → assert stderr contains the exact warning and exit code stays 0 for th… | +| `DOCKER-12` | best-eff | I | With a non-empty SOCKET_GID: if no group already has that GID (`getent group $SOCKET_GID` fails), create one named 'moat-docker' at that GID via `groupadd -g $SOCKET_GID moat-dock… | Integration against a fake group db: (a) unused GID → 'moat-docker' created at that gid; (b) GID already owned by 'staff' → no groupadd, 'staff' reused downstream. Unit:… | +| `DOCKER-13` | best-eff | I | Resolve the group NAME owning SOCKET_GID via `DOCKER_GROUP=$(getent group $SOCKET_GID \| cut -d: -f1)`. If DOCKER_GROUP is non-empty AND moatuser exists, add moatuser to that grou… | Integration/e2e: after host-mode setup with a known socket GID, assert moatuser is a supplementary member of the group owning that GID. Companion: moatuser absent → no u… | +| `DOCKER-14` | **fatal** | U | Global ordering/isolation invariant for the region: the mutual-exclusion guard (DOCKER-01) runs first, then AT MOST ONE of {dind block, host block} executes (guarded on distinct, … | Integration: with both-vars-set the process exits at DOCKER-01 and never reaches dind/host bodies; dind-only runs dind not host; host-only vice versa. Ordering test (str… | + +### WS — Workspace volume population (tar staging → /workspace) (13 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `WS-01` | info | U | populate_workspace_volume is a no-op (returns 0, changes nothing) unless MOAT_WORKSPACE_VOLUME equals exactly the string "1". The guard is `[ "${MOAT_WORKSPACE_VOLUME:-}" = "1" ] … | Run the entrypoint (or Go equivalent) with MOAT_WORKSPACE_VOLUME each of {unset, "", "0", "true", " 1", "01"} and assert /workspace is left untouched (no tar, no chown) … | +| `WS-02` | **fatal** | I | Defensive root guard: if the effective UID is not 0, print exactly `moat: populate_workspace_volume must run as root` to stderr and exit 1 (aborts the entrypoint, container start … | Invoke populate with MOAT_WORKSPACE_VOLUME=1 as a non-root user; assert exit code 1 and stderr equals exactly 'moat: populate_workspace_volume must run as root\n'. As ro… | +| `WS-03` | **fatal** | U | Staging source directory defaults to /mnt/host-workspace when MOAT_WORKSPACE_STAGING is unset/empty, otherwise uses the provided value: `staging="${MOAT_WORKSPACE_STAGING:-/mnt/ho… | Unit test the default-resolution logic: unset->/mnt/host-workspace, ""->/mnt/host-workspace, "/custom"->/custom. Integration: point staging at a nonexistent path with VO… | +| `WS-04` | **fatal** | I | Excludes are written to a temp file, never expanded on the command line. exclude_file=/tmp/moat-excludes.$ is truncated/created empty (`: > "$exclude_file"`); if MOAT_WORKSPACE_E… | Integration: set MOAT_WORKSPACE_EXCLUDES to a two-line value, run the copy-in, and confirm the temp exclude file content byte-for-byte equals the env value and that both… | +| `WS-05` | warn | I | Excludes are NEWLINE-delimited and applied in full via `tar --exclude-from="$exclude_file"` (plain, NOT `--null`). This is a deliberate GNU tar 1.34 quirk workaround: GNU tar 1.34… | Integration mirroring TestVolumeCopyInPipeline (internal/deps/moat_init_volume_test.go): stage node_modules/, dist/sub/, dist/keep/, main.go; excludes file = './node_mod… | +| `WS-06` | info | I | Empty/unset excludes copies everything. When MOAT_WORKSPACE_EXCLUDES is unset or empty, an empty exclude file is used and tar --exclude-from an empty file excludes nothing, so the… | Integration mirroring TestVolumeCopyInPipelineEmptyExcludes: stage main.go and README with an empty exclude file; assert both are present in /workspace. This is the requ… | +| `WS-07` | **fatal** | I | The copy is `tar -cf - .` in the staging dir piped to `tar -xf - .` in /workspace, run inside subshells that cd into their respective directories: `( cd "$staging" && tar ... -cf … | Integration: stage a tree with files, nested dirs, and permissions; run the copy; assert /workspace contains an identical tree (same relative paths, same file modes) and… | +| `WS-08` | **fatal** | I | Symlinks are copied AS symlinks, never dereferenced. This is tar's DEFAULT for `-cf`, so out-of-tree/absolute symlink targets are copied as the link node only, not their contents … | Integration mirroring TestVolumeCopyInPipeline invariant 4: create a dangling symlink in staging, run the copy, os.Lstat it in /workspace and assert mode has os.ModeSyml… | +| `WS-09` | **fatal** | I | Both source and destination tar exit codes are captured and checked; either being nonzero is FATAL. Because POSIX sh `$?` after a pipeline reports only the RIGHTMOST command's sta… | Integration: (a) make a staging file unreadable so source tar exits nonzero while dest succeeds; assert overall exit 1 and stderr contains 'moat: failed to populate work… | +| `WS-10` | **fatal** | I | After a successful copy, `chown -R moatuser:moatuser /workspace` runs recursively to hand the fresh (root-owned) volume mountpoint to the agent user. Unlike other chowns in the sc… | Integration (as root, with a moatuser account): after copy, assert every entry under /workspace is owned by moatuser:moatuser, including nested files and the /workspace … | +| `WS-11` | best-eff | I | Temp files are always removed after the copy: `rm -f "$exclude_file" "$src_rc_file"` runs BEFORE the success/failure check, so both the exclude file and the rc file are cleaned up… | Integration: after both a successful and a failing copy, assert /tmp/moat-excludes.* and /tmp/moat-ws-rc.* for that PID no longer exist. Assert no error is raised if a t… | +| `WS-12` | **fatal** | I | Ordering invariant: populate_workspace_volume is invoked (line 590) FIRST among the three trailing steps — before setup_workspace_mcp_json (591), before run_pre_run_hook (592), an… | Unit (script-marker, mirroring TestMoatInitScriptVolumePopulate): assert index('populate_workspace_volume') < index('exec gosu moatuser'). Integration: with VOLUME=1 plu… | +| `WS-13` | warn | U | Excludes carry a './' prefix convention that must match the './'-rooted member names produced by `tar -cf - .`. Go emits each exclude as './'+pattern (workspaceExcludes, volume.go… | Unit test run.workspaceExcludes (volume.go): input ['node_modules','dist/sub'] -> output 'is ./node_modules\n./dist/sub' (each prefixed, newline-joined). Integration (WS… | + +### EXEC — Pre-run hook + named-volume chown + privilege drop + exec (14 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `EXEC-01-PRERUN-GATE` | info | U | run_pre_run_hook returns immediately (no-op, exit 0, entrypoint continues) when MOAT_PRE_RUN is empty or unset. Uses `[ -z "$MOAT_PRE_RUN" ]` — an unset var and an empty-string va… | With MOAT_PRE_RUN unset and again with ="", run_pre_run_hook returns 0 and the entrypoint proceeds to exec (harness prints __CONTINUED__). With MOAT_PRE_RUN=" " the hook… | +| `EXEC-02-PRERUN-NONROOT-SUBSHELL` | **fatal** | I | When `id -u` != 0 (already non-root, e.g. --user passed), the hook runs as `( cd /workspace && sh -c "$MOAT_PRE_RUN" )` in an explicit SUBSHELL. The subshell isolates the hook's `… | Integration: as a non-root uid with MOAT_PRE_RUN='cd /tmp && touch marker', after run_pre_run_hook the entrypoint's cwd is still the original (verify a following `pwd` o… | +| `EXEC-03-PRERUN-ROOT-GOSU` | **fatal** | E | When `id -u` == 0 AND moatuser exists, the hook runs via `gosu moatuser sh -c "cd /workspace && $MOAT_PRE_RUN"`. gosu spawns a SEPARATE process (its own cwd), so no wrapping subsh… | e2e/integration: root entrypoint + moatuser, MOAT_PRE_RUN='id -un > /workspace/who; exit 7'. /workspace/who contains 'moatuser' and run_pre_run_hook reports exit code 7 … | +| `EXEC-04-PRERUN-ROOT-NO-MOATUSER-NOOP` | info | U | When MOAT_PRE_RUN is set, id -u == 0, but moatuser does NOT exist, the hook is silently skipped: hook_status is set to 0 (the else branch) and the entrypoint continues. The hook i… | Unit/integration: root, no moatuser, MOAT_PRE_RUN='touch /should-not-exist'; run_pre_run_hook returns 0 and /should-not-exist was NOT created (hook did not run). No 'pre… | +| `EXEC-05-PRERUN-SETE-DISABLE` | info | U | The hook invocation is wrapped in `set +e` ... `set -e` so a non-zero hook exit does NOT abort the entrypoint under the script's top-level `set -e`. The exit code is captured into… | Unit: a failing hook produces the framed message (not a bare abort) AND the entrypoint's set -e is still active afterward for successful hooks (a subsequent failing comm… | +| `EXEC-06-PRERUN-FAIL-FRAMED-MESSAGE` | **fatal** | U | When hook_status != 0, print a multi-line framed error to stderr, then exit with the hook's status. EXACT wording (each line prefixed as shown), in order: blank line; `moat: pre_r… | Unit: MOAT_PRE_RUN='echo doing-setup; exit 42' → process exit code 42; stdout+stderr contains substring 'pre_run hook failed (exit code 42)', 'command: echo doing-setup;… | +| `EXEC-07-VOLCHOWN-GATE` | best-eff | I | The named-volume chown block runs ONLY when ALL three hold: MOAT_VOLUME_CHOWN non-empty AND `id -u` == 0 AND moatuser exists. Otherwise the whole block is skipped. On the non-root… | Unit/integration matrix: (set,root,moatuser)→chown runs; (unset,root,moatuser)→no chown; (set,nonroot,*)→no chown; (set,root,no-moatuser)→no chown. Assert chown invoked … | +| `EXEC-08-VOLCHOWN-SETF-NOGLOB` | best-eff | I | Inside the chown block, `set -f` DISABLES pathname (glob) expansion for the `for vpath in $MOAT_VOLUME_CHOWN` loop, so a target containing a glob char ([ ] * ?) is treated literal… | Integration against a tmproot: MOAT_VOLUME_CHOWN='/r/[x] /r/normal' with dirs literally named '[x]' and 'normal' plus a decoy '/r/x'; assert both literal dirs are chowne… | +| `EXEC-09-VOLCHOWN-NONRECURSIVE-BESTEFFORT` | best-eff | I | Each path is chowned `chown moatuser:moatuser "$vpath" 2>/dev/null \|\| true` — NON-recursively (no -R) and best-effort (errors swallowed). Non-recursive is deliberate: a fresh vo… | Integration: a volume root containing a large/deep subtree with mixed ownership — after chown, ONLY the root's owner is moatuser; a pre-existing descendant's owner is un… | +| `EXEC-10-FINAL-ORDER` | **fatal** | I | The terminal sequence executes in EXACTLY this order: (1) populate_workspace_volume; (2) setup_workspace_mcp_json; (3) run_pre_run_hook; (4) the exec dispatch. Ordering is load-be… | Unit (source-order assertion) + integration: assert the call order populate→mcp→hook→exec. In volume mode, a staging tree containing its own .mcp.json ends up with moat'… | +| `EXEC-11-EXEC-NONROOT-DIRECT` | **fatal** | E | After setup, if `id -u` != 0 the entrypoint does `exec "$@"` DIRECTLY (no gosu, no privilege drop) — we're already non-root (e.g. Docker --user matched the host UID on Linux). exe… | e2e/integration: container started with --user 1000:1000, cmd `id -u`; output is 1000 and the process is PID 1 (exec, not a child). No gosu in the process tree. | +| `EXEC-12-EXEC-ROOT-GOSU-DROP` | **fatal** | E | If `id -u` == 0 AND moatuser exists, the entrypoint does `exec gosu moatuser "$@"` — dropping privileges to moatuser and exec-replacing the process. gosu sets uid/gid to moatuser … | e2e: root+moatuser, with MOAT_DOCKER_GID set and socket mounted; cmd `id` shows uid=5000(moatuser), gid=moatuser, and groups INCLUDES the docker socket group; `docker in… | +| `EXEC-13-EXEC-ROOT-NO-MOATUSER-FATAL` | **fatal** | I | If `id -u` == 0 AND moatuser does NOT exist, the entrypoint FATALs with a multi-line remediation message and `exit 1` — running as root defeats the container security model, so it… | Integration: simulate root + no moatuser (or run the exec-dispatch tail with `id moatuser` stubbed to fail); assert exit code 1, the user command did NOT run, and stderr… | +| `EXEC-14-MOATUSER-DETECTION-CONSISTENCY` | **fatal** | U | Every branch uses the SAME moatuser-existence check `id moatuser >/dev/null 2>&1` and the SAME root check `[ "$(id -u)" = "0" ]`. The hook branch (EXEC-03/04), the volume-chown ga… | Unit: given a fixed (uid, moatuser-present) tuple, assert the same branch class is chosen for hook-dispatch and exec-dispatch. Companion cases: root+moatuser→gosu/gosu; … | + +### X — Cross-cutting: env catalog, ordering, exit semantics, external binaries (19 requirements) + +| ID | Mode | Lvl | Requirement | Acceptance assertion | +|----|------|-----|-------------|----------------------| +| `X-ORDER-GLOBAL` | **fatal** | I | The entrypoint executes a fixed top-to-bottom sequence with hard ordering dependencies: (1) extra-hosts /etc/hosts injection; (2) SSH socat bridge; (3) Claude init copy; (4) Codex… | Assert the Go rewrite performs these phases in exactly this relative order; port the existing marker tests: populate_workspace_volume index < 'exec gosu moatuser' index;… | +| `X-SETE-GLOBAL` | **fatal** | I | Global `set -e` is active for the whole script: any unguarded command returning nonzero aborts the entrypoint immediately (the container appears to fail to start). Commands that m… | Unit-test the failure classification: for each guarded command, injecting a failure must NOT abort; for each unguarded fatal command (agent cp, mkdir, base64 -d), inject… | +| `X-EXIT-CONTRACT` | **fatal** | I | Exhaustive nonzero-exit (fatal) conditions, all others continue: (a) extra-hosts: cannot resolve an '@'hostname within 5s -> exit 1 with the 3-line 'could not resolve' message; ca… | Table-driven: each fatal trigger -> nonzero exit with the exact documented message substring; each best-effort trigger -> exit 0/continue. Assert pre_run propagates the … | +| `X-TARGETHOME-IDIOM` | best-eff | U | A repeated root-detection + home-selection idiom appears verbatim in 5 blocks (Claude 140-144, Codex 199-203, Gemini 232-237, init-files 270-276, and inline in mcp/volume-chown/fi… | Unit-test a shared helper targetHome()/initOwner(): (root, moatuser present) -> (/home/moatuser, moatuser:moatuser); (root, no moatuser) -> ($HOME, no-chown); (non-root)… | +| `X-EXTRAHOSTS-RESOLVE` | **fatal** | I | For each space-separated MOAT_EXTRA_HOSTS entry 'name:target': split name=${entry%%:*}, target=${entry#*:}; skip if name empty, target empty, or name==target. If target starts wit… | Integration against a tmp /etc/hosts + a stub resolver: '@host' with only AAAA -> falls back but prefers A when both present; unresolvable '@host' after 5s -> exit 1 wit… | +| `X-SSH-SOCAT` | warn | E | When MOAT_SSH_TCP_ADDR set: mkdir -p /run/moat/ssh (\|\| true), chmod 755, chown moatuser (if present, \|\| true), then start `socat UNIX-LISTEN:/run/moat/ssh/agent.sock,fork,mode… | Integration: with a stub TCP listener, the Go rewrite creates the Unix socket at 0660 within 2s and bridges bytes; socket owned by moatuser when root; if the bridge fail… | +| `X-AGENT-COPIES` | **fatal** | I | For each of Claude/Codex/Gemini, when MOAT_*_INIT set AND is a directory: mkdir -p target dir, copy present files with `cp -p` (preserve perms), chmod 600 the secret files (.crede… | Integration against a tmp home: staged files land at the right paths with perms preserved; secrets end at exactly 0600 regardless of source perms; missing optional files… | +| `X-INIT-FILES` | **fatal** | I | When MOAT_INIT_FILES set: determine INIT_OWNER/INIT_HOME via the root idiom. Pipe the value through `while IFS= read -r filepath content`: skip empty filepath, mkdir -p dirna… | Integration: a 2-record MOAT_INIT_FILES writes both files with decoded content at 0600, dirs at 0755; on root path files+intermediate dirs (up to but excluding INIT_HOME… | +| `X-MCP-JSON` | **fatal** | I | setup_workspace_mcp_json (called near the end, after populate): if MOAT_CODEX_INIT/mcp.json exists, cp -p it to /workspace/.mcp.json (chown moatuser on root path); same for MOAT_G… | Integration: in simulated volume mode, a staging tree containing .mcp.json is extracted into /workspace, THEN setup_workspace_mcp_json overwrites it with the provider's … | +| `X-CLIPBOARD-XVFB` | best-eff | E | When MOAT_CLIPBOARD = '1' (exact): start `Xvfb :99 -screen 0 1x1x8 >/dev/null 2>&1 &` (long-lived background child) and `export DISPLAY=:99`. | e2e: with MOAT_CLIPBOARD=1 the Go rewrite starts an X server on :99 and the user command sees DISPLAY=:99; with MOAT_CLIPBOARD unset/0 no X server starts. Confirm the ex… | +| `X-GIT-CONFIG` | best-eff | I | When `command -v git` succeeds: `git config --system --add safe.directory /workspace` (\|\| true); if MOAT_GIT_USER_NAME set -> --system user.name; if MOAT_GIT_USER_EMAIL set -> -… | Integration with a git binary + tmp system config: safe.directory /workspace, http.proxyAuthMethod=basic always present; user.name/email present iff the env vars set; ur… | +| `X-DOCKER-MUTEX` | **fatal** | U | If BOTH MOAT_DOCKER_DIND and MOAT_DOCKER_GID are non-empty -> print the 3-line 'mutually exclusive' error and exit 1 BEFORE either docker block runs. | Unit/integration: both set -> exit 1 with 'MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive' plus the two usage lines. Only one set -> no error here. | +| `X-DIND` | **fatal** | E | When MOAT_DOCKER_DIND = '1' AND root: print 'Starting Docker daemon (dind mode)...', mkdir -p /var/run, start `dockerd --storage-driver=vfs --log-level=warn >/var/log/dockerd.log … | e2e (real privileged container): dockerd comes up within 30s, docker info works, moatuser is in the docker group; a forced dockerd crash -> exit 1 with 'Docker daemon fa… | +| `X-DOCKER-GID` | warn | I | When MOAT_DOCKER_GID set AND root AND /var/run/docker.sock is a socket: SOCKET_GID=$(stat -c '%g' socket) (GNU stat, container-Linux only). If empty -> WARNING (continue). Else if… | Integration on Linux with a socket at a known GID: a group with that GID gets moatuser added; pre-existing GID group reused (no duplicate); stat failure emits the exact … | +| `X-POPULATE-VOLUME` | **fatal** | I | populate_workspace_volume: no-op unless MOAT_WORKSPACE_VOLUME=1. Defensive: if not root -> exit 1 'must run as root'. staging=${MOAT_WORKSPACE_STAGING:-/mnt/host-workspace}. Write… | Integration against tmp staging+workspace: nested exclude (dist/sub) AND single-component exclude (node_modules) both absent; sibling dist/keep present; symlinks preserv… | +| `X-PRERUN-HOOK` | **fatal** | I | run_pre_run_hook: return immediately if MOAT_PRE_RUN empty. Else `set +e`; if non-root run `( cd /workspace && sh -c "$MOAT_PRE_RUN" )` in a SUBSHELL (so the hook's cd can't chang… | Port TestMoatInitPreRunHookBehavior: failing hook -> framed 'pre_run hook failed (exit code 42)' and exit 42, main command NOT run; successful hook -> continue; empty ho… | +| `X-VOLUME-CHOWN` | best-eff | I | When MOAT_VOLUME_CHOWN set AND root AND moatuser exists: `set -f` (disable glob expansion so a path containing [ ] * ? is literal; word-splitting on spaces stays ON), loop over sp… | Integration: space-separated roots each get chowned to moatuser NON-recursively (a nested file's owner is unchanged); a path literally containing a '*' is chowned as a l… | +| `X-PRIVILEGE-DROP` | **fatal** | E | Final dispatch after populate/mcp/pre_run: if uid!=0 -> `exec "$@"` directly (already non-root, e.g. --user host-UID mapping). Elif moatuser exists -> `exec gosu moatuser "$@"` (d… | e2e: non-root container exec's argv directly and the process is replaced (PID unchanged, exit code = command's); root+moatuser drops to uid 5000 via gosu and exec's; roo… | +| `X-EXTBIN-CATALOG` | info | U | External-binary dependency set and delegation boundary: `gosu`/`socat`/`tar` stay in the base image and are invoked as **targeted subprocesses** (privilege drop, SSH bridge, volume byte-copy); the entrypoint moves only the surrounding **logic** into Go (branch selection, arg/config assembly, exclude computation, error classification). `sh` stays for `-c` hooks/pre_run. Pure-logic shell utils (`base64`, `getent`/DNS, `stat`) may move in-process where Go stdlib is a cleaner, testable in-process replacement doing no real system work. | Produce a table asserting, for each binary, either (a) 'invoked as a targeted subprocess; surrounding logic in Go' with the delegated operation named, or (b) 'logic moved in-process' with the Go API used, or (c) 'must remain in base image / on PATH' with the reason. `gosu`/`socat`/`tar` fall in bucket (a). | + +--- + +## Appendix B — Adversarial-review additions (36) + +Gaps surfaced by three completeness critics (security / lifecycle / cross-runtime lenses) reviewing the catalog against the script. Fold the P0/P1 items into the acceptance suite alongside Appendix A. + +| Sev | Lens | Missed behavior | Suggested acceptance criterion | +|-----|------|-----------------|-------------------------------| +| P0 | lifecycle | Detached children: reaping/orphaning after syscall.Exec (socat, Xvfb, dockerd) — the three background children (socat SSH-11, Xvfb GIT-CLIP-01, dockerd DOCKER-04) are still **targeted long-lived subprocesses**, unchanged from the shell… | The Go entrypoint MUST hand off via syscall.Exec (image replacement, PID preserved) — of the user command, or of `gosu` on the root path — never fork+wait — so the detached `socat`/`Xvfb`/`dockerd` reparent exactly as under the shell's `exec`. Pure parity; no native reimplementation of these children. | +| P0 | lifecycle | gosu group replication: ordering of mid-run usermod vs. the privilege drop — EXEC-12 requires gosu to establish moatuser's FULL supplementary group list including the docker group… | All group-membership mutations (DIND `usermod -aG docker`, host-mode `usermod -aG `) MUST complete BEFORE the `exec gosu moatuser` handoff. Because the drop is a **targeted `gosu` subprocess** (not a native re-implementation), `gosu` re-reads `/etc/group` fresh and resolves the full set itself — there is no native `getgrouplist`/`setgroups` parity to prove. The Go-owned obligation is only the **ordering** (usermod-before-exec); an e2e asserts the dropped `id -G` set includes the docker group. | +| P0 | lifecycle | Env that must be EXCLUDED from the exec'd command (MOAT_INIT_FILES) vs. how Go inherits container env — INIT-10 correctly requires MOAT_INIT_FILES to be absent from the exec'd command's env… | Both handoff paths (non-root direct `syscall.Exec` and root `exec gosu moatuser`) MUST construct the exec environment by explicitly REMOVING MOAT_INIT_FILES from o… | +| P0 | cross-runtime | Base-image binary reliance / static linking (whole file, cross-cutting) — X-EXTBIN-CATALOG now lists which binaries stay as targeted subprocesses (`gosu`/`socat`/`tar`) vs which logic moves in-process; the load-bearing invariant is that the **logic binary** is portable… | The ported entrypoint binary MUST be built with CGO_ENABLED=0 (statically linked, no dynamic loader/glibc dependency) and MUST execute successfully across base images; the delegated tools (`gosu`/`socat`/`tar`) must remain present/on PATH. No native group-resolution parity is required — group resolution is delegated to `gosu`. | +| P1 | security | Provider init files (INIT-06 / INIT-05) — pipeline-subshell fatality + partial secret write — The base64 decode, mkdir, and chmod steps (lines 281-283) execute INSIDE the `printf '%s\n' \| … | Given a MOAT_INIT_FILES record with invalid base64 content, the entrypoint MUST abort non-zero (fail-closed) and MUST NOT proceed to exec; assert no … | +| P1 | lifecycle | DISPLAY propagation through the root gosu drop — GIT-CLIP-02 asserts DISPLAY=:99 is exported and reaches the exec'd command, but only reasons about the entrypoint's own `export` and the non… | On the root privilege-drop path, DISPLAY (when clipboard is enabled) MUST be present in the environment of the exec'd user command, and HOME MUST rem… | +| P1 | lifecycle | Subshell cd isolation for populate tar and dirname walks (distinct from pre_run) — EXEC-02 covers the pre_run hook's subshell cd isolation, and WS-07 mentions 'the caller's cwd is unchanged… | The entrypoint's own working directory MUST be unchanged by any phase (populate copy, init-files dir creation, mcp copy); only child processes (pre_r… | +| P1 | lifecycle | Ordering: MOAT_INIT_FILES unset must happen before exec, but is emitted from a pipeline subshell — INIT-10 correctly notes the `while` loop runs in a pipeline subshell and the `unset` is ou… | Define and test the exact fatal-vs-continue behavior of a base64-decode / write failure inside the init-files loop: determine whether the shell actua… | +| P1 | cross-runtime | CGO_ENABLED=0 pure-Go os/user vs `id moatuser`/getent semantics — every moatuser-existence and root check in the script uses `id moatuser`/`id -u`/`getent`, which honor /etc/nsswitch.conf (… | Document and test that moatuser/root **detection** parity depends on /etc/passwd+/etc/group (files) NSS: the port resolves the existence/uid branch via pure-Go `os/user`… **Scoped narrower now:** this covers only branch **detection**, not the group **set** used for the drop — the drop execs `gosu`, which resolves groups itself — so the files-vs-LDAP/SSS divergence is limited to whether moatuser is *detected*, not to which groups it lands with. | +| P1 | cross-runtime | IPv6 fallback accepts ::1 — cross-runtime reachability hazard is under-tested — HOSTS-06 documents the acknowledged tradeoff that `getent hosts` fallback will accept an IPv6 (e.g. ::1) when… | Require that when only an IPv6/loopback address is resolvable for a proxy-bearing synthetic host (moat-proxy), the port emits at least a warning (or … | +| P1 | cross-runtime | Apple-container PID/reaping semantics for long-lived children (socat, Xvfb, dockerd) — SSH-11, GIT-CLIP-01, DOCKER-04 all require the child to outlive the entrypoint's exec. They reason abo… | Add an Apple-container e2e asserting that after the entrypoint execs the user command, the socat SSH bridge (and Xvfb if clipboard, dockerd if applic… | +| P1 | cross-runtime | gosu supplementary-group re-read must include groups from usermod done EARLIER in the SAME script — the `usermod`-before-`exec gosu` ordering must hold so `gosu` picks up the run-added group… | Add an acceptance test: root+moatuser, MOAT_DOCKER_GID (or DIND) set so usermod adds moatuser to the docker/socket group during this run; assert the dropped `id -G` set includes it. Because the drop is a targeted `gosu` call, `gosu` re-reads `/etc/group` itself — no native `getgrouplist` re-read to implement or prove; only the ordering is Go's concern. | +| P2 | security | Provider init files (INIT-05) — chmod 755 widens an existing restrictive parent directory — Line 281 `mkdir -p "$dir" && chmod 755 "$dir"` unconditionally chmods the IMMEDIATE parent dir of… | Document and test that the immediate parent directory of each init-file is set to exactly 0755 even when it pre-existed at a stricter mode (parity), … | +| P2 | security | Provider init files (INIT-05) — mkdir/chmod fatality inside the pipeline subshell — INIT-05 says mkdir/chmod failure is fatal under set -e, but does not account for these running inside the… | Assert that a mkdir or chmod failure for ANY init-file record aborts the entrypoint non-zero and prevents exec (not a per-record skip). Test by makin… | +| P2 | security | Extra-hosts resolution (HOSTS-07 / HOSTS-08) — getent-missing vs NXDOMAIN both fail closed, but message misleads and Go port may diverge — The catalog notes getent-missing yields an empty c… | Add an acceptance test that a name present ONLY in the container's /etc/hosts (not in DNS) still resolves via the Go port (parity with getent hosts f… | +| P2 | security | Cross-cutting (X-SETE-GLOBAL) — enumeration of unguarded/fatal commands is incomplete for the DIND and populate regions — X-SETE-GLOBAL lists agent cp, mkdir at 147, base64 -d, chmod as the… | Provide a complete, testable inventory mapping every mutating command to fatal vs best-effort, explicitly including `mkdir -p /var/run` (fatal), `cho… | +| P2 | security | Init-files ownership walk (INIT-08) — upward chown can chown dirs OUTSIDE the intended tree when path is unusual — The parent-dir chown walk (lines 288-291) stops at '/', '.', or INIT_HOME,… | Assert that init-file paths outside INIT_HOME cause the ancestor-chown walk to climb to '/', chowning intermediate system dirs to moatuser (parity), … | +| P2 | security | SSH bridge socket mode (SSH-06) — 0660 depends on socat umask/creation and dir traversal, no assertion the socket is NOT world-accessible — SSH-06 asserts mode=0660 on the UNIX-LISTEN socke… | Add a security assertion that the agent socket's mode has no world bits (mode & 0o007 == 0) and is owned by moatuser, and that a non-moatuser/non-gro… | +| P2 | security | Cross-cutting env leakage (INIT-10 / X-INIT-FILES) — only MOAT_INIT_FILES is unset; other MOAT_* secret-bearing vars leak to the exec'd command — INIT-10 correctly requires MOAT_INIT_FILES … | Define an explicit allowlist/denylist: exactly which MOAT_* vars the exec'd command MUST see (parity: all except MOAT_INIT_FILES) vs which the Go por… | +| P2 | security | Workspace populate (WS-10 / WS-08) — recursive chown must not follow symlinks (Lchown), else out-of-tree targets get re-owned — WS-10 go_notes correctly recommend Lchown, but there is no de… | Add a security acceptance test: with a symlink in /workspace pointing to an out-of-tree file (e.g. /etc/hostname), after the recursive chown the TARG… | +| P2 | lifecycle | run_pre_run_hook: set -e must be restored before the status check, and errors must not swallow subsequent fatals — EXEC-05 notes `set +e ... set -e` wraps only the hook invocation and that … | The best-effort error handling in run_pre_run_hook MUST be scoped to the hook invocation only: a hook non-zero exit is captured and framed (exiting w… | +| P2 | lifecycle | Failure-mode misclassification: populate `cd "$staging"` failure and DIND `mkdir -p /var/run` — WS-03 labels a nonexistent staging dir as leading to a fatal via WS-08's rc check, which is c… | A missing/inaccessible MOAT_WORKSPACE_STAGING MUST be FATAL (exit 1), never a silently empty /workspace: the Go copy must treat 'source root does not… | +| P2 | lifecycle | Xvfb has no readiness wait, but xclip exec depends on it — lifecycle coupling across exec sessions — GIT-CLIP-01 correctly requires Xvfb to be long-lived and fire-and-forget (no readiness w… | Xvfb MUST remain alive for the container's lifetime (reaped only at teardown) so that later `moat` exec sessions running xclip can attach to :99; and… | +| P2 | lifecycle | dockerd readiness poll must not block the 5s/2s/30s budgets via per-attempt hangs (cross-cutting timeout hazard) — DOCKER-05 preserves the 30s dind budget and HOSTS-14/SSH-07 preserve the 5… | Every per-attempt probe in a bounded retry loop (DNS resolve, `docker info`, socket stat) MUST have a per-attempt timeout smaller than the loop's rem… | +| P2 | cross-runtime | Hardcoded /home/moatuser vs real passwd home on CUSTOM base images — AGENT-CLAUDE-TARGETHOME and X-TARGETHOME-IDIOM correctly say 'hardcode /home/moatuser, do not resolve moatuser's passwd … | Add an acceptance test for the (moatuser present, passwd home != /home/moatuser) case asserting the port reproduces the shell's behavior byte-for-byt… | +| P2 | cross-runtime | golang:X base image is NON-slim with different tar/coreutils (tar 1.34 assumption) — WS-08/X-POPULATE-VOLUME hinge the no-`--no-dereference` decision on 'debian bookworm ships GNU tar 1.34'… | The port **retains `tar` as a targeted subprocess** (Open Decision #2), so require a build-time or runtime assertion of tar version/behavior and pin/assert `tar` in the parity-harness scenario images; add an acceptance test that the exclude-all-patterns behavior holds. The GNU-tar fragility is **contained and documented**, not eliminated; a native `filepath.WalkDir` copy that would shed it is optional future hardening. | +| P2 | cross-runtime | getent ahostsv4 multi-socktype output & first-field parity (Docker Desktop DNS) — HOSTS-15 notes ahostsv4 emits one line per socktype (STREAM/DGRAM/RAW) with the SAME v4 address and awk tak… | Add an integration/e2e assertion on a real Docker Desktop container that the ported resolver yields the identical first IPv4 for host.docker.internal… | +| P2 | cross-runtime | stat -c '%g' is GNU-specific; Go syscall.Stat_t.Gid parity across runtimes — DOCKER-10 notes GNU `stat -c` is Linux-only and says use syscall.Stat_t.Gid. But it misses that the DOCKER SOCKE… | Add an acceptance test (or e2e on Docker Desktop macOS) asserting the port derives the docker group from an in-container stat of /var/run/docker.sock… | +| P3 | security | Git configuration (GIT-02) — safe.directory scope is only /workspace, not volume/named-volume roots — safe.directory is added ONLY for the literal `/workspace` (line 348). Named-volume moun… | Document that safe.directory whitelists exactly `/workspace` and nothing else; assert the Go port does not silently broaden or narrow this. If future… | +| P3 | security | Final exec / privilege drop (EXEC-13 / X-EXIT-CONTRACT) — root-with-no-moatuser can leave root-owned secret files written earlier — When root AND moatuser is absent, the agent-copy, init-fi… | Assert that in the root-without-moatuser case the entrypoint still exits 1 with the exact security message, and document that provider secret files m… | +| P3 | security | DIND readiness (DOCKER-05 / X-DIND) — hard dependency on the `docker` CLI binary, not just dockerd — Readiness requires BOTH `[ -S /var/run/docker.sock ]` AND `docker info` succeeding (line… | Assert that the readiness probe's dependency on the docker CLI is documented: if the Go port dials the socket via the Docker API instead of shelling … | +| P3 | security | Agent copies (AGENT-CLAUDE-CHOWN-JSON) — `A && B 2>/dev/null \|\| true` precedence hides chown failures but also masks a genuine set -e trip — Line 190 is `[ -f X ] && chown ... 2>/dev/null… | Assert the .claude.json home-root chown is best-effort in BOTH the missing-file and chown-failure cases: a missing .claude.json is a no-op success, A… | +| P3 | security | Pre-run hook (EXEC-06 / X-PRERUN-HOOK) — framed failure message is emitted to stderr but the leading blank line goes to stderr too; exact stream and ordering not pinned against hook's own o… | Assert the framed pre_run failure block (blank line + 4 moat: lines) is written to STDERR (not stdout) and appears after the hook's own output, prese… | +| P3 | cross-runtime | base64 decode tolerance divergence (coreutils vs Go strict StdEncoding) — INIT-06 acknowledges coreutils base64 -d tolerates embedded whitespace/newlines while Go StdEncoding is strict, and… | Add a unit test feeding base64 content WITH embedded newlines/76-col wrapping and assert the port decodes it identically to `base64 -d` (i.e. use a d… | +| P3 | cross-runtime | golang:X / node:X base images may lack `command -v git` yet ship other tooling; git-block skip parity — GIT-01 requires skipping the git block when git is absent via `command -v git`. The c… | Add an acceptance test matrix asserting the git-config block runs iff git is on PATH for each base image variant, and that when a `git` dependency is… | +| P3 | cross-runtime | awk dependency for getent field extraction on minimal base images — HOSTS-06/HOSTS-15 pipe getent through `awk '{print $1; exit}'`. awk is NOT in moat's installed packages (dockerfile.go:29… | Require the port to perform DNS resolution + first-field parsing **in-process** (Go `net.Resolver`, no awk/getent subprocess) and add a note/test that the entrypoint functions on a base image lacking awk. This is a sanctioned in-process move: DNS resolve/parse is pure logic doing no real system work — distinct from `gosu`/`socat`/`tar`, which stay targeted subprocesses. | From 6b4b15965d6177ee9ef23ad6d179c879ba6e8dee Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:31:20 +0000 Subject: [PATCH 02/17] feat(moatinit): scaffold Go entrypoint, dual-ship dispatcher, embedded init binaries Commit 1 of the moat-init shell->Go rewrite (docs/plans/2026-07-01-moat-init-go-rewrite-plan.md). No behavior change: the dispatcher defaults to the shell entrypoint. - cmd/moat-init + internal/moatinit skeleton with the Sys seam (identity/ fs/subprocess/DNS operations injectable for tests); pipeline fails closed until the exec-dispatch phase lands - internal/initbin embeds prebuilt static linux/{amd64,arm64} entrypoint binaries; committed blobs are fail-closed shell-script stubs (reviewable text) regenerated by 'go generate ./internal/initbin' via make build and the goreleaser before hook; checksums.txt + unit test catch stale blobs; embed/ dir avoids the bare dist/ gitignore pattern - writeEntrypoint dual-ships: dispatcher at /usr/local/bin/moat-init plus moat-init-sh and arch-matched moat-init-go; MOAT_INIT_IMPL/MOAT_INIT_LEGACY are a closed enum, read once, unset before handoff - run.Create() rejects the reserved dispatcher vars in moat.yaml env and -e (always-on, unlike the proxy-var filter which is gated on needsProxy) - image cache key folds in dispatcher + binary bytes under a bumped moat-init-v2 salt so pre-dispatcher cached images re-key --- Makefile | 12 +- cmd/moat-init/main.go | 27 ++ internal/deps/builder.go | 34 +- internal/deps/dockerfile.go | 32 +- internal/deps/moat_init_dispatch_test.go | 133 ++++++ internal/deps/registry.go | 9 + internal/deps/scripts/moat-init-dispatch.sh | 44 ++ internal/initbin/checksums.txt | 2 + internal/initbin/embed/moat-init-linux-amd64 | 14 + internal/initbin/embed/moat-init-linux-arm64 | 14 + internal/initbin/gen/gen.go | 57 +++ internal/initbin/initbin.go | 70 ++++ internal/initbin/initbin_test.go | 86 ++++ internal/moatinit/config.go | 65 +++ internal/moatinit/doc.go | 29 ++ internal/moatinit/phase.go | 77 ++++ internal/moatinit/phase_test.go | 28 ++ internal/moatinit/sys.go | 413 +++++++++++++++++++ internal/run/envguard.go | 67 +++ internal/run/envguard_test.go | 68 +++ internal/run/manager_create.go | 7 + 21 files changed, 1271 insertions(+), 17 deletions(-) create mode 100644 cmd/moat-init/main.go create mode 100644 internal/deps/moat_init_dispatch_test.go create mode 100644 internal/deps/scripts/moat-init-dispatch.sh create mode 100644 internal/initbin/checksums.txt create mode 100755 internal/initbin/embed/moat-init-linux-amd64 create mode 100755 internal/initbin/embed/moat-init-linux-arm64 create mode 100644 internal/initbin/gen/gen.go create mode 100644 internal/initbin/initbin.go create mode 100644 internal/initbin/initbin_test.go create mode 100644 internal/moatinit/config.go create mode 100644 internal/moatinit/doc.go create mode 100644 internal/moatinit/phase.go create mode 100644 internal/moatinit/phase_test.go create mode 100644 internal/moatinit/sys.go create mode 100644 internal/run/envguard.go create mode 100644 internal/run/envguard_test.go diff --git a/Makefile b/Makefile index c36f3c75..36827409 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all help build test test-unit test-e2e test-bats lint fix clean coverage snapshot +.PHONY: all help build build-cli generate-init restore-init-stubs test test-unit test-e2e test-bats lint fix clean coverage snapshot # Default target - running "make" shows help all: help @@ -15,12 +15,18 @@ help: ## Show this help message @echo " make test-unit ARGS='-run TestName' # Run specific unit test" @echo " make test-unit ARGS='-run TestName ./internal/proxy'" # Run test in specific package" -build: ## Build the project +build: generate-init ## Build the project (regenerates the embedded moat-init binaries) go build ./... -build-cli: ## Build the CLI binary ./moat +build-cli: generate-init ## Build the CLI binary ./moat go build -ldflags "-s -w -X github.com/majorcontext/moat/cmd/moat/cli.version=dev -X github.com/majorcontext/moat/cmd/moat/cli.commit=$$(git rev-parse --short HEAD) -X github.com/majorcontext/moat/cmd/moat/cli.date=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o moat ./cmd/moat +generate-init: ## Cross-compile cmd/moat-init into internal/initbin/embed (over the committed stubs) + go generate ./internal/initbin + +restore-init-stubs: ## Restore the committed moat-init stub blobs after a local build + git checkout -- internal/initbin/embed internal/initbin/checksums.txt + test: test-unit test-e2e test-bats ## Run all tests (unit + E2E + hooks) test-unit: ## Run unit tests with race detector (use ARGS for filtering, e.g., ARGS='-run TestName') diff --git a/cmd/moat-init/main.go b/cmd/moat-init/main.go new file mode 100644 index 00000000..2d8d4c88 --- /dev/null +++ b/cmd/moat-init/main.go @@ -0,0 +1,27 @@ +// Command moat-init is the container entrypoint: the Go port of +// internal/deps/scripts/moat-init.sh (see internal/moatinit for the phases +// and docs/plans/2026-07-01-moat-init-go-rewrite-plan.md for the parity +// contract). +// +// It is cross-compiled static (CGO_ENABLED=0) for linux/amd64 and +// linux/arm64 by `go generate ./internal/initbin`, embedded into the moat +// host binary, and shipped into run images next to the shell script during +// the migration window (selected via the moat-init dispatcher). +package main + +import ( + "os" + + "github.com/majorcontext/moat/internal/moatinit" +) + +func main() { + sys := moatinit.NewSys() + ctx := &moatinit.Context{ + Sys: sys, + Cfg: moatinit.LoadConfig(sys), + Argv: os.Args[1:], + Stderr: os.Stderr, + } + os.Exit(moatinit.Run(ctx)) +} diff --git a/internal/deps/builder.go b/internal/deps/builder.go index 54e71409..5b48d635 100644 --- a/internal/deps/builder.go +++ b/internal/deps/builder.go @@ -7,6 +7,7 @@ import ( "sort" "strings" + "github.com/majorcontext/moat/internal/initbin" "github.com/majorcontext/moat/internal/providers/pi" ) @@ -54,12 +55,11 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { hashInput += ",clipboard:xvfb" } - // When the moat-init entrypoint is used, hash the script contents so that - // changes to moat-init.sh (e.g. adding /etc/hosts injection for synthetic - // hostnames) invalidate cached images. Without this, users on runtimes - // without --add-host (Apple) can end up running stale images that lack - // critical initialization logic. Mirror the conditions in needsInit() plus - // any dep-driven DockerMode. + // When the moat-init entrypoint is used, hash its contents so that + // changes to any entrypoint piece invalidate cached images. Without this, + // users on runtimes without --add-host (Apple) can end up running stale + // images that lack critical initialization logic. Mirror the conditions + // in needsInit() plus any dep-driven DockerMode. dockerModePresent := false for _, d := range deps { if d.DockerMode != "" { @@ -68,8 +68,7 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { } } if dockerModePresent || opts.needsInit("") { - scriptHash := sha256.Sum256([]byte(MoatInitScript)) - hashInput += ",moat-init:" + hex.EncodeToString(scriptHash[:])[:8] + hashInput += "," + initHashComponent() } // Include plugins in hash (different plugins = different image). @@ -118,3 +117,22 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { return "moat/run:" + hash } + +// initHashComponent is the cache-key contribution of the moat-init +// entrypoint. It covers every piece writeEntrypoint ships: the shell script, +// the dispatcher, and the embedded Go binary — so a change to any of them +// re-keys freshly built images. +// +// The "moat-init-v2" label is a deliberate salt bump: images cached before +// the dispatcher existed hashed only the script under the "moat-init" label, +// and a warm-cache lookup must not resolve to a pre-dispatcher image that +// lacks the moat-init-go/moat-init-sh split. Note this re-keys the `moat +// run` build/lookup path only — a workflow pinning a concrete moat/run: +// tag still resolves the old image and must re-tag/rebuild at cutover. +func initHashComponent() string { + h := sha256.New() + h.Write([]byte(MoatInitScript)) + h.Write([]byte(MoatInitDispatcher)) + h.Write(initbin.Binary()) + return "moat-init-v2:" + hex.EncodeToString(h.Sum(nil))[:8] +} diff --git a/internal/deps/dockerfile.go b/internal/deps/dockerfile.go index b2d9c430..aa538ae9 100644 --- a/internal/deps/dockerfile.go +++ b/internal/deps/dockerfile.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/majorcontext/moat/internal/initbin" "github.com/majorcontext/moat/internal/providers/claude" "github.com/majorcontext/moat/internal/providers/pi" ) @@ -610,15 +611,34 @@ func formatHookCommand(cmd string) string { } // writeEntrypoint writes the entrypoint configuration and working directory. -// When the init script is needed, it is added as a context file and COPYed -// into the image. This avoids embedding a large base64 blob inline in a RUN -// command, which triggers gRPC transport errors in Apple's container builder. +// When the init entrypoint is needed, its pieces are added as context files +// and COPYed into the image. This avoids embedding large base64 blobs inline +// in a RUN command, which triggers gRPC transport errors in Apple's container +// builder. Everything is materialized from bytes embedded in the moat host +// binary and COPY'd from the local build context — zero network at image +// build time. +// +// During the shell->Go migration window the ENTRYPOINT is a dispatcher that +// selects between the shell script (moat-init-sh, the default) and the Go +// binary (moat-init-go) via the operator-only MOAT_INIT_IMPL / +// MOAT_INIT_LEGACY variables, so one cached image carries both +// implementations. The Go binary is arch-matched: run images are always +// built for the host's own architecture, so the runtime.GOARCH blob from +// internal/initbin is the right one. func writeEntrypoint(b *strings.Builder, opts *ImageSpec, dockerMode DockerMode, contextFiles map[string][]byte) { if opts.needsInit(dockerMode) { contextFiles["moat-init.sh"] = []byte(MoatInitScript) - b.WriteString("# Moat initialization script (privilege drop + feature setup)\n") - b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init\n") - b.WriteString("RUN chmod +x /usr/local/bin/moat-init\n") + contextFiles["moat-init-dispatch.sh"] = []byte(MoatInitDispatcher) + b.WriteString("# Moat initialization entrypoint (privilege drop + feature setup)\n") + b.WriteString("COPY moat-init-dispatch.sh /usr/local/bin/moat-init\n") + b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init-sh\n") + chmodPaths := "/usr/local/bin/moat-init /usr/local/bin/moat-init-sh" + if goBin := initbin.Binary(); goBin != nil { + contextFiles["moat-init-go"] = goBin + b.WriteString("COPY moat-init-go /usr/local/bin/moat-init-go\n") + chmodPaths += " /usr/local/bin/moat-init-go" + } + b.WriteString("RUN chmod +x " + chmodPaths + "\n") b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n") } else { b.WriteString(fmt.Sprintf("# Run as non-root user\nUSER %s\n", containerUser)) diff --git a/internal/deps/moat_init_dispatch_test.go b/internal/deps/moat_init_dispatch_test.go new file mode 100644 index 00000000..89dbb388 --- /dev/null +++ b/internal/deps/moat_init_dispatch_test.go @@ -0,0 +1,133 @@ +package deps + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/initbin" +) + +// TestWriteEntrypointDualShip asserts the migration-window image layout: the +// dispatcher is the ENTRYPOINT at the original moat-init path, with the shell +// script and the Go binary installed next to it, all from local context files +// (no network fetch at image build time). +func TestWriteEntrypointDualShip(t *testing.T) { + result, err := GenerateDockerfile(nil, &ImageSpec{NeedsSSH: true}) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + df := result.Dockerfile + + for _, want := range []string{ + "COPY moat-init-dispatch.sh /usr/local/bin/moat-init\n", + "COPY moat-init.sh /usr/local/bin/moat-init-sh\n", + "COPY moat-init-go /usr/local/bin/moat-init-go\n", + "RUN chmod +x /usr/local/bin/moat-init /usr/local/bin/moat-init-sh /usr/local/bin/moat-init-go\n", + "ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n", + } { + if !strings.Contains(df, want) { + t.Errorf("Dockerfile missing %q\nGenerated Dockerfile:\n%s", want, df) + } + } + + if got := string(result.ContextFiles["moat-init.sh"]); got != MoatInitScript { + t.Error("context file moat-init.sh does not carry MoatInitScript") + } + if got := string(result.ContextFiles["moat-init-dispatch.sh"]); got != MoatInitDispatcher { + t.Error("context file moat-init-dispatch.sh does not carry MoatInitDispatcher") + } + if got := result.ContextFiles["moat-init-go"]; string(got) != string(initbin.Binary()) { + t.Error("context file moat-init-go does not carry the arch-matched embedded binary") + } + + // Offline-build contract: the entrypoint must be materialized from + // embedded bytes, never fetched or compiled at image build time. + if strings.Contains(df, "curl") && strings.Contains(df, "moat-init") { + for _, line := range strings.Split(df, "\n") { + if strings.Contains(line, "moat-init") && strings.Contains(line, "curl") { + t.Errorf("entrypoint line fetches over the network: %q", line) + } + } + } + if strings.Contains(df, "FROM golang") { + t.Errorf("Dockerfile uses a golang build stage for the entrypoint:\n%s", df) + } +} + +// TestWriteEntrypointCompanionNoInit asserts the companion case: images that +// do not need moat-init get none of the entrypoint pieces. +func TestWriteEntrypointCompanionNoInit(t *testing.T) { + result, err := GenerateDockerfile(nil, nil) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-init-go"} { + if _, ok := result.ContextFiles[name]; ok { + t.Errorf("context file %s present in a no-init image", name) + } + } + if strings.Contains(result.Dockerfile, "ENTRYPOINT") { + t.Errorf("no-init image should not set an ENTRYPOINT:\n%s", result.Dockerfile) + } +} + +// TestDispatcherContract pins the dispatcher's load-bearing properties: the +// closed MOAT_INIT_IMPL/MOAT_INIT_LEGACY enum (fatal on anything else), +// unsetting both before the handoff, and exec (never fork+wait) into the +// selected implementation. +func TestDispatcherContract(t *testing.T) { + d := MoatInitDispatcher + for _, want := range []string{ + `impl="${MOAT_INIT_IMPL:-sh}"`, + "unset MOAT_INIT_IMPL MOAT_INIT_LEGACY", + "exec /usr/local/bin/moat-init-go \"$@\"", + "exec /usr/local/bin/moat-init-sh \"$@\"", + "Error: invalid MOAT_INIT_IMPL", + "Error: invalid MOAT_INIT_LEGACY", + } { + if !strings.Contains(d, want) { + t.Errorf("dispatcher missing %q", want) + } + } + // The enum is read once, before any phase: the dispatcher must not + // invoke either implementation by any means other than exec. + if strings.Count(d, "exec ") != 2 { + t.Errorf("dispatcher should exec exactly twice (go leg, sh leg); got %d", strings.Count(d, "exec ")) + } +} + +// TestInitHashComponentReKeys asserts the cache-key salt bump: the moat-init +// component no longer matches the pre-dispatcher scheme (label or value), so +// images cached before the dual-ship cannot satisfy a post-dual-ship lookup. +func TestInitHashComponentReKeys(t *testing.T) { + comp := initHashComponent() + + if !strings.HasPrefix(comp, "moat-init-v2:") { + t.Fatalf("initHashComponent() = %q, want moat-init-v2: prefix", comp) + } + + // The pre-commit component was "moat-init:" + sha256(script)[:8]. Assert + // both directions: the old label is gone, and the new value is not the + // old value under a new name (it must fold in the dispatcher + binary). + oldHash := sha256.Sum256([]byte(MoatInitScript)) + oldValue := hex.EncodeToString(oldHash[:])[:8] + if strings.HasPrefix(comp, "moat-init:") { + t.Errorf("initHashComponent() = %q still uses the v1 label", comp) + } + if strings.HasSuffix(comp, oldValue) { + t.Errorf("initHashComponent() = %q hashes only the script; must include dispatcher + binary", comp) + } + + // And the tag itself changes for an init-bearing spec vs the v1 scheme. + tag := ImageTag(nil, &ImageSpec{NeedsSSH: true}) + oldInput := ",ssh:agent,moat-init:" + oldValue + oldTag := func() string { + h := sha256.Sum256([]byte(oldInput)) + return "moat/run:" + hex.EncodeToString(h[:])[:16] + }() + if tag == oldTag { + t.Error("ImageTag matches the pre-dispatcher tag; cache was not re-keyed") + } +} diff --git a/internal/deps/registry.go b/internal/deps/registry.go index c4e502f9..28f873b3 100644 --- a/internal/deps/registry.go +++ b/internal/deps/registry.go @@ -13,6 +13,15 @@ var registryData []byte //go:embed scripts/moat-init.sh var MoatInitScript string +// MoatInitDispatcher selects between the shell and Go entrypoint +// implementations during the moat-init shell->Go migration window +// (docs/plans/2026-07-01-moat-init-go-rewrite-plan.md). It is installed as +// /usr/local/bin/moat-init (the ENTRYPOINT); the script and the Go binary +// are installed next to it as moat-init-sh and moat-init-go. +// +//go:embed scripts/moat-init-dispatch.sh +var MoatInitDispatcher string + // registry holds all available dependencies. It is read-only after init(). var registry map[string]DepSpec diff --git a/internal/deps/scripts/moat-init-dispatch.sh b/internal/deps/scripts/moat-init-dispatch.sh new file mode 100644 index 00000000..9217df87 --- /dev/null +++ b/internal/deps/scripts/moat-init-dispatch.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# moat-init-dispatch.sh - Entrypoint dispatcher for the shell->Go migration. +# +# Selects which moat-init implementation runs as PID 1: +# /usr/local/bin/moat-init-sh - the original shell entrypoint +# /usr/local/bin/moat-init-go - the Go entrypoint (cmd/moat-init) +# +# MOAT_INIT_IMPL and MOAT_INIT_LEGACY are operator-only controls injected by +# the moat host binary; run.Create() rejects them in moat.yaml env and -e +# flags (they select a security-critical PID 1, so a user-settable switch +# would be an attack surface). Both are read exactly once, here, before any +# phase runs, and are never re-read after user-controlled code executes. +# They are unset before the handoff so the selected implementation and the +# user command see the same environment they would without the dispatcher. +# +# Closed enum, fatal on anything else: a typo must fail loudly, not fall +# back to an unintended entrypoint. +set -e + +impl="${MOAT_INIT_IMPL:-sh}" + +case "${MOAT_INIT_LEGACY:-}" in + "") ;; + 1) impl=sh ;; + *) + echo "Error: invalid MOAT_INIT_LEGACY '${MOAT_INIT_LEGACY}' (expected '1' or unset)" >&2 + exit 1 + ;; +esac + +case "$impl" in + sh|go) ;; + *) + echo "Error: invalid MOAT_INIT_IMPL '${MOAT_INIT_IMPL}' (expected 'sh' or 'go')" >&2 + exit 1 + ;; +esac + +unset MOAT_INIT_IMPL MOAT_INIT_LEGACY + +if [ "$impl" = "go" ]; then + exec /usr/local/bin/moat-init-go "$@" +fi +exec /usr/local/bin/moat-init-sh "$@" diff --git a/internal/initbin/checksums.txt b/internal/initbin/checksums.txt new file mode 100644 index 00000000..8689686b --- /dev/null +++ b/internal/initbin/checksums.txt @@ -0,0 +1,2 @@ +f2afdc36982626fcae023bfa28561703de6630137321e80834e748a683f1a72d moat-init-linux-amd64 +f2afdc36982626fcae023bfa28561703de6630137321e80834e748a683f1a72d moat-init-linux-arm64 diff --git a/internal/initbin/embed/moat-init-linux-amd64 b/internal/initbin/embed/moat-init-linux-amd64 new file mode 100755 index 00000000..bcc6fea5 --- /dev/null +++ b/internal/initbin/embed/moat-init-linux-amd64 @@ -0,0 +1,14 @@ +#!/bin/sh +# moat-init-stub +# +# Committed placeholder for the real moat-init entrypoint binary so a fresh +# clone compiles (go:embed requires the file to exist). The real binary is +# cross-compiled over this file by `go generate ./internal/initbin`, which +# `make build` and the release pipeline run automatically. +# +# Fail closed: if this stub ever ships as a container entrypoint, running it +# must refuse loudly rather than silently skip the privilege drop, the +# chmod-600 secret contracts, /etc/hosts injection, and the MOAT_INIT_FILES +# scrub. +echo "FATAL: moat-init stub embedded — rebuild moat via 'make build' (runs go generate ./internal/initbin)" >&2 +exit 1 diff --git a/internal/initbin/embed/moat-init-linux-arm64 b/internal/initbin/embed/moat-init-linux-arm64 new file mode 100755 index 00000000..bcc6fea5 --- /dev/null +++ b/internal/initbin/embed/moat-init-linux-arm64 @@ -0,0 +1,14 @@ +#!/bin/sh +# moat-init-stub +# +# Committed placeholder for the real moat-init entrypoint binary so a fresh +# clone compiles (go:embed requires the file to exist). The real binary is +# cross-compiled over this file by `go generate ./internal/initbin`, which +# `make build` and the release pipeline run automatically. +# +# Fail closed: if this stub ever ships as a container entrypoint, running it +# must refuse loudly rather than silently skip the privilege drop, the +# chmod-600 secret contracts, /etc/hosts injection, and the MOAT_INIT_FILES +# scrub. +echo "FATAL: moat-init stub embedded — rebuild moat via 'make build' (runs go generate ./internal/initbin)" >&2 +exit 1 diff --git a/internal/initbin/gen/gen.go b/internal/initbin/gen/gen.go new file mode 100644 index 00000000..3c6849e9 --- /dev/null +++ b/internal/initbin/gen/gen.go @@ -0,0 +1,57 @@ +// Command gen cross-compiles cmd/moat-init into the embed/ blobs and +// refreshes checksums.txt. It is invoked by `go generate ./internal/initbin` +// from the initbin package directory (go generate sets the working directory +// to the directory of the file containing the directive). +// +// Build flags: CGO_ENABLED=0 for a static binary that runs on any base image +// (no dynamic loader / glibc dependency), -trimpath and -ldflags "-s -w" for +// reproducible, minimal blobs. The checksums are committed alongside the +// stubs; a unit test hashes the embedded bytes against checksums.txt to +// catch a stale or hand-edited blob. Note the checksums pin the Go +// toolchain: a toolchain upgrade changes the -trimpath output and fails the +// checksum test until regenerated. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const target = "github.com/majorcontext/moat/cmd/moat-init" + +func main() { + arches := []string{"amd64", "arm64"} + sums := "" + for _, arch := range arches { + out := filepath.Join("embed", "moat-init-linux-"+arch) + // go build -o refuses to overwrite a non-object file (the committed + // shell-script stub), so clear the target first. + if err := os.Remove(out); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "initbin gen: removing %s: %v\n", out, err) + os.Exit(1) + } + cmd := exec.Command("go", "build", "-trimpath", "-ldflags", "-s -w", "-o", out, target) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+arch) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "initbin gen: building %s for %s: %v\n", target, arch, err) + os.Exit(1) + } + data, err := os.ReadFile(out) + if err != nil { + fmt.Fprintf(os.Stderr, "initbin gen: reading %s: %v\n", out, err) + os.Exit(1) + } + sum := sha256.Sum256(data) + sums += hex.EncodeToString(sum[:]) + " " + filepath.Base(out) + "\n" + } + if err := os.WriteFile("checksums.txt", []byte(sums), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "initbin gen: writing checksums.txt: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/initbin/initbin.go b/internal/initbin/initbin.go new file mode 100644 index 00000000..d7949d79 --- /dev/null +++ b/internal/initbin/initbin.go @@ -0,0 +1,70 @@ +// Package initbin embeds the prebuilt moat-init entrypoint binaries +// (cmd/moat-init cross-compiled static for linux/amd64 and linux/arm64) so +// the moat host binary can ship them into run images without any network +// fetch at image-build time. +// +// The committed blobs under embed/ are human-readable fail-closed stubs (a +// tiny shell script that prints a FATAL message and exits 1), so a fresh +// clone always compiles. `go generate ./internal/initbin` cross-compiles the +// real binaries over the stubs and refreshes checksums.txt; it is wired into +// `make build` and the goreleaser before hook (`go generate ./...`). Never +// commit the regenerated real blobs — `make restore-init-stubs` puts the +// stubs back. +// +// The embed directory is deliberately named embed/ (not dist/): the repo's +// .gitignore has a bare `dist/` that matches at any depth and would silently +// untrack the committed stubs. +package initbin + +import ( + "bytes" + _ "embed" + "runtime" +) + +//go:generate go run ./gen + +//go:embed embed/moat-init-linux-amd64 +var binAMD64 []byte + +//go:embed embed/moat-init-linux-arm64 +var binARM64 []byte + +//go:embed checksums.txt +var Checksums string + +// stubMarker identifies the committed placeholder blobs. The stub is a shell +// script (reviewable text, runs on any Linux) whose second line carries this +// marker; a real cross-compiled binary is an ELF image and can never start +// with it. +const stubMarker = "#!/bin/sh\n# moat-init-stub" + +// BinaryFor returns the embedded entrypoint for a GOARCH, or nil when no +// binary is embedded for that architecture. Run images are always built for +// the host's own architecture, so runtime.GOARCH selects the right blob. +func BinaryFor(goarch string) []byte { + switch goarch { + case "amd64": + return binAMD64 + case "arm64": + return binARM64 + default: + return nil + } +} + +// Binary returns the embedded entrypoint matching the host architecture, or +// nil on architectures moat does not build run images for. +func Binary() []byte { + return BinaryFor(runtime.GOARCH) +} + +// IsStub reports whether b is the committed fail-closed placeholder rather +// than a real cross-compiled entrypoint. Release checks refuse to ship an +// image whose entrypoint bytes are the stub; the stub itself also fails +// loudly at runtime (defense in depth — a checksum test alone cannot catch a +// regenerated-but-defective blob, which is why the release pipeline also +// execs the binary; see the plan's positive functional gate). +func IsStub(b []byte) bool { + return bytes.HasPrefix(b, []byte(stubMarker)) +} diff --git a/internal/initbin/initbin_test.go b/internal/initbin/initbin_test.go new file mode 100644 index 00000000..92934b23 --- /dev/null +++ b/internal/initbin/initbin_test.go @@ -0,0 +1,86 @@ +package initbin + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +// TestEmbeddedChecksums hashes the embedded blobs against the committed +// checksums.txt, catching a stale or hand-edited blob (in either state: +// committed stubs, or blobs regenerated by go generate). +func TestEmbeddedChecksums(t *testing.T) { + want := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(Checksums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + t.Fatalf("malformed checksums.txt line: %q", line) + } + want[fields[1]] = fields[0] + } + got := map[string][]byte{ + "moat-init-linux-amd64": BinaryFor("amd64"), + "moat-init-linux-arm64": BinaryFor("arm64"), + } + if len(want) != len(got) { + t.Fatalf("checksums.txt has %d entries, want %d", len(want), len(got)) + } + for name, data := range got { + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != want[name] { + t.Errorf("%s: embedded bytes do not match checksums.txt — regenerate with 'go generate ./internal/initbin' (or restore stubs with 'make restore-init-stubs')", name) + } + } +} + +func TestBinaryForArchSelection(t *testing.T) { + if BinaryFor("amd64") == nil { + t.Error("BinaryFor(amd64) = nil, want embedded blob") + } + if BinaryFor("arm64") == nil { + t.Error("BinaryFor(arm64) = nil, want embedded blob") + } + // Companion: unsupported architectures yield nil, not a wrong-arch blob. + if BinaryFor("riscv64") != nil { + t.Error("BinaryFor(riscv64) != nil, want nil for unsupported arch") + } + if Binary() == nil { + t.Error("Binary() = nil on a supported host arch") + } +} + +func TestIsStub(t *testing.T) { + // The committed placeholders must be recognized as stubs so release + // checks can refuse to ship them... + if !IsStub([]byte("#!/bin/sh\n# moat-init-stub\necho FATAL >&2\nexit 1\n")) { + t.Error("IsStub(stub content) = false, want true") + } + // ...and real binaries (ELF magic) must not be misclassified. + if IsStub([]byte("\x7fELF\x02\x01\x01\x00moat-init")) { + t.Error("IsStub(ELF bytes) = true, want false") + } + // Companion: an arbitrary shell script that is not the stub is not a stub. + if IsStub([]byte("#!/bin/sh\necho hello\n")) { + t.Error("IsStub(unrelated script) = true, want false") + } +} + +// TestStubFailsClosed pins the fail-closed contract of the committed stub: +// it must be a shell script that prints a FATAL message and exits 1, never +// a silent no-op that would start the user command as root. +func TestStubFailsClosed(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + b := BinaryFor(arch) + if !IsStub(b) { + t.Skipf("%s blob is a real binary (generated tree), stub contract not applicable", arch) + } + s := string(b) + if !strings.Contains(s, "FATAL: moat-init stub embedded") { + t.Errorf("%s stub missing FATAL message", arch) + } + if !strings.Contains(s, "exit 1") { + t.Errorf("%s stub missing exit 1", arch) + } + } +} diff --git a/internal/moatinit/config.go b/internal/moatinit/config.go new file mode 100644 index 00000000..ba1660b3 --- /dev/null +++ b/internal/moatinit/config.go @@ -0,0 +1,65 @@ +package moatinit + +// Config is the entrypoint's environment contract, read once at startup and +// frozen — no MOAT_* control variable is re-read after a user-controlled +// phase (e.g. the pre_run hook) has executed. +// +// One deliberate exception mirrors the script: MOAT_INIT_FILES is also +// removed from the live process environment right after the init-files phase +// (the script's `unset MOAT_INIT_FILES`), so children spawned by later +// phases never see it. +type Config struct { + ExtraHosts string // MOAT_EXTRA_HOSTS: space-separated name:target pairs for /etc/hosts + SSHTCPAddr string // MOAT_SSH_TCP_ADDR: TCP address of the host-side SSH agent proxy + + ClaudeInit string // MOAT_CLAUDE_INIT: staging dir for ~/.claude + CodexInit string // MOAT_CODEX_INIT: staging dir for ~/.codex + GeminiInit string // MOAT_GEMINI_INIT: staging dir for ~/.gemini + CopilotInit string // MOAT_COPILOT_INIT: staging dir for ~/.copilot + + InitFiles string // MOAT_INIT_FILES: tab-delimited \t records + + Clipboard string // MOAT_CLIPBOARD: "1" starts Xvfb :99 and exports DISPLAY + + GitUserName string // MOAT_GIT_USER_NAME + GitUserEmail string // MOAT_GIT_USER_EMAIL + GitSSHGitHub string // MOAT_GIT_SSH_GITHUB: "1" sets the github.com insteadOf rewrite + + DockerDIND string // MOAT_DOCKER_DIND: "1" starts dockerd (dind mode) + DockerGID string // MOAT_DOCKER_GID: non-empty enables host-socket group mode + + WorkspaceVolume string // MOAT_WORKSPACE_VOLUME: "1" populates /workspace from staging + WorkspaceStaging string // MOAT_WORKSPACE_STAGING: staging source (default /mnt/host-workspace) + WorkspaceExcludes string // MOAT_WORKSPACE_EXCLUDES: newline-delimited ./-prefixed patterns + + VolumeChown string // MOAT_VOLUME_CHOWN: space-separated named-volume mount roots + + PreRun string // MOAT_PRE_RUN: hook command run in /workspace before the main command + + Home string // HOME of the entrypoint process (target home on the non-root path) +} + +// LoadConfig snapshots the entrypoint environment from sys. +func LoadConfig(sys Sys) *Config { + return &Config{ + ExtraHosts: sys.Getenv("MOAT_EXTRA_HOSTS"), + SSHTCPAddr: sys.Getenv("MOAT_SSH_TCP_ADDR"), + ClaudeInit: sys.Getenv("MOAT_CLAUDE_INIT"), + CodexInit: sys.Getenv("MOAT_CODEX_INIT"), + GeminiInit: sys.Getenv("MOAT_GEMINI_INIT"), + CopilotInit: sys.Getenv("MOAT_COPILOT_INIT"), + InitFiles: sys.Getenv("MOAT_INIT_FILES"), + Clipboard: sys.Getenv("MOAT_CLIPBOARD"), + GitUserName: sys.Getenv("MOAT_GIT_USER_NAME"), + GitUserEmail: sys.Getenv("MOAT_GIT_USER_EMAIL"), + GitSSHGitHub: sys.Getenv("MOAT_GIT_SSH_GITHUB"), + DockerDIND: sys.Getenv("MOAT_DOCKER_DIND"), + DockerGID: sys.Getenv("MOAT_DOCKER_GID"), + WorkspaceVolume: sys.Getenv("MOAT_WORKSPACE_VOLUME"), + WorkspaceStaging: sys.Getenv("MOAT_WORKSPACE_STAGING"), + WorkspaceExcludes: sys.Getenv("MOAT_WORKSPACE_EXCLUDES"), + VolumeChown: sys.Getenv("MOAT_VOLUME_CHOWN"), + PreRun: sys.Getenv("MOAT_PRE_RUN"), + Home: sys.Getenv("HOME"), + } +} diff --git a/internal/moatinit/doc.go b/internal/moatinit/doc.go new file mode 100644 index 00000000..50ddb9c0 --- /dev/null +++ b/internal/moatinit/doc.go @@ -0,0 +1,29 @@ +// Package moatinit implements the container entrypoint as testable Go. +// +// It is the Go port of internal/deps/scripts/moat-init.sh, with behavioral +// parity as the contract: same phase ordering, same fail-closed vs +// best-effort classification per operation, and verbatim user-facing +// error wording. The catalog of ported requirements lives in +// docs/plans/2026-07-01-moat-init-go-rewrite-plan.md (Appendix A/B). +// +// The package moves the entrypoint's business logic — env parsing, branch +// and mode selection, phase ordering, error classification, argument and +// config assembly, exclude computation, root/moatuser detection — into +// unit-testable Go. Mechanical, security-sensitive, or well-understood +// system operations stay delegated to the audited tools already in the +// base image, invoked as targeted subprocesses: +// +// - gosu — the privilege drop (Go selects the branch, gosu transitions) +// - socat — the SSH agent TCP↔unix bridge (long-lived child) +// - tar — the workspace volume byte copy (Go owns excludes/args/rc checks) +// +// All identity, filesystem, subprocess, and DNS operations go through the +// Sys seam so phases can be exercised under `go test` against an injected +// temp root with no container and no root privileges. +// +// The compiled binary (cmd/moat-init) is embedded into the moat host binary +// by internal/initbin and shipped into run images by writeEntrypoint. During +// the migration window a dispatcher selects between the shell script and +// this implementation via MOAT_INIT_IMPL (operator-only; see internal/run's +// reserved-key validation). +package moatinit diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go new file mode 100644 index 00000000..adccbe42 --- /dev/null +++ b/internal/moatinit/phase.go @@ -0,0 +1,77 @@ +package moatinit + +import ( + "errors" + "fmt" + "io" +) + +// Context carries the frozen configuration, the Sys seam, the user command, +// and the stderr stream through every phase. +type Context struct { + Sys Sys + Cfg *Config + Argv []string // the user command (the entrypoint's "$@") + Stderr io.Writer +} + +// exitError aborts the entrypoint with a specific exit code. The user-facing +// message has already been written to Stderr by the phase (wording is +// contract and lives next to the phase logic), so the pipeline only carries +// the code. +type exitError struct{ code int } + +func (e exitError) Error() string { return fmt.Sprintf("moat-init: exit %d", e.code) } + +// Phase is one ordered step of the entrypoint. Phases return nil to +// continue, or an exitError to abort with that code. Any other error is a +// programming bug and is reported as a generic fatal. +type Phase struct { + Name string + Run func(*Context) error +} + +// phases returns the ordered phase list. The exact order is a +// correctness/security invariant (catalog X-ORDER-GLOBAL): /etc/hosts +// synthetic entries must precede anything that resolves moat-proxy/moat-host; +// agent staging and init files must precede the pre_run hook; the workspace +// volume populate must precede setup_workspace_mcp_json (so moat's .mcp.json +// wins over a user tree's copy) and the privilege drop (its chown needs +// root); the exec dispatch is last and replaces the process image. +// +// Phase bodies are filled in incrementally (plan §9 commits 2–6); until the +// exec dispatch phase lands, Run ends fail-closed rather than starting the +// user command without the privilege-drop contract. +func phases() []Phase { + return []Phase{ + // Commit 2–6 land: extra-hosts, ssh-agent-bridge, claude-staging, + // codex-staging, gemini-staging, copilot-staging, init-files, + // clipboard, git-config, docker, named-volume-chown, + // populate-workspace-volume, workspace-mcp-json, pre-run-hook, + // exec-dispatch. + } +} + +// Run executes all phases in order. On success it never returns: the final +// phase replaces the process image via Sys.Exec. It returns the process exit +// code on failure. +// +// Fail-closed: reaching the end of the phase list means the exec dispatch +// did not run (it can only be skipped in an incomplete build), and starting +// the user command without the privilege-drop contract would silently run it +// as root — so refuse instead. +func Run(ctx *Context) int { + for _, p := range phases() { + if err := p.Run(ctx); err != nil { + var exit exitError + if errors.As(err, &exit) { + return exit.code + } + fmt.Fprintf(ctx.Stderr, "moat-init: internal error in phase %s: %v\n", p.Name, err) + return 1 + } + } + fmt.Fprintln(ctx.Stderr, "FATAL: moat-init reached the end of its phase list without exec'ing the command.") + fmt.Fprintln(ctx.Stderr, "This build of moat-init is incomplete; use MOAT_INIT_IMPL=sh (the default) or rebuild moat.") + return 1 +} diff --git a/internal/moatinit/phase_test.go b/internal/moatinit/phase_test.go new file mode 100644 index 00000000..33870cfd --- /dev/null +++ b/internal/moatinit/phase_test.go @@ -0,0 +1,28 @@ +package moatinit + +import ( + "strings" + "testing" +) + +// TestRunFailsClosedWithoutExecPhase pins the incomplete-build contract: +// until the exec-dispatch phase lands, Run must refuse to fall through to +// the user command (which would silently run it as root, skipping the +// privilege-drop contract) — it exits 1 with a loud FATAL instead. +func TestRunFailsClosedWithoutExecPhase(t *testing.T) { + var stderr strings.Builder + sys := NewSys() + ctx := &Context{ + Sys: sys, + Cfg: LoadConfig(sys), + Argv: []string{"true"}, + Stderr: &stderr, + } + code := Run(ctx) + if code != 1 { + t.Errorf("Run() = %d, want 1 (fail closed)", code) + } + if !strings.Contains(stderr.String(), "FATAL: moat-init reached the end of its phase list") { + t.Errorf("missing fail-closed FATAL message, got: %q", stderr.String()) + } +} diff --git a/internal/moatinit/sys.go b/internal/moatinit/sys.go new file mode 100644 index 00000000..813ef4df --- /dev/null +++ b/internal/moatinit/sys.go @@ -0,0 +1,413 @@ +package moatinit + +import ( + "context" + "errors" + "io" + "io/fs" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// User is a resolved account (the subset of user.User the entrypoint needs). +type User struct { + UID int + GID int +} + +// Cmd describes a subprocess invocation. +type Cmd struct { + Argv []string + Dir string // working directory ("" = inherit) + Env []string // nil = inherit the current process environment + Stdout io.Writer // nil = discard + Stderr io.Writer // nil = discard +} + +// Sys abstracts every identity, filesystem, subprocess, and DNS operation the +// entrypoint performs, so phases can be exercised under `go test` against an +// injected temp root with no container and no root privileges. OSSys is the +// production implementation; tests embed *OSSys (pointed at a t.TempDir() +// root) and shadow the identity/subprocess/DNS methods they need to fake. +// +// Filesystem methods take absolute container paths (/etc/hosts, +// /home/moatuser/...); OSSys re-roots them under Root when set. +type Sys interface { + // Identity. Lookups use pure-Go os/user (files NSS: /etc/passwd, + // /etc/group) — parity with the script's `id`/`getent` for the standard + // moat images; documented divergence for LDAP/SSSD-backed custom images + // (detection only — the privilege drop itself is delegated to gosu). + Geteuid() int + LookupUser(name string) (User, bool) + LookupGroupByName(name string) (gid string, ok bool) + LookupGroupByGID(gid string) (name string, ok bool) + + // Process environment. Mirrors the shell's export/unset so that children + // spawned later (pre_run hook, gosu, the exec'd command) inherit exactly + // what they would have under the script. + Getenv(key string) string + Setenv(key, value string) + Unsetenv(key string) + Environ() []string + + // Filesystem. + Stat(path string) (fs.FileInfo, error) + Lstat(path string) (fs.FileInfo, error) + MkdirAll(path string, perm fs.FileMode) error + Chmod(path string, perm fs.FileMode) error + Chown(path string, uid, gid int) error + Lchown(path string, uid, gid int) error + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte, perm fs.FileMode) error + AppendFile(path string, data []byte) error + Remove(path string) error + CopyFilePreserving(src, dst string) error // cp -p + CopyTreePreserving(src, dst string) error // cp -rp + WalkDir(root string, fn fs.WalkDirFunc) error + Getpid() int + + // Subprocesses. + LookPath(file string) (string, error) + Run(c Cmd) (exitCode int, err error) + StartDetached(c Cmd) (pid int, err error) + ProcessAlive(pid int) bool + Pipe(src, dst Cmd) (srcExit, dstExit int, err error) + + // DNS. ResolveIPv4First is the port of `getent ahostsv4 | awk '{print + // $1; exit}'`; ResolveAnyFirst of the `getent hosts` fallback. Both + // consult /etc/hosts then DNS (Go resolver) and return "" on failure. + ResolveIPv4First(host string) string + ResolveAnyFirst(host string) string + + Sleep(d time.Duration) + + // Exec replaces the process image (syscall.Exec). It only returns on + // error. argv[0] is resolved via PATH when not absolute. + Exec(argv []string, env []string) error +} + +// OSSys is the production Sys backed by the real operating system. Root, when +// non-empty, re-roots all filesystem paths beneath it (used by integration +// tests to run phases against a temp directory). +type OSSys struct { + Root string + + // resolveTimeout bounds each DNS lookup attempt so a retry loop's total + // budget is honored even when the resolver hangs (plan Appendix B: + // per-attempt timeout must stay below the loop budget). + ResolveTimeout time.Duration +} + +// NewSys returns the production Sys. +func NewSys() *OSSys { + return &OSSys{ResolveTimeout: 2 * time.Second} +} + +// path re-roots an absolute container path under Root for tests. +func (s *OSSys) path(p string) string { + if s.Root == "" { + return p + } + return filepath.Join(s.Root, strings.TrimPrefix(p, "/")) +} + +func (s *OSSys) Geteuid() int { return os.Geteuid() } + +func (s *OSSys) LookupUser(name string) (User, bool) { + u, err := user.Lookup(name) + if err != nil { + return User{}, false + } + uid, err1 := strconv.Atoi(u.Uid) + gid, err2 := strconv.Atoi(u.Gid) + if err1 != nil || err2 != nil { + return User{}, false + } + return User{UID: uid, GID: gid}, true +} + +func (s *OSSys) LookupGroupByName(name string) (string, bool) { + g, err := user.LookupGroup(name) + if err != nil { + return "", false + } + return g.Gid, true +} + +func (s *OSSys) LookupGroupByGID(gid string) (string, bool) { + g, err := user.LookupGroupId(gid) + if err != nil { + return "", false + } + return g.Name, true +} + +func (s *OSSys) Getenv(key string) string { return os.Getenv(key) } +func (s *OSSys) Setenv(key, value string) { os.Setenv(key, value) } //nolint:errcheck // parity: shell export cannot fail +func (s *OSSys) Unsetenv(key string) { os.Unsetenv(key) } //nolint:errcheck // parity: shell unset cannot fail +func (s *OSSys) Environ() []string { return os.Environ() } +func (s *OSSys) Getpid() int { return os.Getpid() } +func (s *OSSys) Sleep(d time.Duration) { time.Sleep(d) } + +func (s *OSSys) Stat(path string) (fs.FileInfo, error) { return os.Stat(s.path(path)) } +func (s *OSSys) Lstat(path string) (fs.FileInfo, error) { return os.Lstat(s.path(path)) } + +func (s *OSSys) MkdirAll(path string, perm fs.FileMode) error { + return os.MkdirAll(s.path(path), perm) +} + +func (s *OSSys) Chmod(path string, perm fs.FileMode) error { + return os.Chmod(s.path(path), perm) +} + +func (s *OSSys) Chown(path string, uid, gid int) error { + return os.Chown(s.path(path), uid, gid) +} + +func (s *OSSys) Lchown(path string, uid, gid int) error { + return os.Lchown(s.path(path), uid, gid) +} + +func (s *OSSys) ReadFile(path string) ([]byte, error) { return os.ReadFile(s.path(path)) } + +func (s *OSSys) WriteFile(path string, data []byte, perm fs.FileMode) error { + return os.WriteFile(s.path(path), data, perm) +} + +func (s *OSSys) AppendFile(path string, data []byte) error { + f, err := os.OpenFile(s.path(path), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) + if err != nil { + return err + } + _, werr := f.Write(data) + cerr := f.Close() + if werr != nil { + return werr + } + return cerr +} + +func (s *OSSys) Remove(path string) error { return os.Remove(s.path(path)) } + +// CopyFilePreserving mirrors `cp -p src dst`: bytes, mode, and timestamps are +// preserved (failure is an error); ownership preservation is attempted but, +// like cp -p without appropriate privileges, its failure is not an error. +func (s *OSSys) CopyFilePreserving(src, dst string) error { + rsrc, rdst := s.path(src), s.path(dst) + info, err := os.Stat(rsrc) + if err != nil { + return err + } + data, err := os.ReadFile(rsrc) + if err != nil { + return err + } + if err := os.WriteFile(rdst, data, info.Mode().Perm()); err != nil { + return err + } + // WriteFile only applies the mode at creation; force it for pre-existing + // destinations (cp truncates and keeps applying -p semantics). + if err := os.Chmod(rdst, info.Mode().Perm()); err != nil { + return err + } + if st, ok := info.Sys().(*syscall.Stat_t); ok { + _ = os.Chown(rdst, int(st.Uid), int(st.Gid)) // best-effort, like cp -p as non-root + } + return os.Chtimes(rdst, time.Now(), info.ModTime()) +} + +// CopyTreePreserving mirrors `cp -rp src dst` where dst is the destination +// path of the copied tree (POSIX -R semantics: symlinks are duplicated as +// symlinks, never followed). +func (s *OSSys) CopyTreePreserving(src, dst string) error { + rsrc := s.path(src) + return filepath.WalkDir(rsrc, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(rsrc, p) + if err != nil { + return err + } + target := dst + if rel != "." { + target = filepath.Join(dst, rel) + } + switch { + case d.Type()&fs.ModeSymlink != 0: + dest, err := os.Readlink(p) + if err != nil { + return err + } + return os.Symlink(dest, s.path(target)) + case d.IsDir(): + info, err := d.Info() + if err != nil { + return err + } + return os.MkdirAll(s.path(target), info.Mode().Perm()) + default: + // p is already re-rooted; strip Root before re-entering the seam. + relSrc := p + if s.Root != "" { + relSrc = "/" + strings.TrimPrefix(strings.TrimPrefix(p, s.Root), "/") + } + return s.CopyFilePreserving(relSrc, target) + } + }) +} + +func (s *OSSys) WalkDir(root string, fn fs.WalkDirFunc) error { + rroot := s.path(root) + return filepath.WalkDir(rroot, func(p string, d fs.DirEntry, err error) error { + // Report container-absolute paths to the callback so phase logic + // stays independent of the injected root. + rel := p + if s.Root != "" { + rel = "/" + strings.TrimPrefix(strings.TrimPrefix(p, s.Root), "/") + } + return fn(rel, d, err) + }) +} + +func (s *OSSys) LookPath(file string) (string, error) { return exec.LookPath(file) } + +func (s *OSSys) Run(c Cmd) (int, error) { + cmd := exec.Command(c.Argv[0], c.Argv[1:]...) + cmd.Dir = c.Dir + cmd.Env = c.Env + cmd.Stdout = c.Stdout + cmd.Stderr = c.Stderr + err := cmd.Run() + if err == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, err +} + +// StartDetached launches a long-lived background child, exactly like the +// shell's `cmd ... &`: the child shares the entrypoint's process group (job +// control is off in non-interactive sh, so backgrounded children are NOT +// re-grouped — parity requires the same signal delivery the shell had), and +// it survives the entrypoint's exec because exec replaces the process image +// without touching children. A reaper goroutine collects the child if it +// dies before the handoff, so ProcessAlive mirrors the shell's `kill -0` on +// a reaped background job instead of seeing a signalable zombie. +func (s *OSSys) StartDetached(c Cmd) (int, error) { + cmd := exec.Command(c.Argv[0], c.Argv[1:]...) + cmd.Dir = c.Dir + cmd.Env = c.Env + cmd.Stdout = c.Stdout + cmd.Stderr = c.Stderr + if err := cmd.Start(); err != nil { + return 0, err + } + go cmd.Wait() //nolint:errcheck // reap-only; the child outlives us by design + return cmd.Process.Pid, nil +} + +func (s *OSSys) ProcessAlive(pid int) bool { + return syscall.Kill(pid, 0) == nil +} + +// Pipe runs `src | dst` and returns both exit codes, mirroring the script's +// capture of the source tar's status alongside the destination's ($? after a +// POSIX pipeline only reports the rightmost command). +func (s *OSSys) Pipe(src, dst Cmd) (int, int, error) { + pr, pw, err := os.Pipe() + if err != nil { + return -1, -1, err + } + srcCmd := exec.Command(src.Argv[0], src.Argv[1:]...) + srcCmd.Dir = src.Dir + srcCmd.Env = src.Env + srcCmd.Stdout = pw + srcCmd.Stderr = src.Stderr + dstCmd := exec.Command(dst.Argv[0], dst.Argv[1:]...) + dstCmd.Dir = dst.Dir + dstCmd.Env = dst.Env + dstCmd.Stdin = pr + dstCmd.Stdout = dst.Stdout + dstCmd.Stderr = dst.Stderr + + if err := srcCmd.Start(); err != nil { + pw.Close() + pr.Close() + return -1, -1, err + } + if err := dstCmd.Start(); err != nil { + pw.Close() + pr.Close() + _ = srcCmd.Wait() + return -1, -1, err + } + // Close the parent's copies so the pipe sees EOF when src exits. + pw.Close() + pr.Close() + + srcRC := exitCodeOf(srcCmd.Wait()) + dstRC := exitCodeOf(dstCmd.Wait()) + return srcRC, dstRC, nil +} + +func exitCodeOf(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} + +func (s *OSSys) resolveCtx() (context.Context, context.CancelFunc) { + timeout := s.ResolveTimeout + if timeout <= 0 { + timeout = 2 * time.Second + } + return context.WithTimeout(context.Background(), timeout) +} + +func (s *OSSys) ResolveIPv4First(host string) string { + ctx, cancel := s.resolveCtx() + defer cancel() + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", host) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} + +func (s *OSSys) ResolveAnyFirst(host string) string { + ctx, cancel := s.resolveCtx() + defer cancel() + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} + +func (s *OSSys) Exec(argv []string, env []string) error { + path := argv[0] + if !strings.Contains(path, "/") { + resolved, err := exec.LookPath(path) + if err != nil { + return err + } + path = resolved + } + return syscall.Exec(path, argv, env) +} diff --git a/internal/run/envguard.go b/internal/run/envguard.go new file mode 100644 index 00000000..29cc3b36 --- /dev/null +++ b/internal/run/envguard.go @@ -0,0 +1,67 @@ +package run + +import ( + "fmt" + "strings" + + "github.com/majorcontext/moat/internal/config" +) + +// reservedInitEnvVars are entrypoint-dispatcher controls injected by the moat +// host binary itself: they select which PID-1 implementation runs inside the +// container (see internal/deps/scripts/moat-init-dispatch.sh). A +// user-settable value would be an attack surface — the dispatcher chooses a +// security-critical entrypoint — so these keys are rejected outright from +// moat.yaml env and -e flags. +// +// Unlike isMoatOwnedProxyVar, this rejection is always on: it does not +// depend on whether a proxy is active (a grantless permissive run passes all +// other env through untouched), and it fails the run instead of warning and +// skipping, because silently dropping an explicit entrypoint selection would +// hide from the operator that their switch never applied. +var reservedInitEnvVars = []string{"MOAT_INIT_IMPL", "MOAT_INIT_LEGACY"} + +// isReservedInitVar reports whether name is a reserved entrypoint-dispatcher +// variable. Matching is case-insensitive for consistency with +// isMoatOwnedProxyVar: only the exact-case variable influences the +// dispatcher, but allowing a case-twin through would invite confusion with +// no legitimate use. +func isReservedInitVar(name string) bool { + upper := strings.ToUpper(name) + for _, r := range reservedInitEnvVars { + if upper == r { + return true + } + } + return false +} + +// validateReservedEnv rejects reserved entrypoint-dispatcher variables in +// user-supplied environment sources (moat.yaml env: and -e/--env flags). An +// -e entry without '=' is a host-passthrough form and is matched on its full +// name. +func validateReservedEnv(cfg *config.Config, explicitEnv []string) error { + if cfg != nil { + for k := range cfg.Env { + if isReservedInitVar(k) { + return reservedEnvError(k, "moat.yaml env") + } + } + } + for _, e := range explicitEnv { + name := e + if idx := strings.IndexByte(e, '='); idx >= 0 { + name = e[:idx] + } + if isReservedInitVar(name) { + return reservedEnvError(name, "-e flag") + } + } + return nil +} + +func reservedEnvError(name, source string) error { + return fmt.Errorf("%s is reserved for moat's entrypoint dispatcher and cannot be set via %s.\n"+ + "It selects which container entrypoint implementation runs and is managed by moat itself.\n"+ + "Remove %s from your configuration and re-run", name, source, name) +} diff --git a/internal/run/envguard_test.go b/internal/run/envguard_test.go new file mode 100644 index 00000000..31a76e93 --- /dev/null +++ b/internal/run/envguard_test.go @@ -0,0 +1,68 @@ +package run + +import ( + "strings" + "testing" + + "github.com/majorcontext/moat/internal/config" +) + +func TestValidateReservedEnv(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + env []string + wantErr string // substring; "" = no error + }{ + {"nil config, no env", nil, nil, ""}, + {"benign config env", &config.Config{Env: map[string]string{"FOO": "bar", "MOAT_LIKE_BUT_NOT": "x"}}, nil, ""}, + {"benign -e env", nil, []string{"FOO=bar", "BAZ"}, ""}, + {"MOAT_INIT_IMPL in config env", &config.Config{Env: map[string]string{"MOAT_INIT_IMPL": "go"}}, nil, "MOAT_INIT_IMPL is reserved"}, + {"MOAT_INIT_LEGACY in config env", &config.Config{Env: map[string]string{"MOAT_INIT_LEGACY": "1"}}, nil, "MOAT_INIT_LEGACY is reserved"}, + {"MOAT_INIT_IMPL in -e", nil, []string{"MOAT_INIT_IMPL=go"}, "MOAT_INIT_IMPL is reserved"}, + {"MOAT_INIT_LEGACY in -e", nil, []string{"MOAT_INIT_LEGACY=1"}, "MOAT_INIT_LEGACY is reserved"}, + // -e NAME without '=' is the host-passthrough form; it still injects + // the variable, so it must be rejected too. + {"bare -e passthrough", nil, []string{"MOAT_INIT_IMPL"}, "MOAT_INIT_IMPL is reserved"}, + // Case-insensitive, consistent with isMoatOwnedProxyVar. + {"lowercase in -e", nil, []string{"moat_init_impl=go"}, "is reserved"}, + // Companion: an empty value is still an injection attempt. + {"empty value in -e", nil, []string{"MOAT_INIT_IMPL="}, "MOAT_INIT_IMPL is reserved"}, + // Companion: prefix/suffix near-misses are not reserved. + {"near-miss names pass", &config.Config{Env: map[string]string{"MOAT_INIT_IMPL_X": "1", "XMOAT_INIT_IMPL": "1"}}, []string{"MOAT_INIT=1"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateReservedEnv(tt.cfg, tt.env) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateReservedEnv() = %v, want nil", err) + } + return + } + if err == nil { + t.Fatalf("validateReservedEnv() = nil, want error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} + +// TestReservedInitVarsUnfiltered pins the division of labor: the reserved +// dispatcher vars are NOT part of the proxy-var filter (which only runs when +// a proxy is active and warns-and-skips). They must be rejected by +// validateReservedEnv regardless of proxy state, so adding them to +// isMoatOwnedProxyVar would silently weaken the guard. +func TestReservedInitVarsUnfiltered(t *testing.T) { + for _, name := range reservedInitEnvVars { + if isMoatOwnedProxyVar(name) { + t.Errorf("%s is in isMoatOwnedProxyVar; it must stay under the always-on validateReservedEnv guard instead", name) + } + if !isReservedInitVar(name) { + t.Errorf("isReservedInitVar(%s) = false", name) + } + } +} diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index cbff94e0..6da47cf9 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -107,6 +107,13 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } } + // Reject reserved entrypoint-dispatcher variables before any resources are + // staged. Always on — unlike the isMoatOwnedProxyVar filter below, this + // must hold for grantless/proxyless runs too. + if err := validateReservedEnv(opts.Config, opts.Env); err != nil { + return nil, err + } + opts.Grants = normalizeCopilotGrantNames(opts.Grants) if opts.Config != nil { opts.Config.Grants = normalizeCopilotGrantNames(opts.Config.Grants) From 37140d1dfe867e46e737dd97da393b01a498261b Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:36:38 +0000 Subject: [PATCH 03/17] feat(moatinit): pure-logic phase decisions with unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 2 of the moat-init rewrite: the branch/mode/parse logic moves into table-tested Go — extra-hosts tokenizing (first-colon split, word-split, skip rules, @-target discrimination), the shared targetHome/ownership idiom, MOAT_INIT_FILES record parsing (exact POSIX IFS= read -r semantics, verified against a live /bin/sh — leading tabs strip, interior runs collapse, trailing runs trim), buffer-first base64 decode, docker dind/host-mode predicates and mutex, git config command assembly (insteadOf exact-"1" gate), and the workspace-volume gates (exact-"1" enable, staging default, verbatim exclude content, set -f chown paths). --- internal/moatinit/docker.go | 23 ++++++ internal/moatinit/docker_test.go | 71 +++++++++++++++++ internal/moatinit/git.go | 34 ++++++++ internal/moatinit/git_test.go | 57 ++++++++++++++ internal/moatinit/home.go | 40 ++++++++++ internal/moatinit/home_test.go | 62 +++++++++++++++ internal/moatinit/hosts.go | 47 ++++++++++++ internal/moatinit/hosts_test.go | 115 ++++++++++++++++++++++++++++ internal/moatinit/initfiles.go | 69 +++++++++++++++++ internal/moatinit/initfiles_test.go | 94 +++++++++++++++++++++++ internal/moatinit/volume.go | 36 +++++++++ internal/moatinit/volume_test.go | 54 +++++++++++++ 12 files changed, 702 insertions(+) create mode 100644 internal/moatinit/docker.go create mode 100644 internal/moatinit/docker_test.go create mode 100644 internal/moatinit/git.go create mode 100644 internal/moatinit/git_test.go create mode 100644 internal/moatinit/home.go create mode 100644 internal/moatinit/home_test.go create mode 100644 internal/moatinit/hosts.go create mode 100644 internal/moatinit/hosts_test.go create mode 100644 internal/moatinit/initfiles.go create mode 100644 internal/moatinit/initfiles_test.go create mode 100644 internal/moatinit/volume.go create mode 100644 internal/moatinit/volume_test.go diff --git a/internal/moatinit/docker.go b/internal/moatinit/docker.go new file mode 100644 index 00000000..686bfda7 --- /dev/null +++ b/internal/moatinit/docker.go @@ -0,0 +1,23 @@ +package moatinit + +// dockerMutexViolated mirrors DOCKER-01: MOAT_DOCKER_DIND and MOAT_DOCKER_GID +// are mutually exclusive whenever BOTH are non-empty (any values — the guard +// tests emptiness, not "1"). +func dockerMutexViolated(dind, gid string) bool { + return dind != "" && gid != "" +} + +// dindActive mirrors DOCKER-02: dind mode activates only when +// MOAT_DOCKER_DIND is exactly "1" AND the process is root. A non-root +// process with DIND=1 silently skips dind setup (no dockerd, no error). +func dindActive(dind string, euid int) bool { + return dind == "1" && euid == 0 +} + +// hostGIDActive mirrors DOCKER-09: host-socket mode activates only when +// MOAT_DOCKER_GID is non-empty (any value) AND the process is root AND +// /var/run/docker.sock is a socket. Any missing condition silently skips +// host-mode setup. +func hostGIDActive(gid string, euid int, socketPresent bool) bool { + return gid != "" && euid == 0 && socketPresent +} diff --git a/internal/moatinit/docker_test.go b/internal/moatinit/docker_test.go new file mode 100644 index 00000000..d29afb6b --- /dev/null +++ b/internal/moatinit/docker_test.go @@ -0,0 +1,71 @@ +package moatinit + +import "testing" + +func TestDockerMutexViolated(t *testing.T) { + tests := []struct { + dind, gid string + want bool + }{ + // DOCKER-01: any non-empty pair violates — the guard tests + // emptiness, not "1". + {"1", "999", true}, + {"0", "999", true}, + {"true", "x", true}, + // Companions: one or neither set never violates. + {"1", "", false}, + {"", "999", false}, + {"", "", false}, + } + for _, tt := range tests { + if got := dockerMutexViolated(tt.dind, tt.gid); got != tt.want { + t.Errorf("dockerMutexViolated(%q, %q) = %v, want %v", tt.dind, tt.gid, got, tt.want) + } + } +} + +// TestDindActive covers the DOCKER-02 matrix {1,0,true,”}×{root,non-root}. +func TestDindActive(t *testing.T) { + tests := []struct { + dind string + euid int + want bool + }{ + {"1", 0, true}, + // Non-root with DIND=1 silently skips (no dockerd, no error). + {"1", 1000, false}, + {"0", 0, false}, + {"true", 0, false}, + {"", 0, false}, + {"", 1000, false}, + } + for _, tt := range tests { + if got := dindActive(tt.dind, tt.euid); got != tt.want { + t.Errorf("dindActive(%q, %d) = %v, want %v", tt.dind, tt.euid, got, tt.want) + } + } +} + +// TestHostGIDActive covers the DOCKER-09 matrix +// {gid set/unset}×{root/non-root}×{socket present/absent}. +func TestHostGIDActive(t *testing.T) { + tests := []struct { + gid string + euid int + socket bool + want bool + }{ + {"999", 0, true, true}, + // Any value activates, not just numeric — the guard tests emptiness. + {"docker", 0, true, true}, + {"999", 0, false, false}, + {"999", 1000, true, false}, + {"", 0, true, false}, + {"", 1000, false, false}, + } + for _, tt := range tests { + if got := hostGIDActive(tt.gid, tt.euid, tt.socket); got != tt.want { + t.Errorf("hostGIDActive(%q, %d, %v) = %v, want %v", tt.gid, tt.euid, tt.socket, got, tt.want) + } + } +} diff --git a/internal/moatinit/git.go b/internal/moatinit/git.go new file mode 100644 index 00000000..40f26fde --- /dev/null +++ b/internal/moatinit/git.go @@ -0,0 +1,34 @@ +package moatinit + +// gitConfigCommands assembles the `git config --system` invocations for the +// git-configuration phase, in script order (GIT-02..GIT-06): +// +// 1. safe.directory /workspace — always (whitelists exactly /workspace, +// nothing else; do not broaden or narrow) +// 2. user.name — only when MOAT_GIT_USER_NAME is non-empty +// 3. user.email — only when MOAT_GIT_USER_EMAIL is non-empty (independent +// of user.name; either may be set without the other) +// 4. http.proxyAuthMethod basic — always (git does not retry after the +// proxy's 407 CONNECT challenge; see issue #370) +// 5. url."git@github.com:".insteadOf — only when MOAT_GIT_SSH_GITHUB is +// exactly "1" (opt-out is "0"; any other value also skips) +// +// Every command is best-effort at execution time (stderr discarded, failure +// ignored — GIT-07); the caller only runs them when a git binary is on PATH +// (GIT-01). +func gitConfigCommands(cfg *Config) [][]string { + cmds := [][]string{ + {"git", "config", "--system", "--add", "safe.directory", "/workspace"}, + } + if cfg.GitUserName != "" { + cmds = append(cmds, []string{"git", "config", "--system", "user.name", cfg.GitUserName}) + } + if cfg.GitUserEmail != "" { + cmds = append(cmds, []string{"git", "config", "--system", "user.email", cfg.GitUserEmail}) + } + cmds = append(cmds, []string{"git", "config", "--system", "http.proxyAuthMethod", "basic"}) + if cfg.GitSSHGitHub == "1" { + cmds = append(cmds, []string{"git", "config", "--system", "url.git@github.com:.insteadOf", "https://github.com/"}) + } + return cmds +} diff --git a/internal/moatinit/git_test.go b/internal/moatinit/git_test.go new file mode 100644 index 00000000..5af1324d --- /dev/null +++ b/internal/moatinit/git_test.go @@ -0,0 +1,57 @@ +package moatinit + +import ( + "reflect" + "testing" +) + +func TestGitConfigCommands(t *testing.T) { + safeDir := []string{"git", "config", "--system", "--add", "safe.directory", "/workspace"} + proxyAuth := []string{"git", "config", "--system", "http.proxyAuthMethod", "basic"} + insteadOf := []string{"git", "config", "--system", "url.git@github.com:.insteadOf", "https://github.com/"} + + tests := []struct { + name string + cfg Config + want [][]string + }{ + // GIT-02/GIT-05: safe.directory and proxyAuthMethod are + // unconditional (present with no MOAT_GIT_* env at all). + {"no env", Config{}, [][]string{safeDir, proxyAuth}}, + // GIT-03: user.name only. + { + "name only", + Config{GitUserName: "Ada Lovelace"}, + [][]string{safeDir, {"git", "config", "--system", "user.name", "Ada Lovelace"}, proxyAuth}, + }, + // GIT-04: user.email independent of user.name. + { + "email only", + Config{GitUserEmail: "ada@example.com"}, + [][]string{safeDir, {"git", "config", "--system", "user.email", "ada@example.com"}, proxyAuth}, + }, + { + "both identity", + Config{GitUserName: "Ada", GitUserEmail: "ada@example.com"}, + [][]string{ + safeDir, + {"git", "config", "--system", "user.name", "Ada"}, + {"git", "config", "--system", "user.email", "ada@example.com"}, + proxyAuth, + }, + }, + // GIT-06: insteadOf requires exactly "1"... + {"ssh github on", Config{GitSSHGitHub: "1"}, [][]string{safeDir, proxyAuth, insteadOf}}, + // ...companions: "0", "true", and empty all skip it. + {"ssh github opt-out", Config{GitSSHGitHub: "0"}, [][]string{safeDir, proxyAuth}}, + {"ssh github non-1", Config{GitSSHGitHub: "true"}, [][]string{safeDir, proxyAuth}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gitConfigCommands(&tt.cfg) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("gitConfigCommands() =\n%v\nwant\n%v", got, tt.want) + } + }) + } +} diff --git a/internal/moatinit/home.go b/internal/moatinit/home.go new file mode 100644 index 00000000..eb16a675 --- /dev/null +++ b/internal/moatinit/home.go @@ -0,0 +1,40 @@ +package moatinit + +// targetHome is the repeated root-detection + home-selection idiom +// (X-TARGETHOME-IDIOM), shared by the agent-staging blocks, the init-files +// block, and the workspace .mcp.json / volume-chown guards: +// +// if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then +// TARGET_HOME="/home/moatuser" +// else +// TARGET_HOME="$HOME" +// fi +// +// When root with a moatuser account, staged files go under the hardcoded +// literal /home/moatuser — deliberately NOT moatuser's passwd home entry +// (parity: a custom base image whose moatuser home differs still stages to +// /home/moatuser, byte-for-byte like the script). Otherwise the current +// $HOME. +func targetHome(euid int, moatuserExists bool, home string) string { + if euid == 0 && moatuserExists { + return "/home/moatuser" + } + return home +} + +// chownToMoatuser reports whether ownership fixups run (the same +// root+moatuser predicate; on any other branch no chown of any kind +// happens — files stay owned by the writing process). +func chownToMoatuser(euid int, moatuserExists bool) bool { + return euid == 0 && moatuserExists +} + +// initFilesOwnership mirrors the init-files ownership resolution (INIT-02): +// the ancestor-chown walk stops at initHome, which is /home/moatuser on the +// chown path and $HOME otherwise. +func initFilesOwnership(euid int, moatuserExists bool, home string) (chown bool, initHome string) { + if euid == 0 && moatuserExists { + return true, "/home/moatuser" + } + return false, home +} diff --git a/internal/moatinit/home_test.go b/internal/moatinit/home_test.go new file mode 100644 index 00000000..a74dd350 --- /dev/null +++ b/internal/moatinit/home_test.go @@ -0,0 +1,62 @@ +package moatinit + +import "testing" + +// TestTargetHome covers the X-TARGETHOME-IDIOM matrix over +// {root, non-root} × {moatuser present, absent}. +func TestTargetHome(t *testing.T) { + tests := []struct { + name string + euid int + moatuser bool + home string + want string + }{ + // Root + moatuser: the hardcoded literal, NOT moatuser's passwd home. + {"root with moatuser", 0, true, "/root", "/home/moatuser"}, + // Root without moatuser: falls through to $HOME (files stage there; + // the exec dispatch later fails closed). + {"root without moatuser", 0, false, "/root", "/root"}, + {"non-root with moatuser", 1000, true, "/tmp/h", "/tmp/h"}, + {"non-root without moatuser", 1000, false, "/tmp/h", "/tmp/h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := targetHome(tt.euid, tt.moatuser, tt.home); got != tt.want { + t.Errorf("targetHome(%d, %v, %q) = %q, want %q", tt.euid, tt.moatuser, tt.home, got, tt.want) + } + // The chown predicate mirrors the same branch: fixups run only + // on the root+moatuser path. + wantChown := tt.euid == 0 && tt.moatuser + if got := chownToMoatuser(tt.euid, tt.moatuser); got != wantChown { + t.Errorf("chownToMoatuser(%d, %v) = %v, want %v", tt.euid, tt.moatuser, got, wantChown) + } + }) + } +} + +// TestInitFilesOwnership covers the INIT-02 four-branch table. +func TestInitFilesOwnership(t *testing.T) { + tests := []struct { + name string + euid int + moatuser bool + home string + wantChown bool + wantInitHome string + }{ + {"root with moatuser", 0, true, "/root", true, "/home/moatuser"}, + {"root without moatuser", 0, false, "/root", false, "/root"}, + {"non-root with moatuser", 1000, true, "/tmp/h", false, "/tmp/h"}, + {"non-root without moatuser", 1000, false, "/tmp/h", false, "/tmp/h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chown, initHome := initFilesOwnership(tt.euid, tt.moatuser, tt.home) + if chown != tt.wantChown || initHome != tt.wantInitHome { + t.Errorf("initFilesOwnership(%d, %v, %q) = (%v, %q), want (%v, %q)", + tt.euid, tt.moatuser, tt.home, chown, initHome, tt.wantChown, tt.wantInitHome) + } + }) + } +} diff --git a/internal/moatinit/hosts.go b/internal/moatinit/hosts.go new file mode 100644 index 00000000..537fe17e --- /dev/null +++ b/internal/moatinit/hosts.go @@ -0,0 +1,47 @@ +package moatinit + +import "strings" + +// hostEntry is one parsed MOAT_EXTRA_HOSTS token ("name:target"). +type hostEntry struct { + name string + target string +} + +// splitExtraHosts mirrors the script's unquoted `for entry in +// $MOAT_EXTRA_HOSTS` (HOSTS-02): word-splitting on default IFS — space, tab, +// and newline — with runs of separators collapsing so no empty tokens are +// produced. +func splitExtraHosts(v string) []string { + return strings.Fields(v) +} + +// parseHostEntry splits a token on its FIRST colon (HOSTS-03): +// name=${entry%%:*} (everything before the first colon) and +// target=${entry#*:} (everything after it). A colon-less token leaves both +// parameter expansions unchanged, so name == target == token — which the +// skip rule then drops. +func parseHostEntry(tok string) hostEntry { + idx := strings.IndexByte(tok, ':') + if idx < 0 { + return hostEntry{name: tok, target: tok} + } + return hostEntry{name: tok[:idx], target: tok[idx+1:]} +} + +// skip mirrors the script's continue (HOSTS-04): drop malformed entries +// ("name:", ":target") and colon-less tokens (name == target). +func (e hostEntry) skip() bool { + return e.name == "" || e.target == "" || e.name == e.target +} + +// resolveTarget discriminates the target (HOSTS-05): a leading '@' marks a +// hostname to resolve via the container's DNS ("@host.docker.internal"); +// anything else is a literal IP written verbatim (including odd values like +// "a@b" or "::1" — no validation, parity with the script's `case` patterns). +func (e hostEntry) resolveTarget() (hostname string, resolve bool) { + if strings.HasPrefix(e.target, "@") { + return strings.TrimPrefix(e.target, "@"), true + } + return "", false +} diff --git a/internal/moatinit/hosts_test.go b/internal/moatinit/hosts_test.go new file mode 100644 index 00000000..c3c98f67 --- /dev/null +++ b/internal/moatinit/hosts_test.go @@ -0,0 +1,115 @@ +package moatinit + +import ( + "reflect" + "testing" +) + +func TestSplitExtraHosts(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"spaces only", " ", nil}, + {"single", "a:1", []string{"a:1"}}, + // HOSTS-02: double space yields exactly two tokens, no empty third. + {"double space", "a:1 b:2", []string{"a:1", "b:2"}}, + // Default IFS also splits on tabs and newlines. + {"tab and newline separators", "a:1\tb:2\nc:3", []string{"a:1", "b:2", "c:3"}}, + {"leading/trailing whitespace", " a:1 ", []string{"a:1"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitExtraHosts(tt.in) + if len(got) == 0 && len(tt.want) == 0 { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitExtraHosts(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestParseHostEntry(t *testing.T) { + tests := []struct { + tok string + wantName string + wantTarget string + }{ + // HOSTS-03: split on the FIRST colon. + {"x:a:b", "x", "a:b"}, + {"x:", "x", ""}, + {":y", "", "y"}, + // Colon-less: both parameter expansions leave the token unchanged. + {"z", "z", "z"}, + // First colon at position 0 for a bare IPv6-ish token. + {"::1", "", ":1"}, + {"moat-proxy:192.0.2.5", "moat-proxy", "192.0.2.5"}, + {"moat-host:@host.docker.internal", "moat-host", "@host.docker.internal"}, + } + for _, tt := range tests { + t.Run(tt.tok, func(t *testing.T) { + e := parseHostEntry(tt.tok) + if e.name != tt.wantName || e.target != tt.wantTarget { + t.Errorf("parseHostEntry(%q) = {name:%q target:%q}, want {name:%q target:%q}", + tt.tok, e.name, e.target, tt.wantName, tt.wantTarget) + } + }) + } +} + +func TestHostEntrySkip(t *testing.T) { + tests := []struct { + tok string + want bool + }{ + // HOSTS-04: malformed entries are skipped... + {"moat-proxy:", true}, + {":1.2.3.4", true}, + {"foo", true}, + {"x:x", true}, + // ...but 'moat-proxy:@' is NOT skipped here (target "@" is + // non-empty); it advances to the resolve branch and fails there. + {"moat-proxy:@", false}, + {"a:1", false}, + } + for _, tt := range tests { + t.Run(tt.tok, func(t *testing.T) { + if got := parseHostEntry(tt.tok).skip(); got != tt.want { + t.Errorf("skip(%q) = %v, want %v", tt.tok, got, tt.want) + } + }) + } +} + +func TestResolveTarget(t *testing.T) { + tests := []struct { + target string + wantHostname string + wantResolve bool + }{ + // HOSTS-05: '@'-prefix means resolve; anything else is literal. + {"@foo", "foo", true}, + {"@host.docker.internal", "host.docker.internal", true}, + {"192.168.64.1", "", false}, + // 'a@b' is a literal target, not a resolve form. + {"a@b", "", false}, + // '::1' is written verbatim as a literal. + {"::1", "", false}, + // Bare '@' resolves the empty hostname (which then fails the + // resolve loop and exits 1 — parity with the script). + {"@", "", true}, + } + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + host, resolve := hostEntry{name: "n", target: tt.target}.resolveTarget() + if host != tt.wantHostname || resolve != tt.wantResolve { + t.Errorf("resolveTarget(%q) = (%q, %v), want (%q, %v)", + tt.target, host, resolve, tt.wantHostname, tt.wantResolve) + } + }) + } +} diff --git a/internal/moatinit/initfiles.go b/internal/moatinit/initfiles.go new file mode 100644 index 00000000..a38f9b73 --- /dev/null +++ b/internal/moatinit/initfiles.go @@ -0,0 +1,69 @@ +package moatinit + +import ( + "encoding/base64" + "strings" +) + +// initFileRecord is one MOAT_INIT_FILES record: an absolute path and its +// base64-encoded content. +type initFileRecord struct { + path string + content string +} + +// parseInitFiles mirrors the script's record loop: +// +// printf '%s\n' "$MOAT_INIT_FILES" | while IFS="$(printf '\t')" read -r filepath content +// +// Records are newline-separated. Field splitting follows POSIX `read` with +// IFS set to a single tab — and because tab is IFS *whitespace*, the +// semantics are subtler than "split on first tab" (verified against a live +// /bin/sh; see TestSplitInitRecord): +// +// - leading tabs are stripped, so a leading-tab record yields the payload +// as the PATH (not an empty path) +// - the delimiter run after the first field is consumed entirely +// - interior tabs inside the remainder are preserved (extra fields land in +// content), but trailing tabs are trimmed from it +// +// An empty line yields an empty path, which the phase skips (INIT-04) — that +// is what makes a trailing newline harmless. In practice the producer +// (internal/run) only emits \t records; these edge rules +// exist for byte-parity with the shell on malformed input. +func parseInitFiles(v string) []initFileRecord { + lines := strings.Split(v, "\n") + recs := make([]initFileRecord, 0, len(lines)) + for _, line := range lines { + path, content := splitInitRecord(line) + recs = append(recs, initFileRecord{path: path, content: content}) + } + return recs +} + +// splitInitRecord applies the IFS= read -r splitting rules above to a +// single record. +func splitInitRecord(line string) (path, content string) { + line = strings.TrimLeft(line, "\t") + idx := strings.IndexByte(line, '\t') + if idx < 0 { + return line, "" + } + path = line[:idx] + content = strings.TrimLeft(line[idx:], "\t") + content = strings.TrimRight(content, "\t") + return path, content +} + +// decodeInitContent decodes a record's base64 payload (INIT-06). Go's +// StdEncoding decoder already ignores \r and \n like coreutils `base64 -d` +// (embedded newlines cannot occur here anyway — a newline would split the +// record), and rejects other non-alphabet bytes exactly as `base64 -d` +// rejects "invalid input". Decoding happens to a buffer BEFORE any file is +// touched, so an invalid payload aborts fail-closed without leaving a +// partial secret on disk (plan Appendix B P1; the shell's `base64 -d > +// "$filepath"` could leave a truncated file behind before aborting — the +// buffer-first port is the sanctioned hardening of that same fatal path). +func decodeInitContent(content string) ([]byte, error) { + return base64.StdEncoding.DecodeString(content) +} diff --git a/internal/moatinit/initfiles_test.go b/internal/moatinit/initfiles_test.go new file mode 100644 index 00000000..85b7e553 --- /dev/null +++ b/internal/moatinit/initfiles_test.go @@ -0,0 +1,94 @@ +package moatinit + +import ( + "bytes" + "encoding/base64" + "testing" +) + +// TestSplitInitRecord pins the exact POSIX `IFS= read -r filepath +// content` splitting semantics. Because tab is IFS *whitespace*, the rules +// differ from a naive "split on first tab"; every case below was verified +// against a live /bin/sh running the script's actual loop. +func TestSplitInitRecord(t *testing.T) { + tests := []struct { + name string + line string + wantPath string + wantContent string + }{ + {"simple record", "a\tXX", "a", "XX"}, + // Leading tabs are IFS whitespace: stripped, so the payload becomes + // the PATH — not an empty path. + {"leading tab", "\tXX", "XX", ""}, + {"multiple leading tabs", "\t\tXX", "XX", ""}, + {"no tab", "a", "a", ""}, + // Extra tabs land in content (interior runs preserved)... + {"extra fields", "a\tb\tc", "a", "b\tc"}, + // ...but the delimiter run after the path collapses entirely... + {"adjacent delimiter tabs", "a\t\tb", "a", "b"}, + // ...and trailing tabs are trimmed from the last field. + {"trailing tab", "a\tb\t", "a", "b"}, + {"trailing tab run", "a\tb\t\t", "a", "b"}, + {"only trailing tab", "a\t", "a", ""}, + // An empty line yields an empty path — the skip rule (INIT-04) that + // makes MOAT_INIT_FILES' trailing newline harmless. + {"empty line", "", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, content := splitInitRecord(tt.line) + if path != tt.wantPath || content != tt.wantContent { + t.Errorf("splitInitRecord(%q) = (%q, %q), want (%q, %q)", + tt.line, path, content, tt.wantPath, tt.wantContent) + } + }) + } +} + +func TestParseInitFiles(t *testing.T) { + in := "/home/moatuser/.config/a\tYWJj\n/etc/b\tZGVm\n" + recs := parseInitFiles(in) + if len(recs) != 3 { + t.Fatalf("got %d records, want 3 (two payloads + empty trailing line)", len(recs)) + } + if recs[0].path != "/home/moatuser/.config/a" || recs[0].content != "YWJj" { + t.Errorf("record 0 = %+v", recs[0]) + } + if recs[1].path != "/etc/b" || recs[1].content != "ZGVm" { + t.Errorf("record 1 = %+v", recs[1]) + } + // The trailing empty line parses to an empty path (skipped by the phase). + if recs[2].path != "" { + t.Errorf("record 2 path = %q, want empty", recs[2].path) + } +} + +func TestDecodeInitContent(t *testing.T) { + // Round-trip, including binary content and no added trailing newline + // (INIT-06: printf '%s' | base64 -d writes exactly the decoded bytes). + secret := []byte("token = \"abc123\"\x00\xff") + got, err := decodeInitContent(base64.StdEncoding.EncodeToString(secret)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !bytes.Equal(got, secret) { + t.Errorf("decoded %q, want %q", got, secret) + } + + // Empty content decodes to an empty file, not an error. + if got, err := decodeInitContent(""); err != nil || len(got) != 0 { + t.Errorf("decodeInitContent(\"\") = (%q, %v), want empty, nil", got, err) + } + + // Wrapped payloads (embedded CR/LF) decode like coreutils base64 -d. + if got, err := decodeInitContent("YW\r\nJj"); err != nil || string(got) != "abc" { + t.Errorf("wrapped payload = (%q, %v), want (abc, nil)", got, err) + } + + // Companion: invalid base64 fails closed (the phase aborts before any + // file is written and before exec). + if _, err := decodeInitContent("!!!not-base64!!!"); err == nil { + t.Error("decodeInitContent(invalid) = nil error, want failure") + } +} diff --git a/internal/moatinit/volume.go b/internal/moatinit/volume.go new file mode 100644 index 00000000..e369c6eb --- /dev/null +++ b/internal/moatinit/volume.go @@ -0,0 +1,36 @@ +package moatinit + +import "strings" + +// workspaceVolumeEnabled mirrors WS-01: populate_workspace_volume runs only +// when MOAT_WORKSPACE_VOLUME is exactly the string "1" — not "true", "01", +// " 1", or any other value. +func workspaceVolumeEnabled(v string) bool { + return v == "1" +} + +// stagingDir mirrors WS-03: `staging="${MOAT_WORKSPACE_STAGING:-/mnt/host-workspace}"` +// — the `:-` expansion defaults on unset AND on empty. +func stagingDir(staging string) string { + if staging == "" { + return "/mnt/host-workspace" + } + return staging +} + +// excludeFileContent mirrors WS-04: the exclude file is created empty and, +// when MOAT_WORKSPACE_EXCLUDES is non-empty, receives the value verbatim +// (printf '%s' — no trailing newline appended). Patterns are +// newline-delimited "./"-prefixed paths produced by run.workspaceExcludes; +// an empty exclude file excludes nothing (WS-06). +func excludeFileContent(excludes string) string { + return excludes +} + +// volumeChownPaths mirrors the named-volume chown loop's word-splitting: +// `set -f` disables glob expansion (a target containing [ ] * ? is treated +// literally, never expanded against the filesystem), while word-splitting on +// default IFS stays on — the paths are space-separated. +func volumeChownPaths(v string) []string { + return strings.Fields(v) +} diff --git a/internal/moatinit/volume_test.go b/internal/moatinit/volume_test.go new file mode 100644 index 00000000..49582bab --- /dev/null +++ b/internal/moatinit/volume_test.go @@ -0,0 +1,54 @@ +package moatinit + +import ( + "reflect" + "testing" +) + +// TestWorkspaceVolumeEnabled covers the WS-01 gate: only the exact string +// "1" activates the populate. +func TestWorkspaceVolumeEnabled(t *testing.T) { + if !workspaceVolumeEnabled("1") { + t.Error(`workspaceVolumeEnabled("1") = false, want true`) + } + for _, v := range []string{"", "0", "true", " 1", "01", "yes"} { + if workspaceVolumeEnabled(v) { + t.Errorf("workspaceVolumeEnabled(%q) = true, want false", v) + } + } +} + +// TestStagingDir covers WS-03: the :- expansion defaults on empty (and +// therefore unset), and passes explicit values through. +func TestStagingDir(t *testing.T) { + if got := stagingDir(""); got != "/mnt/host-workspace" { + t.Errorf(`stagingDir("") = %q, want /mnt/host-workspace`, got) + } + if got := stagingDir("/custom"); got != "/custom" { + t.Errorf(`stagingDir("/custom") = %q, want /custom`, got) + } +} + +// TestExcludeFileContent covers WS-04/WS-06: the env value is written +// verbatim (no trailing newline appended), empty stays empty. +func TestExcludeFileContent(t *testing.T) { + if got := excludeFileContent("./node_modules\n./dist/sub"); got != "./node_modules\n./dist/sub" { + t.Errorf("excludeFileContent altered the value: %q", got) + } + if got := excludeFileContent(""); got != "" { + t.Errorf(`excludeFileContent("") = %q, want empty`, got) + } +} + +// TestVolumeChownPaths covers the named-volume chown splitting: space +// separated, glob characters kept literal (set -f semantics). +func TestVolumeChownPaths(t *testing.T) { + got := volumeChownPaths("/r/[x] /r/normal /r/star*") + want := []string{"/r/[x]", "/r/normal", "/r/star*"} + if !reflect.DeepEqual(got, want) { + t.Errorf("volumeChownPaths() = %v, want %v", got, want) + } + if paths := volumeChownPaths(""); len(paths) != 0 { + t.Errorf("volumeChownPaths(\"\") = %v, want empty", paths) + } +} From 39d455f56b5e622c1ad187e0e6939c4da6215ea3 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:44:19 +0000 Subject: [PATCH 04/17] =?UTF-8?q?feat(moatinit):=20filesystem=20phases=20?= =?UTF-8?q?=E2=80=94=20hosts,=20agent=20staging,=20init=20files,=20workspa?= =?UTF-8?q?ce=20mcp.json,=20git=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3 of the moat-init rewrite: the fail-closed /etc/hosts injection (exact three-line error contracts, IPv4-preferred resolve with the 25x0.2s budget, sanctioned IPv6/loopback-fallback warning), the four agent staging blocks (allowlist copies, cp -p mode preservation, the chmod-600 secret contracts, best-effort lchown hand-off), MOAT_INIT_FILES writes (0600 files, 0755 immediate parent, ancestor-chown walk stopping at INIT_HOME, decode-to-buffer fail-closed on invalid base64, unset after the loop), workspace .mcp.json copy, and best-effort git --system config gated on a git binary. Integration tests run the real phase bodies against a t.TempDir()-rooted Sys with recorded chowns and a stubbed resolver; a real-git test verifies the assembled argv against GIT_CONFIG_SYSTEM. --- internal/moatinit/agents.go | 145 ++++++++++++ internal/moatinit/agents_phase_test.go | 258 ++++++++++++++++++++++ internal/moatinit/fsutil.go | 38 ++++ internal/moatinit/git.go | 16 ++ internal/moatinit/hosts.go | 83 ++++++- internal/moatinit/hosts_phase_test.go | 173 +++++++++++++++ internal/moatinit/initfiles.go | 73 ++++++ internal/moatinit/initfiles_phase_test.go | 145 ++++++++++++ internal/moatinit/mcp.go | 32 +++ internal/moatinit/mcp_git_phase_test.go | 143 ++++++++++++ internal/moatinit/phase.go | 18 +- internal/moatinit/phase_test.go | 7 +- internal/moatinit/testsys_test.go | 129 +++++++++++ 13 files changed, 1251 insertions(+), 9 deletions(-) create mode 100644 internal/moatinit/agents.go create mode 100644 internal/moatinit/agents_phase_test.go create mode 100644 internal/moatinit/fsutil.go create mode 100644 internal/moatinit/hosts_phase_test.go create mode 100644 internal/moatinit/initfiles_phase_test.go create mode 100644 internal/moatinit/mcp.go create mode 100644 internal/moatinit/mcp_git_phase_test.go create mode 100644 internal/moatinit/testsys_test.go diff --git a/internal/moatinit/agents.go b/internal/moatinit/agents.go new file mode 100644 index 00000000..2740036f --- /dev/null +++ b/internal/moatinit/agents.go @@ -0,0 +1,145 @@ +package moatinit + +import ( + "fmt" + "path/filepath" +) + +// stagedEntry is one allowlisted item an agent staging block may copy. The +// blocks copy ONLY explicitly named files — an allowlist, never a recursive +// copy of the staging dir (AGENT-NO-EXTRANEOUS-COPY): a stray file in the +// staging mount must not leak into the home directory. +type stagedEntry struct { + name string // file (or dir) name inside the staging dir + secret bool // chmod 600 after the copy — the credential-file contract + tree bool // directory copied with cp -rp instead of cp -p + home bool // destination is $TARGET_HOME itself, not the agent dir +} + +// claudeStagingPhase mirrors the Claude Code setup block. +func claudeStagingPhase(ctx *Context) error { + return stageAgent(ctx, "claude", ctx.Cfg.ClaudeInit, ".claude", []stagedEntry{ + {name: "settings.json"}, + // Plugins are baked into the image at build time; settings.json + // above carries the marketplace config (AGENT-CLAUDE-NO-PLUGINS-COPY). + {name: ".credentials.json", secret: true}, + // Server-managed settings cache; prevents a managed-settings + // approval prompt on every container start. + {name: "remote-settings.json", secret: true}, + {name: "statsig", tree: true}, + {name: "stats-cache.json"}, + {name: "CLAUDE.md"}, + // Onboarding/trust state lands at the HOME ROOT, not in .claude/. + {name: ".claude.json", home: true}, + }) +} + +// codexStagingPhase mirrors the Codex CLI setup block. +func codexStagingPhase(ctx *Context) error { + return stageAgent(ctx, "codex", ctx.Cfg.CodexInit, ".codex", []stagedEntry{ + {name: "config.toml"}, + {name: "auth.json", secret: true}, + {name: "AGENTS.md"}, + }) +} + +// geminiStagingPhase mirrors the Gemini CLI setup block. Note settings.json +// is NOT a secret here: its source mode is preserved, only oauth_creds.json +// gets the forced 0600 (AGENT-GEMINI-CP-SETTINGS vs -CP-OAUTHCREDS). +func geminiStagingPhase(ctx *Context) error { + return stageAgent(ctx, "gemini", ctx.Cfg.GeminiInit, ".gemini", []stagedEntry{ + {name: "settings.json"}, + {name: "oauth_creds.json", secret: true}, + {name: "GEMINI.md"}, + }) +} + +// copilotStagingPhase mirrors the GitHub Copilot CLI setup block (runtime +// context stays mounted in the staging dir and is referenced via +// COPILOT_CUSTOM_INSTRUCTIONS_DIRS, so only config/state files are copied). +func copilotStagingPhase(ctx *Context) error { + return stageAgent(ctx, "copilot", ctx.Cfg.CopilotInit, ".copilot", []stagedEntry{ + {name: "config.json"}, + {name: "settings.json"}, + {name: "permissions-config.json"}, + }) +} + +// stageAgent is the shared body of the four agent staging blocks: +// +// - gated on the staging env var being non-empty AND naming a directory +// - TARGET_HOME recomputed per block via the shared root idiom +// - mkdir -p of the agent dir (unguarded under set -e: fatal on failure) +// - each allowlisted file that exists is copied with cp -p (fatal on +// failure); secret files additionally chmod 600 (fatal) because cp -p +// preserves the SOURCE mode and credentials must never stay group/world +// readable +// - on the root+moatuser path, a best-effort recursive chown of the agent +// dir, then best-effort chowns of any home-root files +func stageAgent(ctx *Context, agent, staging, agentDir string, entries []stagedEntry) error { + cfg, sys := ctx.Cfg, ctx.Sys + if staging == "" || !isDir(sys, staging) { + return nil + } + + home := targetHome(sys.Geteuid(), moatuserExists(sys), cfg.Home) + destDir := filepath.Join(home, agentDir) + if err := sys.MkdirAll(destDir, 0o755); err != nil { + return fatalPhaseError(ctx, "creating "+destDir, err) + } + + for _, e := range entries { + src := filepath.Join(staging, e.name) + switch { + case e.tree: + if !isDir(sys, src) { + continue + } + if err := sys.CopyTreePreserving(src, filepath.Join(destDir, e.name)); err != nil { + return fatalPhaseError(ctx, "staging "+agent+" "+e.name, err) + } + default: + if !isFile(sys, src) { + continue + } + dst := filepath.Join(destDir, e.name) + if e.home { + dst = filepath.Join(home, e.name) + } + if err := sys.CopyFilePreserving(src, dst); err != nil { + return fatalPhaseError(ctx, "staging "+agent+" "+e.name, err) + } + if e.secret { + if err := sys.Chmod(dst, 0o600); err != nil { + return fatalPhaseError(ctx, "restricting "+dst, err) + } + } + } + } + + // Ownership hand-off (best-effort, silent — the copies themselves are + // the contract; a chown failure must not abort the start). + if chownToMoatuser(sys.Geteuid(), moatuserExists(sys)) { + if u, ok := sys.LookupUser("moatuser"); ok { + recursiveChownBestEffort(sys, destDir, u.UID, u.GID) + for _, e := range entries { + if !e.home { + continue + } + dst := filepath.Join(home, e.name) + if isFile(sys, dst) { + _ = sys.Chown(dst, u.UID, u.GID) + } + } + } + } + return nil +} + +// fatalPhaseError reports an unguarded operation failure — the Go +// equivalent of set -e aborting the script mid-block — and returns the +// exit-1 sentinel. +func fatalPhaseError(ctx *Context, op string, err error) error { + fmt.Fprintf(ctx.Stderr, "moat-init: %s: %v\n", op, err) + return exitError{code: 1} +} diff --git a/internal/moatinit/agents_phase_test.go b/internal/moatinit/agents_phase_test.go new file mode 100644 index 00000000..e7e3fd38 --- /dev/null +++ b/internal/moatinit/agents_phase_test.go @@ -0,0 +1,258 @@ +package moatinit + +import ( + "os" + "path/filepath" + "testing" +) + +// stage writes a file into a host-side staging dir (absolute, outside the +// injected root — staging mounts are read through the same seam, so place +// them inside the root for the test). +func stageFile(t *testing.T, ts *testSys, stagingRel, name string, mode os.FileMode, content string) string { + t.Helper() + dir := filepath.Join(ts.Root, stagingRel) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), mode); err != nil { + t.Fatal(err) + } + // Explicit chmod: WriteFile honors umask at creation. + if err := os.Chmod(filepath.Join(dir, name), mode); err != nil { + t.Fatal(err) + } + return "/" + stagingRel +} + +func statMode(t *testing.T, ts *testSys, path string) os.FileMode { + t.Helper() + info, err := os.Stat(filepath.Join(ts.Root, filepath.FromSlash(path[1:]))) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return info.Mode().Perm() +} + +func fileContent(t *testing.T, ts *testSys, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(ts.Root, filepath.FromSlash(path[1:]))) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func exists(ts *testSys, path string) bool { + _, err := os.Lstat(filepath.Join(ts.Root, filepath.FromSlash(path[1:]))) + return err == nil +} + +func TestClaudeStagingFullSet(t *testing.T) { + ts := newTestSys(t, 0, true) + staging := stageFile(t, ts, "mnt/claude-init", "settings.json", 0o640, `{"s":1}`) + stageFile(t, ts, "mnt/claude-init", ".credentials.json", 0o644, `{"token":"x"}`) + stageFile(t, ts, "mnt/claude-init", "remote-settings.json", 0o644, `{"r":1}`) + stageFile(t, ts, "mnt/claude-init", "stats-cache.json", 0o644, `{}`) + stageFile(t, ts, "mnt/claude-init", "CLAUDE.md", 0o644, "ctx") + stageFile(t, ts, "mnt/claude-init", ".claude.json", 0o644, `{"onboarded":true}`) + stageFile(t, ts, "mnt/claude-init/statsig", "cache.db", 0o600, "st") + // Allowlist companions: strays must NOT be copied. + stageFile(t, ts, "mnt/claude-init", "stray.txt", 0o644, "no") + stageFile(t, ts, "mnt/claude-init", "mcp.json", 0o644, "no") + + ctx, _ := newTestContext(ts, Config{ClaudeInit: staging, Home: "/root"}) + if err := claudeStagingPhase(ctx); err != nil { + t.Fatal(err) + } + + // Root+moatuser: TARGET_HOME is the hardcoded /home/moatuser. + if got := fileContent(t, ts, "/home/moatuser/.claude/settings.json"); got != `{"s":1}` { + t.Errorf("settings.json = %q", got) + } + // Non-secret modes preserved (cp -p). + if got := statMode(t, ts, "/home/moatuser/.claude/settings.json"); got != 0o640 { + t.Errorf("settings.json mode = %o, want 640 (preserved)", got) + } + // The four-secret contract: 0600 regardless of source mode. + for _, p := range []string{"/home/moatuser/.claude/.credentials.json", "/home/moatuser/.claude/remote-settings.json"} { + if got := statMode(t, ts, p); got != 0o600 { + t.Errorf("%s mode = %o, want 600", p, got) + } + } + // statsig dir copied recursively with modes preserved. + if got := statMode(t, ts, "/home/moatuser/.claude/statsig/cache.db"); got != 0o600 { + t.Errorf("statsig/cache.db mode = %o", got) + } + // .claude.json lands at the HOME ROOT, not inside .claude/. + if !exists(ts, "/home/moatuser/.claude.json") { + t.Error(".claude.json missing from home root") + } + if exists(ts, "/home/moatuser/.claude/.claude.json") { + t.Error(".claude.json wrongly copied into .claude/") + } + // Allowlist: strays absent. + for _, p := range []string{"/home/moatuser/.claude/stray.txt", "/home/moatuser/.claude/mcp.json"} { + if exists(ts, p) { + t.Errorf("stray file leaked: %s", p) + } + } + // Ownership hand-off recorded: the dir tree recursively (via lchown) + // plus the home-root .claude.json. + if !ts.chowned("/home/moatuser/.claude/.credentials.json") { + t.Error("no chown recorded for .credentials.json") + } + if !ts.chowned("/home/moatuser/.claude.json") { + t.Error("no chown recorded for home-root .claude.json") + } +} + +func TestAgentStagingGates(t *testing.T) { + // Gate companions: unset var, empty dir path, and file-not-dir all skip. + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{Home: "/root"}) + if err := claudeStagingPhase(ctx); err != nil { + t.Fatal(err) + } + if exists(ts, "/home/moatuser/.claude") { + t.Error(".claude created with no MOAT_CLAUDE_INIT") + } + + // A file (not a directory) as the staging path skips the block. + ts2 := newTestSys(t, 0, true) + stageFile(t, ts2, "mnt", "notadir", 0o644, "x") + ctx2, _ := newTestContext(ts2, Config{ClaudeInit: "/mnt/notadir", Home: "/root"}) + if err := claudeStagingPhase(ctx2); err != nil { + t.Fatal(err) + } + if exists(ts2, "/home/moatuser/.claude") { + t.Error(".claude created for file-typed staging path") + } + + // Companion: an empty staging DIR still creates the agent dir. + ts3 := newTestSys(t, 0, true) + if err := os.MkdirAll(filepath.Join(ts3.Root, "mnt/claude-init"), 0o755); err != nil { + t.Fatal(err) + } + ctx3, _ := newTestContext(ts3, Config{ClaudeInit: "/mnt/claude-init", Home: "/root"}) + if err := claudeStagingPhase(ctx3); err != nil { + t.Fatal(err) + } + if !exists(ts3, "/home/moatuser/.claude") { + t.Error(".claude not created for empty staging dir") + } +} + +func TestAgentStagingNonRootTargetsHome(t *testing.T) { + ts := newTestSys(t, 1000, true) // moatuser exists but we're not root + staging := stageFile(t, ts, "mnt/codex-init", "config.toml", 0o644, "cfg") + stageFile(t, ts, "mnt/codex-init", "auth.json", 0o644, `{"k":"v"}`) + ctx, _ := newTestContext(ts, Config{CodexInit: staging, Home: "/tmp/h"}) + if err := codexStagingPhase(ctx); err != nil { + t.Fatal(err) + } + if !exists(ts, "/tmp/h/.codex/config.toml") { + t.Error("config.toml not staged under $HOME") + } + if got := statMode(t, ts, "/tmp/h/.codex/auth.json"); got != 0o600 { + t.Errorf("auth.json mode = %o, want 600", got) + } + // Non-root: no chown of any kind. + if len(ts.chowns) != 0 { + t.Errorf("non-root path recorded chowns: %v", ts.chowns) + } +} + +func TestGeminiSettingsModePreserved(t *testing.T) { + // AGENT-GEMINI-CP-SETTINGS: settings.json keeps its source mode — NOT + // forced to 600 (companion to the oauth_creds.json secret contract). + ts := newTestSys(t, 0, true) + staging := stageFile(t, ts, "mnt/gemini-init", "settings.json", 0o640, `{}`) + stageFile(t, ts, "mnt/gemini-init", "oauth_creds.json", 0o644, `{}`) + ctx, _ := newTestContext(ts, Config{GeminiInit: staging, Home: "/root"}) + if err := geminiStagingPhase(ctx); err != nil { + t.Fatal(err) + } + if got := statMode(t, ts, "/home/moatuser/.gemini/settings.json"); got != 0o640 { + t.Errorf("settings.json mode = %o, want 640 preserved", got) + } + if got := statMode(t, ts, "/home/moatuser/.gemini/oauth_creds.json"); got != 0o600 { + t.Errorf("oauth_creds.json mode = %o, want 600", got) + } +} + +func TestCopilotStaging(t *testing.T) { + ts := newTestSys(t, 0, true) + staging := stageFile(t, ts, "mnt/copilot-init", "config.json", 0o644, `{}`) + stageFile(t, ts, "mnt/copilot-init", "settings.json", 0o644, `{}`) + stageFile(t, ts, "mnt/copilot-init", "permissions-config.json", 0o644, `{}`) + ctx, _ := newTestContext(ts, Config{CopilotInit: staging, Home: "/root"}) + if err := copilotStagingPhase(ctx); err != nil { + t.Fatal(err) + } + for _, f := range []string{"config.json", "settings.json", "permissions-config.json"} { + if !exists(ts, "/home/moatuser/.copilot/"+f) { + t.Errorf("%s not staged", f) + } + } +} + +func TestAgentBlockIndependence(t *testing.T) { + // AGENT-BLOCK-INDEPENDENCE-ORDER: with only Codex set, .claude and + // .gemini are absent; blocks are guarded solely by their own var. + ts := newTestSys(t, 0, true) + staging := stageFile(t, ts, "mnt/codex-init", "config.toml", 0o644, "x") + cfg := Config{CodexInit: staging, Home: "/root"} + ctx, _ := newTestContext(ts, cfg) + for _, phase := range []func(*Context) error{claudeStagingPhase, codexStagingPhase, geminiStagingPhase, copilotStagingPhase} { + if err := phase(ctx); err != nil { + t.Fatal(err) + } + } + if !exists(ts, "/home/moatuser/.codex/config.toml") { + t.Error(".codex not populated") + } + for _, p := range []string{"/home/moatuser/.claude", "/home/moatuser/.gemini", "/home/moatuser/.copilot"} { + if exists(ts, p) { + t.Errorf("%s created without its init var", p) + } + } +} + +func TestAgentChownFailureIsBestEffort(t *testing.T) { + // AGENT-SET-E-COMPOUND-SEMANTICS: a chown failure is swallowed (the + // 2>/dev/null || true idiom), the phase still succeeds. + ts := newTestSys(t, 0, true) + ts.chownErr = os.ErrPermission + staging := stageFile(t, ts, "mnt/codex-init", "config.toml", 0o644, "x") + ctx, _ := newTestContext(ts, Config{CodexInit: staging, Home: "/root"}) + if err := codexStagingPhase(ctx); err != nil { + t.Fatalf("chown failure aborted the phase: %v", err) + } +} + +func TestAgentCopyFailureIsFatal(t *testing.T) { + // Companion to best-effort chown: an unguarded cp failure aborts + // (set -e). Make the destination unwritable by pre-creating the agent + // dir as a read-only directory (non-root path so we lack override). + ts := newTestSys(t, 1000, false) + staging := stageFile(t, ts, "mnt/codex-init", "config.toml", 0o644, "x") + roDir := filepath.Join(ts.Root, "tmp/h/.codex") + if err := os.MkdirAll(roDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(roDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) }) + + ctx, stderr := newTestContext(ts, Config{CodexInit: staging, Home: "/tmp/h"}) + err := codexStagingPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + if stderr.Len() == 0 { + t.Error("fatal copy failure produced no stderr") + } +} diff --git a/internal/moatinit/fsutil.go b/internal/moatinit/fsutil.go new file mode 100644 index 00000000..6a31bc04 --- /dev/null +++ b/internal/moatinit/fsutil.go @@ -0,0 +1,38 @@ +package moatinit + +import "io/fs" + +// isFile mirrors `[ -f path ]`: the path exists and is a regular file +// (following symlinks, like test -f). +func isFile(sys Sys, path string) bool { + info, err := sys.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// isDir mirrors `[ -d path ]`. +func isDir(sys Sys, path string) bool { + info, err := sys.Stat(path) + return err == nil && info.IsDir() +} + +// moatuserExists mirrors `id moatuser >/dev/null 2>&1` (EXEC-14: every +// branch uses the same existence check). +func moatuserExists(sys Sys) bool { + _, ok := sys.LookupUser("moatuser") + return ok +} + +// recursiveChownBestEffort mirrors `chown -R user:group root 2>/dev/null || +// true`: every node in the tree is re-owned via lchown (GNU chown -R does +// not dereference symlinks encountered during traversal, so out-of-tree +// symlink targets are never re-owned), and every error — including walk +// errors — is swallowed. +func recursiveChownBestEffort(sys Sys, root string, uid, gid int) { + _ = sys.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // best-effort: skip unreadable entries + } + _ = sys.Lchown(path, uid, gid) + return nil + }) +} diff --git a/internal/moatinit/git.go b/internal/moatinit/git.go index 40f26fde..cb2d3fa0 100644 --- a/internal/moatinit/git.go +++ b/internal/moatinit/git.go @@ -16,6 +16,22 @@ package moatinit // Every command is best-effort at execution time (stderr discarded, failure // ignored — GIT-07); the caller only runs them when a git binary is on PATH // (GIT-01). +// gitConfigPhase mirrors the git-configuration block: gated on a git binary +// being on PATH (GIT-01 — no git, no config, no error), then each command +// runs best-effort with stderr discarded (GIT-07: a read-only /etc/gitconfig +// or non-root run must not abort the start; git config set operations write +// nothing to stdout). +func gitConfigPhase(ctx *Context) error { + sys := ctx.Sys + if _, err := sys.LookPath("git"); err != nil { + return nil + } + for _, argv := range gitConfigCommands(ctx.Cfg) { + _, _ = sys.Run(Cmd{Argv: argv}) // best-effort: exit code and error both ignored + } + return nil +} + func gitConfigCommands(cfg *Config) [][]string { cmds := [][]string{ {"git", "config", "--system", "--add", "safe.directory", "/workspace"}, diff --git a/internal/moatinit/hosts.go b/internal/moatinit/hosts.go index 537fe17e..4bf90be9 100644 --- a/internal/moatinit/hosts.go +++ b/internal/moatinit/hosts.go @@ -1,6 +1,15 @@ package moatinit -import "strings" +import ( + "fmt" + "net" + "strings" + "time" +) + +// dnsWaitIters mirrors MOAT_DNS_WAIT_ITERS: iterations * 0.2s = 5 second +// timeout for container DNS to answer an '@'-form target. +const dnsWaitIters = 25 // hostEntry is one parsed MOAT_EXTRA_HOSTS token ("name:target"). type hostEntry struct { @@ -45,3 +54,75 @@ func (e hostEntry) resolveTarget() (hostname string, resolve bool) { } return "", false } + +// extraHostsPhase appends synthetic entries to /etc/hosts (HOSTS region). +// It must be the FIRST phase: everything after it may resolve +// moat-proxy/moat-host (HOSTS-11). +// +// Fail-closed: an unresolvable '@'-target or an /etc/hosts write failure +// aborts the entrypoint with the script's exact three-line errors. Silent +// failure would leave moat-proxy unresolvable, HTTP_PROXY broken, and +// network policy silently degraded. +func extraHostsPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.ExtraHosts == "" { + return nil // HOSTS-01: unset/empty is a complete no-op + } + for _, tok := range splitExtraHosts(cfg.ExtraHosts) { + e := parseHostEntry(tok) + if e.skip() { + continue + } + + var ip string + if hostname, resolve := e.resolveTarget(); resolve { + // Prefer IPv4 (getent ahostsv4) because the host is reached via + // Docker Desktop's IPv4-only mapping; an IPv6 entry like "::1" + // would resolve to the container's own loopback and silently + // not reach the host. Fall back to any address (getent hosts) + // if the name has only IPv6 records. Retried because Docker + // Desktop's embedded DNS may not be ready the instant the + // ENTRYPOINT runs (HOSTS-06/HOSTS-14: 25 × 0.2s ≈ 5s budget; + // each lookup attempt is itself bounded so a hanging resolver + // cannot blow the loop budget). + for i := 0; i < dnsWaitIters; i++ { + candidate := sys.ResolveIPv4First(hostname) + if candidate == "" { + candidate = sys.ResolveAnyFirst(hostname) + } + if candidate != "" { + ip = candidate + break + } + sys.Sleep(200 * time.Millisecond) + } + if ip == "" { + fmt.Fprintf(ctx.Stderr, "Error: moat-init.sh could not resolve '%s' for /etc/hosts entry '%s'.\n", hostname, e.name) + fmt.Fprintln(ctx.Stderr, "The container's DNS should answer this name. On Docker Desktop, verify that") + fmt.Fprintf(ctx.Stderr, "'getent hosts %s' works inside this container.\n", hostname) + return exitError{code: 1} + } + // Sanctioned addition (plan risk register P1): the IPv6/loopback + // fallback is reachable only when the name had no A record; the + // resulting entry very likely cannot reach the host-side proxy, + // so say so instead of degrading silently. Warning only — the + // entry is still written, byte-for-byte like the script. + if parsed := net.ParseIP(ip); parsed != nil && (parsed.To4() == nil || parsed.IsLoopback()) { + fmt.Fprintf(ctx.Stderr, "Warning: /etc/hosts entry '%s' resolved to '%s' (IPv6 or loopback); the moat proxy may not be reachable through it\n", e.name, ip) + } + } else { + ip = e.target + } + + // Append " " (HOSTS-09). A write failure is fatal + // (HOSTS-10) — typically the entrypoint is not root and lacks + // permission. + if err := sys.AppendFile("/etc/hosts", []byte(ip+" "+e.name+"\n")); err != nil { + fmt.Fprintf(ctx.Stderr, "Error: moat-init.sh cannot write %s to /etc/hosts (required for moat proxy resolution).\n", e.name) + fmt.Fprintf(ctx.Stderr, "The container user (UID %d) lacks permission to modify /etc/hosts.\n", sys.Geteuid()) + fmt.Fprintln(ctx.Stderr, "Rebuild the base image so moat-init.sh runs as root, or grant CAP_DAC_OVERRIDE.") + return exitError{code: 1} + } + } + return nil +} diff --git a/internal/moatinit/hosts_phase_test.go b/internal/moatinit/hosts_phase_test.go new file mode 100644 index 00000000..de319606 --- /dev/null +++ b/internal/moatinit/hosts_phase_test.go @@ -0,0 +1,173 @@ +package moatinit + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func readHosts(t *testing.T, ts *testSys) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(ts.Root, "etc/hosts")) + if err != nil { + if os.IsNotExist(err) { + return "" + } + t.Fatal(err) + } + return string(data) +} + +func writeHosts(t *testing.T, ts *testSys, content string) { + t.Helper() + dir := filepath.Join(ts.Root, "etc") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "hosts"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestExtraHostsPhaseNoOp(t *testing.T) { + // HOSTS-01: unset, empty, and whitespace-only leave /etc/hosts + // byte-identical and succeed. + for _, v := range []string{"", " "} { + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "127.0.0.1 localhost\n") + ctx, _ := newTestContext(ts, Config{ExtraHosts: v}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatalf("ExtraHosts=%q: %v", v, err) + } + if got := readHosts(t, ts); got != "127.0.0.1 localhost\n" { + t.Errorf("ExtraHosts=%q modified /etc/hosts: %q", v, got) + } + } +} + +func TestExtraHostsPhaseLiteralAppend(t *testing.T) { + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "127.0.0.1 localhost\n") + ctx, _ := newTestContext(ts, Config{ExtraHosts: "moat-proxy:192.0.2.5 moat-host:192.0.2.5"}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatal(err) + } + // HOSTS-09: appended in order, " \n", prior content intact. + want := "127.0.0.1 localhost\n192.0.2.5 moat-proxy\n192.0.2.5 moat-host\n" + if got := readHosts(t, ts); got != want { + t.Errorf("hosts = %q, want %q", got, want) + } + // Literal targets never hit the resolver. + if len(ts.resolveCalls) != 0 { + t.Errorf("literal targets resolved DNS: %v", ts.resolveCalls) + } +} + +func TestExtraHostsPhaseSkipsMalformed(t *testing.T) { + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "") + // HOSTS-04 companions: all skipped, no error, file unchanged. + ctx, _ := newTestContext(ts, Config{ExtraHosts: "moat-proxy: :1.2.3.4 foo x:x"}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatal(err) + } + if got := readHosts(t, ts); got != "" { + t.Errorf("malformed entries wrote to hosts: %q", got) + } +} + +func TestExtraHostsPhaseResolve(t *testing.T) { + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "") + // HOSTS-06: IPv4 preferred when both records exist. + ts.resolve4["host.docker.internal"] = "192.0.2.10" + ts.resolveAny["host.docker.internal"] = "::1" + ctx, _ := newTestContext(ts, Config{ExtraHosts: "moat-host:@host.docker.internal"}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatal(err) + } + if got := readHosts(t, ts); got != "192.0.2.10 moat-host\n" { + t.Errorf("hosts = %q, want IPv4-preferred entry", got) + } +} + +func TestExtraHostsPhaseResolveFallbackAny(t *testing.T) { + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "") + // Only the getent-hosts fallback answers (IPv6-only name). + ts.resolveAny["v6only"] = "fd00::5" + ctx, stderr := newTestContext(ts, Config{ExtraHosts: "svc:@v6only"}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatal(err) + } + if got := readHosts(t, ts); got != "fd00::5 svc\n" { + t.Errorf("hosts = %q, want fallback entry", got) + } + // Sanctioned P1 warning for the IPv6/loopback fallback. + if !strings.Contains(stderr.String(), "Warning: /etc/hosts entry 'svc' resolved to 'fd00::5'") { + t.Errorf("missing IPv6 fallback warning, stderr: %q", stderr.String()) + } +} + +func TestExtraHostsPhaseResolveFailureFailsClosed(t *testing.T) { + ts := newTestSys(t, 1000, false) + writeHosts(t, ts, "") + ctx, stderr := newTestContext(ts, Config{ExtraHosts: "moat-proxy:@nope.invalid"}) + err := extraHostsPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + // HOSTS-08: exact three-line error. + want := "Error: moat-init.sh could not resolve 'nope.invalid' for /etc/hosts entry 'moat-proxy'.\n" + + "The container's DNS should answer this name. On Docker Desktop, verify that\n" + + "'getent hosts nope.invalid' works inside this container.\n" + if stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + // HOSTS-14: 25 attempts (both lookups each) with a sleep after each. + if ts.sleeps != dnsWaitIters { + t.Errorf("sleeps = %d, want %d", ts.sleeps, dnsWaitIters) + } + if got := len(ts.resolveCalls); got != 2*dnsWaitIters { + t.Errorf("resolver calls = %d, want %d", got, 2*dnsWaitIters) + } + if got := readHosts(t, ts); got != "" { + t.Errorf("failed resolve still wrote hosts: %q", got) + } +} + +func TestExtraHostsPhaseWriteFailureFailsClosed(t *testing.T) { + ts := newTestSys(t, 1000, false) + // No /etc directory at all — the append cannot create the file. + ctx, stderr := newTestContext(ts, Config{ExtraHosts: "moat-proxy:192.0.2.5"}) + err := extraHostsPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + // HOSTS-10: exact three-line error, UID interpolated. + want := "Error: moat-init.sh cannot write moat-proxy to /etc/hosts (required for moat proxy resolution).\n" + + "The container user (UID 1000) lacks permission to modify /etc/hosts.\n" + + "Rebuild the base image so moat-init.sh runs as root, or grant CAP_DAC_OVERRIDE.\n" + if stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestExtraHostsPhaseEntryIndependence(t *testing.T) { + // HOSTS-13: per-entry state resets; b does not inherit a's IP, and a + // resolve failure budget is fresh per entry. + ts := newTestSys(t, 0, true) + writeHosts(t, ts, "") + ts.resolve4["hostA"] = "10.0.0.1" + ts.resolve4["hostB"] = "10.0.0.2" + ctx, _ := newTestContext(ts, Config{ExtraHosts: "a:@hostA b:@hostB"}) + if err := extraHostsPhase(ctx); err != nil { + t.Fatal(err) + } + if got := readHosts(t, ts); got != "10.0.0.1 a\n10.0.0.2 b\n" { + t.Errorf("hosts = %q", got) + } +} diff --git a/internal/moatinit/initfiles.go b/internal/moatinit/initfiles.go index a38f9b73..7113373f 100644 --- a/internal/moatinit/initfiles.go +++ b/internal/moatinit/initfiles.go @@ -2,6 +2,7 @@ package moatinit import ( "encoding/base64" + "path/filepath" "strings" ) @@ -55,6 +56,78 @@ func splitInitRecord(line string) (path, content string) { return path, content } +// initFilesPhase writes the MOAT_INIT_FILES records to disk (INIT region): +// per record, create the parent chain (0755 on the immediate parent, even a +// pre-existing stricter one — parity), decode the payload to a buffer, +// write the file, force 0600, and on the root+moatuser path chown the file +// plus every ancestor directory up to but excluding INIT_HOME (the walk +// deliberately climbs to '/' for out-of-home paths — parity). +// +// Decode/mkdir/write/chmod failures are fatal and abort before exec: a +// partial secret or a world-readable credential file must never start the +// user command. Chown failures are best-effort and silent. +// +// After the loop the variable is removed from the process environment (the +// script's `unset MOAT_INIT_FILES`) so no later child — the pre_run hook, +// gosu, the exec'd command — inherits the base64 secret payload (INIT-10). +func initFilesPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.InitFiles == "" { + return nil // INIT-01: no work, no ownership resolution + } + chown, initHome := initFilesOwnership(sys.Geteuid(), moatuserExists(sys), cfg.Home) + var owner User + if chown { + u, ok := sys.LookupUser("moatuser") + if !ok { + chown = false + } + owner = u + } + + for _, rec := range parseInitFiles(cfg.InitFiles) { + if rec.path == "" { + continue // INIT-04: harmless trailing-newline record + } + // Decode first (buffer, not stream): an invalid payload aborts + // before any directory or file is touched (Appendix B P1 — the + // shell could leave a truncated file; failing earlier is the + // sanctioned fail-closed ordering of the same fatal). + data, err := decodeInitContent(rec.content) + if err != nil { + return fatalPhaseError(ctx, "decoding init file "+rec.path, err) + } + dir := filepath.Dir(rec.path) + if err := sys.MkdirAll(dir, 0o755); err != nil { + return fatalPhaseError(ctx, "creating "+dir, err) + } + // chmod 755 applies to the immediate parent only, including a + // pre-existing one at a stricter mode (INIT-05, Appendix B P2 — + // documented parity, not an accident). + if err := sys.Chmod(dir, 0o755); err != nil { + return fatalPhaseError(ctx, "setting mode on "+dir, err) + } + if err := sys.WriteFile(rec.path, data, 0o600); err != nil { + return fatalPhaseError(ctx, "writing "+rec.path, err) + } + // Force 0600 even when the file pre-existed at a wider mode + // (WriteFile only applies the mode at creation — INIT-07). + if err := sys.Chmod(rec.path, 0o600); err != nil { + return fatalPhaseError(ctx, "restricting "+rec.path, err) + } + + if chown { + _ = sys.Chown(rec.path, owner.UID, owner.GID) + for d := dir; d != "/" && d != "." && d != initHome; d = filepath.Dir(d) { + _ = sys.Chown(d, owner.UID, owner.GID) + } + } + } + + sys.Unsetenv("MOAT_INIT_FILES") + return nil +} + // decodeInitContent decodes a record's base64 payload (INIT-06). Go's // StdEncoding decoder already ignores \r and \n like coreutils `base64 -d` // (embedded newlines cannot occur here anyway — a newline would split the diff --git a/internal/moatinit/initfiles_phase_test.go b/internal/moatinit/initfiles_phase_test.go new file mode 100644 index 00000000..e983ea51 --- /dev/null +++ b/internal/moatinit/initfiles_phase_test.go @@ -0,0 +1,145 @@ +package moatinit + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +func TestInitFilesPhaseWritesRecords(t *testing.T) { + ts := newTestSys(t, 0, true) + ts.env["MOAT_INIT_FILES"] = "sentinel" // proves the phase unsets it + records := "/home/moatuser/.config/graphite/user_config\t" + b64("token = \"gt_x\"") + "\n" + + "/etc/other/cfg\t" + b64("data") + "\n" + ctx, _ := newTestContext(ts, Config{InitFiles: records, Home: "/root"}) + if err := initFilesPhase(ctx); err != nil { + t.Fatal(err) + } + + // Content decoded exactly, no trailing newline added. + if got := fileContent(t, ts, "/home/moatuser/.config/graphite/user_config"); got != "token = \"gt_x\"" { + t.Errorf("decoded content = %q", got) + } + // INIT-07: every record file is 0600. + for _, p := range []string{"/home/moatuser/.config/graphite/user_config", "/etc/other/cfg"} { + if got := statMode(t, ts, p); got != 0o600 { + t.Errorf("%s mode = %o, want 600", p, got) + } + } + // INIT-05: the immediate parent is 0755. + if got := statMode(t, ts, "/home/moatuser/.config/graphite"); got != 0o755 { + t.Errorf("parent mode = %o, want 755", got) + } + + // INIT-08: file chown + ancestor walk up to but excluding INIT_HOME. + for _, p := range []string{ + "/home/moatuser/.config/graphite/user_config", + "/home/moatuser/.config/graphite", + "/home/moatuser/.config", + } { + if !ts.chowned(p) { + t.Errorf("missing chown for %s", p) + } + } + if ts.chowned("/home/moatuser") { + t.Error("walk chowned INIT_HOME itself") + } + // The out-of-home record's walk climbs to / (parity — B-P2 documented). + if !ts.chowned("/etc/other") || !ts.chowned("/etc") { + t.Error("out-of-home ancestor walk missing /etc/other or /etc") + } + if ts.chowned("/") { + t.Error("walk chowned / itself") + } + + // INIT-10: the variable is gone from the process env. + if _, present := ts.env["MOAT_INIT_FILES"]; present { + t.Error("MOAT_INIT_FILES still in process env after the phase") + } +} + +func TestInitFilesPhaseNonRootNoChown(t *testing.T) { + // INIT-09 (companion to INIT-08): non-root writes files 0600 but + // attempts no chown of any kind. + ts := newTestSys(t, 1000, true) + records := "/tmp/h/.config/app/cfg\t" + b64("x") + ctx, _ := newTestContext(ts, Config{InitFiles: records, Home: "/tmp/h"}) + if err := initFilesPhase(ctx); err != nil { + t.Fatal(err) + } + if got := statMode(t, ts, "/tmp/h/.config/app/cfg"); got != 0o600 { + t.Errorf("mode = %o, want 600", got) + } + if len(ts.chowns) != 0 { + t.Errorf("non-root recorded chowns: %v", ts.chowns) + } +} + +func TestInitFilesPhaseEmptyAndSkips(t *testing.T) { + // INIT-01: empty is a complete no-op. + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{InitFiles: "", Home: "/root"}) + if err := initFilesPhase(ctx); err != nil { + t.Fatal(err) + } + if len(ts.chowns) != 0 { + t.Error("empty MOAT_INIT_FILES did work") + } + + // INIT-04: empty lines are skipped, surrounding records still written. + ts2 := newTestSys(t, 0, true) + records := "\n/a/b\t" + b64("v") + "\n\n" + ctx2, _ := newTestContext(ts2, Config{InitFiles: records, Home: "/root"}) + if err := initFilesPhase(ctx2); err != nil { + t.Fatal(err) + } + if got := fileContent(t, ts2, "/a/b"); got != "v" { + t.Errorf("record around empty lines = %q", got) + } +} + +func TestInitFilesPhaseInvalidBase64FailsClosed(t *testing.T) { + // B-P1: invalid base64 aborts non-zero BEFORE any file is written. + ts := newTestSys(t, 0, true) + records := "/sec/first\t" + b64("ok") + "\n/sec/second\t!!!bad!!!" + ctx, stderr := newTestContext(ts, Config{InitFiles: records, Home: "/root"}) + err := initFilesPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + if stderr.Len() == 0 { + t.Error("no diagnostic for invalid base64") + } + // Decode-to-buffer: the failing record left nothing on disk (not even + // its parent dir), and the entrypoint aborts before exec. + if exists(ts, "/sec/second") || exists(ts, "/sec/second/") { + t.Error("partial secret written for invalid record") + } + // Records before the bad one were already written (parity with the + // shell's sequential loop). + if !exists(ts, "/sec/first") { + t.Error("prior valid record missing") + } +} + +func TestInitFilesPhaseWidensPreexistingParent(t *testing.T) { + // INIT-05 / B-P2: a pre-existing stricter parent is widened to exactly + // 0755 (documented parity). + ts := newTestSys(t, 0, true) + strict := filepath.Join(ts.Root, "priv") + if err := os.MkdirAll(strict, 0o700); err != nil { + t.Fatal(err) + } + records := "/priv/cfg\t" + b64("x") + ctx, _ := newTestContext(ts, Config{InitFiles: records, Home: "/root"}) + if err := initFilesPhase(ctx); err != nil { + t.Fatal(err) + } + if got := statMode(t, ts, "/priv"); got != 0o755 { + t.Errorf("pre-existing parent mode = %o, want 755", got) + } +} diff --git a/internal/moatinit/mcp.go b/internal/moatinit/mcp.go new file mode 100644 index 00000000..c5bfeb14 --- /dev/null +++ b/internal/moatinit/mcp.go @@ -0,0 +1,32 @@ +package moatinit + +// workspaceMCPJSONPhase mirrors setup_workspace_mcp_json: copy the +// local-process MCP config for Codex/Gemini into /workspace/.mcp.json. +// +// Ordering is load-bearing (INIT-11): it runs AFTER populate_workspace_volume +// — in volume mode populate tar-extracts the staging tree over /workspace, +// so writing .mcp.json earlier would let the user's own .mcp.json clobber +// moat's. Running it after makes moat's config win in both modes. +// +// Both Codex and Gemini write the same destination path. This is safe +// because config validation rejects runs that activate both agents +// simultaneously — at most one block executes. A third agent with its own +// .mcp.json must preserve this mutual-exclusion invariant. +func workspaceMCPJSONPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + for _, staging := range []string{cfg.CodexInit, cfg.GeminiInit} { + if staging == "" || !isFile(sys, staging+"/mcp.json") { + continue + } + // cp -p, unguarded in the function body: fatal under set -e. + if err := sys.CopyFilePreserving(staging+"/mcp.json", "/workspace/.mcp.json"); err != nil { + return fatalPhaseError(ctx, "copying workspace .mcp.json", err) + } + if chownToMoatuser(sys.Geteuid(), moatuserExists(sys)) { + if u, ok := sys.LookupUser("moatuser"); ok { + _ = sys.Chown("/workspace/.mcp.json", u.UID, u.GID) // best-effort + } + } + } + return nil +} diff --git a/internal/moatinit/mcp_git_phase_test.go b/internal/moatinit/mcp_git_phase_test.go new file mode 100644 index 00000000..1a7f8ac6 --- /dev/null +++ b/internal/moatinit/mcp_git_phase_test.go @@ -0,0 +1,143 @@ +package moatinit + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWorkspaceMCPJSONPhase(t *testing.T) { + ts := newTestSys(t, 0, true) + if err := os.MkdirAll(filepath.Join(ts.Root, "workspace"), 0o755); err != nil { + t.Fatal(err) + } + staging := stageFile(t, ts, "mnt/codex-init", "mcp.json", 0o644, `{"mcpServers":{}}`) + + ctx, _ := newTestContext(ts, Config{CodexInit: staging, Home: "/root"}) + if err := workspaceMCPJSONPhase(ctx); err != nil { + t.Fatal(err) + } + if got := fileContent(t, ts, "/workspace/.mcp.json"); got != `{"mcpServers":{}}` { + t.Errorf(".mcp.json = %q", got) + } + if got := statMode(t, ts, "/workspace/.mcp.json"); got != 0o644 { + t.Errorf(".mcp.json mode = %o, want 644 preserved", got) + } + if !ts.chowned("/workspace/.mcp.json") { + t.Error("no chown recorded for .mcp.json on the root path") + } +} + +func TestWorkspaceMCPJSONPhaseCompanions(t *testing.T) { + // No staging vars set: no-op. + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{Home: "/root"}) + if err := workspaceMCPJSONPhase(ctx); err != nil { + t.Fatal(err) + } + if exists(ts, "/workspace/.mcp.json") { + t.Error(".mcp.json created with no staging config") + } + + // Staging set but no mcp.json file: no-op (INIT-12 companion). + ts2 := newTestSys(t, 0, true) + if err := os.MkdirAll(filepath.Join(ts2.Root, "mnt/gemini-init"), 0o755); err != nil { + t.Fatal(err) + } + ctx2, _ := newTestContext(ts2, Config{GeminiInit: "/mnt/gemini-init", Home: "/root"}) + if err := workspaceMCPJSONPhase(ctx2); err != nil { + t.Fatal(err) + } + if exists(ts2, "/workspace/.mcp.json") { + t.Error(".mcp.json created without a staged mcp.json") + } +} + +func TestGitConfigPhaseRunsCommandsInOrder(t *testing.T) { + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{ + GitUserName: "Ada Lovelace", + GitUserEmail: "ada@example.com", + GitSSHGitHub: "1", + Home: "/root", + }) + if err := gitConfigPhase(ctx); err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(ts.runs)) + for _, c := range ts.runs { + got = append(got, strings.Join(c.Argv, " ")) + } + want := []string{ + "git config --system --add safe.directory /workspace", + "git config --system user.name Ada Lovelace", + "git config --system user.email ada@example.com", + "git config --system http.proxyAuthMethod basic", + "git config --system url.git@github.com:.insteadOf https://github.com/", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestGitConfigPhaseSkipsWithoutGit(t *testing.T) { + // GIT-01: no git binary, no commands, no error. + ts := newTestSys(t, 0, true) + ts.missingBinaries["git"] = true + ctx, _ := newTestContext(ts, Config{GitUserName: "Ada", Home: "/root"}) + if err := gitConfigPhase(ctx); err != nil { + t.Fatal(err) + } + if len(ts.runs) != 0 { + t.Errorf("git commands ran without a git binary: %v", ts.runs) + } +} + +func TestGitConfigPhaseBestEffortFailures(t *testing.T) { + // GIT-07: git config failures (read-only /etc/gitconfig, non-root) do + // not abort the phase. + ts := newTestSys(t, 1000, false) + ts.runHook = func(Cmd) (int, error) { return 1, nil } + ctx, _ := newTestContext(ts, Config{Home: "/tmp/h"}) + if err := gitConfigPhase(ctx); err != nil { + t.Fatalf("failing git config aborted the phase: %v", err) + } + if len(ts.runs) != 2 { + t.Errorf("expected both unconditional commands to still run, got %d", len(ts.runs)) + } +} + +// TestGitConfigPhaseRealGit exercises the phase against a real git binary +// with GIT_CONFIG_SYSTEM pointed at a temp file, proving the assembled +// argv actually produces the documented config. +func TestGitConfigPhaseRealGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + sysCfg := filepath.Join(t.TempDir(), "gitconfig") + ts := newTestSys(t, 0, true) + ts.runHook = func(c Cmd) (int, error) { + cmd := exec.Command(c.Argv[0], c.Argv[1:]...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_SYSTEM="+sysCfg) + if err := cmd.Run(); err != nil { + return 1, nil + } + return 0, nil + } + ctx, _ := newTestContext(ts, Config{GitSSHGitHub: "1", GitUserName: "Ada", Home: "/root"}) + if err := gitConfigPhase(ctx); err != nil { + t.Fatal(err) + } + out, err := os.ReadFile(sysCfg) + if err != nil { + t.Fatal(err) + } + cfg := string(out) + for _, want := range []string{"[safe]", "directory = /workspace", "proxyAuthMethod = basic", "insteadOf = https://github.com/", "name = Ada"} { + if !strings.Contains(cfg, want) { + t.Errorf("system gitconfig missing %q:\n%s", want, cfg) + } + } +} diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index adccbe42..a4bcaa3b 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -44,11 +44,19 @@ type Phase struct { // user command without the privilege-drop contract. func phases() []Phase { return []Phase{ - // Commit 2–6 land: extra-hosts, ssh-agent-bridge, claude-staging, - // codex-staging, gemini-staging, copilot-staging, init-files, - // clipboard, git-config, docker, named-volume-chown, - // populate-workspace-volume, workspace-mcp-json, pre-run-hook, - // exec-dispatch. + {Name: "extra-hosts", Run: extraHostsPhase}, + // ssh-agent-bridge lands with the long-lived-children commit. + {Name: "claude-staging", Run: claudeStagingPhase}, + {Name: "codex-staging", Run: codexStagingPhase}, + {Name: "gemini-staging", Run: geminiStagingPhase}, + {Name: "copilot-staging", Run: copilotStagingPhase}, + {Name: "init-files", Run: initFilesPhase}, + // clipboard and docker land with the long-lived-children commit. + {Name: "git-config", Run: gitConfigPhase}, + // named-volume-chown and populate-workspace-volume land next; + // populate must precede workspace-mcp-json (INIT-11). + {Name: "workspace-mcp-json", Run: workspaceMCPJSONPhase}, + // pre-run-hook and exec-dispatch land last. } } diff --git a/internal/moatinit/phase_test.go b/internal/moatinit/phase_test.go index 33870cfd..9b1a2d3b 100644 --- a/internal/moatinit/phase_test.go +++ b/internal/moatinit/phase_test.go @@ -11,10 +11,11 @@ import ( // privilege-drop contract) — it exits 1 with a loud FATAL instead. func TestRunFailsClosedWithoutExecPhase(t *testing.T) { var stderr strings.Builder - sys := NewSys() + // An empty Config (not LoadConfig): the test process may itself run + // inside a moat sandbox with live MOAT_* variables set. ctx := &Context{ - Sys: sys, - Cfg: LoadConfig(sys), + Sys: NewSys(), + Cfg: &Config{}, Argv: []string{"true"}, Stderr: &stderr, } diff --git a/internal/moatinit/testsys_test.go b/internal/moatinit/testsys_test.go new file mode 100644 index 00000000..9c1032a7 --- /dev/null +++ b/internal/moatinit/testsys_test.go @@ -0,0 +1,129 @@ +package moatinit + +import ( + "bytes" + "testing" + "time" +) + +// chownCall records one (l)chown request so tests can assert ownership +// behavior without root privileges (the real syscall would fail under go +// test; recording matches the plan's "recording chown" seam). +type chownCall struct { + path string + uid, gid int + lchown bool +} + +// testSys embeds the production OSSys pointed at a t.TempDir() root — real +// filesystem semantics for mkdir/copy/chmod/write — and shadows identity, +// ownership, subprocess, and DNS with controllable fakes. +type testSys struct { + *OSSys + t *testing.T + + euid int + users map[string]User + + chowns []chownCall + chownErr error // returned (after recording) to prove best-effort paths + + resolve4 map[string]string + resolveAny map[string]string + resolveCalls []string + + runs []Cmd + runHook func(c Cmd) (int, error) + + missingBinaries map[string]bool + + sleeps int + + env map[string]string // views/mutations of the live process env +} + +func newTestSys(t *testing.T, euid int, withMoatuser bool) *testSys { + t.Helper() + ts := &testSys{ + OSSys: &OSSys{Root: t.TempDir()}, + t: t, + euid: euid, + users: map[string]User{}, + resolve4: map[string]string{}, + resolveAny: map[string]string{}, + missingBinaries: map[string]bool{}, + env: map[string]string{}, + } + if withMoatuser { + ts.users["moatuser"] = User{UID: 5000, GID: 5000} + } + return ts +} + +func (ts *testSys) Geteuid() int { return ts.euid } + +func (ts *testSys) LookupUser(name string) (User, bool) { + u, ok := ts.users[name] + return u, ok +} + +func (ts *testSys) Chown(path string, uid, gid int) error { + ts.chowns = append(ts.chowns, chownCall{path: path, uid: uid, gid: gid}) + return ts.chownErr +} + +func (ts *testSys) Lchown(path string, uid, gid int) error { + ts.chowns = append(ts.chowns, chownCall{path: path, uid: uid, gid: gid, lchown: true}) + return ts.chownErr +} + +func (ts *testSys) ResolveIPv4First(host string) string { + ts.resolveCalls = append(ts.resolveCalls, "ip4:"+host) + return ts.resolve4[host] +} + +func (ts *testSys) ResolveAnyFirst(host string) string { + ts.resolveCalls = append(ts.resolveCalls, "any:"+host) + return ts.resolveAny[host] +} + +func (ts *testSys) Sleep(_ time.Duration) { ts.sleeps++ } // no real waiting in tests + +func (ts *testSys) LookPath(file string) (string, error) { + if ts.missingBinaries[file] { + return "", &missingBinaryError{name: file} + } + return "/usr/bin/" + file, nil +} + +type missingBinaryError struct{ name string } + +func (e *missingBinaryError) Error() string { return e.name + ": not found on PATH" } + +func (ts *testSys) Run(c Cmd) (int, error) { + ts.runs = append(ts.runs, c) + if ts.runHook != nil { + return ts.runHook(c) + } + return 0, nil +} + +func (ts *testSys) Getenv(key string) string { return ts.env[key] } +func (ts *testSys) Setenv(key, value string) { ts.env[key] = value } +func (ts *testSys) Unsetenv(key string) { delete(ts.env, key) } + +// chowned reports whether a chown for path was recorded. +func (ts *testSys) chowned(path string) bool { + for _, c := range ts.chowns { + if c.path == path { + return true + } + } + return false +} + +// newTestContext builds a Context around a testSys with a config literal. +func newTestContext(ts *testSys, cfg Config) (*Context, *bytes.Buffer) { + stderr := &bytes.Buffer{} + return &Context{Sys: ts, Cfg: &cfg, Argv: []string{"true"}, Stderr: stderr}, stderr +} From 527aae33ebd9bc8a373f7ae8527422eb41f16ec2 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:48:46 +0000 Subject: [PATCH 05/17] =?UTF-8?q?feat(moatinit):=20workspace-volume=20popu?= =?UTF-8?q?late=20=E2=80=94=20Go-owned=20tar=20pipe=20logic=20+=20named-vo?= =?UTF-8?q?lume=20chown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4 of the moat-init rewrite. Go now owns the populate business logic: the exact-"1" gate, the defensive root guard, staging resolution (/mnt/host-workspace default), verbatim exclude-file assembly (newline delimited — GNU tar 1.34's --null --exclude-from applies only the first record), the tar -cf - . | tar -xf - argument vectors, BOTH pipe exit codes (a source-side read error can no longer hide behind the rightmost status), temp-file cleanup on success and failure paths, and the fatal recursive re-own of /workspace via lchown (symlink targets outside the tree are never re-owned). The byte copy stays a targeted tar subprocess. A missing staging root maps to the same fatal rc-check path — never a silently empty /workspace. Also ports the inline named-volume chown block at its exact script position (before the populate/mcp/hook tail): non-recursive, best-effort, glob characters literal. Sys gains RealPath (subprocess-visible path mapping under an injected test root) and Pipe serializes a shared non-file stderr writer (the two tar legs would otherwise race on the test buffer). --- internal/moatinit/phase.go | 8 +- internal/moatinit/sys.go | 32 +++ internal/moatinit/testsys_test.go | 21 +- internal/moatinit/volume.go | 114 +++++++++- internal/moatinit/volume_phase_test.go | 298 +++++++++++++++++++++++++ 5 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 internal/moatinit/volume_phase_test.go diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index a4bcaa3b..890ac5f2 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -53,8 +53,12 @@ func phases() []Phase { {Name: "init-files", Run: initFilesPhase}, // clipboard and docker land with the long-lived-children commit. {Name: "git-config", Run: gitConfigPhase}, - // named-volume-chown and populate-workspace-volume land next; - // populate must precede workspace-mcp-json (INIT-11). + // The named-volume chown block is inline in the script BEFORE the + // populate/mcp/hook tail calls — script order, kept exactly. + {Name: "named-volume-chown", Run: namedVolumeChownPhase}, + // populate must precede workspace-mcp-json (INIT-11): in volume + // mode the tar extract would otherwise clobber moat's .mcp.json. + {Name: "populate-workspace-volume", Run: populateWorkspaceVolumePhase}, {Name: "workspace-mcp-json", Run: workspaceMCPJSONPhase}, // pre-run-hook and exec-dispatch land last. } diff --git a/internal/moatinit/sys.go b/internal/moatinit/sys.go index 813ef4df..af07b3d9 100644 --- a/internal/moatinit/sys.go +++ b/internal/moatinit/sys.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" ) @@ -73,6 +74,13 @@ type Sys interface { WalkDir(root string, fn fs.WalkDirFunc) error Getpid() int + // RealPath maps a container-absolute path to the path a SUBPROCESS must + // use to reach it. In production this is the identity; under an + // injected test root it prefixes the root, so phases that hand paths to + // tar/git/socat (argv or working directory) stay testable against the + // same tree the filesystem methods manipulate. + RealPath(path string) string + // Subprocesses. LookPath(file string) (string, error) Run(c Cmd) (exitCode int, err error) @@ -196,6 +204,8 @@ func (s *OSSys) AppendFile(path string, data []byte) error { func (s *OSSys) Remove(path string) error { return os.Remove(s.path(path)) } +func (s *OSSys) RealPath(path string) string { return s.path(path) } + // CopyFilePreserving mirrors `cp -p src dst`: bytes, mode, and timestamps are // preserved (failure is an error); ownership preservation is attempted but, // like cp -p without appropriate privileges, its failure is not an error. @@ -321,10 +331,32 @@ func (s *OSSys) ProcessAlive(pid int) bool { return syscall.Kill(pid, 0) == nil } +// lockedWriter serializes writes from the two pipe legs when they share a +// destination that is not an *os.File (files are handed to the children as +// fds with no in-process copy goroutine, so os.Stderr needs no locking — +// but a shared in-memory writer, as tests use, would race). +type lockedWriter struct { + mu sync.Mutex + w io.Writer +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} + // Pipe runs `src | dst` and returns both exit codes, mirroring the script's // capture of the source tar's status alongside the destination's ($? after a // POSIX pipeline only reports the rightmost command). func (s *OSSys) Pipe(src, dst Cmd) (int, int, error) { + if src.Stderr != nil && src.Stderr == dst.Stderr { + if _, isFile := src.Stderr.(*os.File); !isFile { + shared := &lockedWriter{w: src.Stderr} + src.Stderr = shared + dst.Stderr = shared + } + } pr, pw, err := os.Pipe() if err != nil { return -1, -1, err diff --git a/internal/moatinit/testsys_test.go b/internal/moatinit/testsys_test.go index 9c1032a7..40530cc3 100644 --- a/internal/moatinit/testsys_test.go +++ b/internal/moatinit/testsys_test.go @@ -2,6 +2,8 @@ package moatinit import ( "bytes" + "os" + "path/filepath" "testing" "time" ) @@ -35,6 +37,9 @@ type testSys struct { runs []Cmd runHook func(c Cmd) (int, error) + pipes [][2]Cmd + pipeHook func(src, dst Cmd) (int, int, error) // nil = run the real pipe + missingBinaries map[string]bool sleeps int @@ -44,8 +49,14 @@ type testSys struct { func newTestSys(t *testing.T, euid int, withMoatuser bool) *testSys { t.Helper() + root := t.TempDir() + // A minimal container-like tree: /tmp always exists (the exclude temp + // file lands there). + if err := os.MkdirAll(filepath.Join(root, "tmp"), 0o777); err != nil { + t.Fatal(err) + } ts := &testSys{ - OSSys: &OSSys{Root: t.TempDir()}, + OSSys: &OSSys{Root: root}, t: t, euid: euid, users: map[string]User{}, @@ -108,6 +119,14 @@ func (ts *testSys) Run(c Cmd) (int, error) { return 0, nil } +func (ts *testSys) Pipe(src, dst Cmd) (int, int, error) { + ts.pipes = append(ts.pipes, [2]Cmd{src, dst}) + if ts.pipeHook != nil { + return ts.pipeHook(src, dst) + } + return ts.OSSys.Pipe(src, dst) +} + func (ts *testSys) Getenv(key string) string { return ts.env[key] } func (ts *testSys) Setenv(key, value string) { ts.env[key] = value } func (ts *testSys) Unsetenv(key string) { delete(ts.env, key) } diff --git a/internal/moatinit/volume.go b/internal/moatinit/volume.go index e369c6eb..b32af2d0 100644 --- a/internal/moatinit/volume.go +++ b/internal/moatinit/volume.go @@ -1,6 +1,11 @@ package moatinit -import "strings" +import ( + "fmt" + "io/fs" + "strconv" + "strings" +) // workspaceVolumeEnabled mirrors WS-01: populate_workspace_volume runs only // when MOAT_WORKSPACE_VOLUME is exactly the string "1" — not "true", "01", @@ -34,3 +39,110 @@ func excludeFileContent(excludes string) string { func volumeChownPaths(v string) []string { return strings.Fields(v) } + +// populateWorkspaceVolumePhase mirrors populate_workspace_volume (WS +// region): copy the read-only staging tree into the named /workspace volume +// before the privilege drop. +// +// Go owns the business logic — the "1" gate, the root guard, staging +// resolution, exclude-file assembly, the tar argument vectors, BOTH pipe +// exit codes, and the recursive chown — while the byte copy itself stays a +// targeted `tar -cf - . | tar -xf -` subprocess pair. Symlinks are copied +// as symlinks (tar's default; the explicit no-dereference long option only +// exists in GNU tar 1.35+, and debian bookworm ships 1.34 which rejects it), +// and excludes go through a temp file so the user-controlled value never +// expands on a command line. Excludes are newline-delimited, NOT --null: +// GNU tar 1.34's `--null --exclude-from` applies only the first record. +func populateWorkspaceVolumePhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if !workspaceVolumeEnabled(cfg.WorkspaceVolume) { + return nil // WS-01: exact-"1" gate, checked before everything else + } + // WS-02: defensive root guard — the call site is before the privilege + // drop; this makes a refactor that moves it after the drop fail loudly + // instead of hitting a silent chown EPERM. + if sys.Geteuid() != 0 { + fmt.Fprintln(ctx.Stderr, "moat: populate_workspace_volume must run as root") + return exitError{code: 1} + } + + staging := stagingDir(cfg.WorkspaceStaging) + excludeFile := "/tmp/moat-excludes." + strconv.Itoa(sys.Getpid()) + if err := sys.WriteFile(excludeFile, []byte(excludeFileContent(cfg.WorkspaceExcludes)), 0o644); err != nil { + return fatalPhaseError(ctx, "writing exclude file", err) + } + + // The exclude-file path lands in tar's argv and the directories in its + // working dirs — subprocess-visible, so they go through RealPath. + srcRC, dstRC, err := sys.Pipe( + Cmd{ + Argv: []string{"tar", "--exclude-from=" + sys.RealPath(excludeFile), "-cf", "-", "."}, + Dir: sys.RealPath(staging), + Stderr: ctx.Stderr, + }, + Cmd{ + Argv: []string{"tar", "-xf", "-"}, + Dir: sys.RealPath("/workspace"), + Stderr: ctx.Stderr, + }, + ) + // Temp-file cleanup happens BEFORE the status check so it runs on the + // failure path too (WS-11). + _ = sys.Remove(excludeFile) + if err != nil { + // The source tar could not even start (typically a missing staging + // dir). Fail closed with the script's rc-check message — never a + // silently empty /workspace. + srcRC, dstRC = 1, 0 + } + if srcRC != 0 || dstRC != 0 { + fmt.Fprintf(ctx.Stderr, "moat: failed to populate workspace volume (src=%d dst=%d)\n", srcRC, dstRC) + return exitError{code: 1} + } + + // Hand the fresh (root-owned) volume to the agent user. Unlike the + // staging chowns this is UNGUARDED in the script — a failure (including + // a missing moatuser account) is fatal (WS-10). lchown per node: the + // re-own must never follow a symlink out of the tree. + u, ok := sys.LookupUser("moatuser") + if !ok { + return fatalPhaseError(ctx, "chowning /workspace", fmt.Errorf("user moatuser does not exist")) + } + var chownErr error + walkErr := sys.WalkDir("/workspace", func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + if cerr := sys.Lchown(path, u.UID, u.GID); cerr != nil && chownErr == nil { + chownErr = cerr + } + return nil + }) + if walkErr != nil { + return fatalPhaseError(ctx, "chowning /workspace", walkErr) + } + if chownErr != nil { + return fatalPhaseError(ctx, "chowning /workspace", chownErr) + } + return nil +} + +// namedVolumeChownPhase mirrors the named-volume ownership block: Docker +// named volumes are created root-owned, so each mount root is chowned to +// moatuser — NON-recursively on purpose (a fresh volume's root is the only +// root-owned node; chown -R over a multi-GB cache on every start would +// reintroduce the slowness this feature avoids) and best-effort. +func namedVolumeChownPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.VolumeChown == "" || sys.Geteuid() != 0 || !moatuserExists(sys) { + return nil + } + u, ok := sys.LookupUser("moatuser") + if !ok { + return nil + } + for _, p := range volumeChownPaths(cfg.VolumeChown) { + _ = sys.Chown(p, u.UID, u.GID) // best-effort, silent + } + return nil +} diff --git a/internal/moatinit/volume_phase_test.go b/internal/moatinit/volume_phase_test.go new file mode 100644 index 00000000..7f09d4a0 --- /dev/null +++ b/internal/moatinit/volume_phase_test.go @@ -0,0 +1,298 @@ +package moatinit + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// setupStagingTree builds the staging fixture mirrored from the existing +// deps.TestVolumeCopyInPipeline: excluded single-component and nested dirs, +// a kept sibling, and a dangling symlink. +func setupStagingTree(t *testing.T, ts *testSys) { + t.Helper() + root := ts.Root + for _, dir := range []string{"mnt/host-workspace/node_modules", "mnt/host-workspace/dist/sub", "mnt/host-workspace/dist/keep", "workspace"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + "mnt/host-workspace/main.go": "package main", + "mnt/host-workspace/node_modules/pkg.json": "{}", + "mnt/host-workspace/dist/sub/bundle.js": "x", + "mnt/host-workspace/dist/keep/artifact.txt": "keep", + } + for p, content := range files { + if err := os.WriteFile(filepath.Join(root, p), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("/nonexistent-target", filepath.Join(root, "mnt/host-workspace/dangling")); err != nil { + t.Fatal(err) + } +} + +// TestPopulateWorkspaceVolumeRealTar runs the phase with the real tar pipe +// against the injected root: excludes applied in full, symlinks preserved, +// every extracted node re-owned via lchown, temp exclude file cleaned up. +func TestPopulateWorkspaceVolumeRealTar(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + ts := newTestSys(t, 0, true) + setupStagingTree(t, ts) + + ctx, _ := newTestContext(ts, Config{ + WorkspaceVolume: "1", + WorkspaceExcludes: "./node_modules\n./dist/sub", + Home: "/root", + }) + if err := populateWorkspaceVolumePhase(ctx); err != nil { + t.Fatal(err) + } + + // Excludes: single-component and nested both absent (the GNU tar 1.34 + // newline --exclude-from contract), sibling of the nested exclude kept. + if exists(ts, "/workspace/node_modules") { + t.Error("excluded node_modules was copied") + } + if exists(ts, "/workspace/dist/sub") { + t.Error("excluded dist/sub was copied") + } + if !exists(ts, "/workspace/dist/keep/artifact.txt") { + t.Error("non-excluded dist/keep missing") + } + if got := fileContent(t, ts, "/workspace/main.go"); got != "package main" { + t.Errorf("main.go = %q", got) + } + // WS-08: the dangling symlink is copied as a symlink, never dereferenced. + info, err := os.Lstat(filepath.Join(ts.Root, "workspace/dangling")) + if err != nil { + t.Fatal("dangling symlink missing from /workspace") + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("symlink was dereferenced during the copy") + } + + // WS-10: every node re-owned — via lchown, so the symlink itself (not + // its target) is in the set. + for _, p := range []string{"/workspace", "/workspace/main.go", "/workspace/dist", "/workspace/dangling"} { + if !ts.chowned(p) { + t.Errorf("missing chown for %s", p) + } + } + for _, c := range ts.chowns { + if !c.lchown { + t.Errorf("chown of %s did not use lchown", c.path) + } + } + + // WS-11: temp exclude file removed. + matches, _ := filepath.Glob(filepath.Join(ts.Root, "tmp/moat-excludes.*")) + if len(matches) != 0 { + t.Errorf("exclude temp files left behind: %v", matches) + } +} + +func TestPopulateWorkspaceVolumeGate(t *testing.T) { + // WS-01: any non-"1" value is a no-op — checked BEFORE the root guard, + // so a disabled populate as non-root is fine. + for _, v := range []string{"", "0", "true", " 1", "01"} { + ts := newTestSys(t, 1000, true) + ctx, _ := newTestContext(ts, Config{WorkspaceVolume: v, Home: "/root"}) + if err := populateWorkspaceVolumePhase(ctx); err != nil { + t.Errorf("WorkspaceVolume=%q: %v", v, err) + } + if len(ts.pipes) != 0 { + t.Errorf("WorkspaceVolume=%q ran tar", v) + } + } +} + +func TestPopulateWorkspaceVolumeRootGuard(t *testing.T) { + // WS-02: enabled as non-root is fatal with the exact message. + ts := newTestSys(t, 1000, true) + ctx, stderr := newTestContext(ts, Config{WorkspaceVolume: "1", Home: "/root"}) + err := populateWorkspaceVolumePhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + if stderr.String() != "moat: populate_workspace_volume must run as root\n" { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestPopulateWorkspaceVolumeMissingStagingFatal(t *testing.T) { + // B-P2: a missing staging root is fatal (src leg cannot start), never a + // silently empty /workspace. + ts := newTestSys(t, 0, true) + if err := os.MkdirAll(filepath.Join(ts.Root, "workspace"), 0o755); err != nil { + t.Fatal(err) + } + ctx, stderr := newTestContext(ts, Config{WorkspaceVolume: "1", Home: "/root"}) + err := populateWorkspaceVolumePhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + if !strings.Contains(stderr.String(), "moat: failed to populate workspace volume (src=1 dst=0)") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestPopulateWorkspaceVolumeRCClassification(t *testing.T) { + // WS-09: either end failing is fatal, with both codes reported. + cases := []struct { + src, dst int + want string + }{ + {2, 0, "moat: failed to populate workspace volume (src=2 dst=0)"}, + {0, 2, "moat: failed to populate workspace volume (src=0 dst=2)"}, + } + for _, tc := range cases { + ts := newTestSys(t, 0, true) + setupStagingTree(t, ts) + ts.pipeHook = func(src, dst Cmd) (int, int, error) { return tc.src, tc.dst, nil } + ctx, stderr := newTestContext(ts, Config{WorkspaceVolume: "1", Home: "/root"}) + err := populateWorkspaceVolumePhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 1 { + t.Fatalf("src=%d dst=%d: err = %v, want exitError{1}", tc.src, tc.dst, err) + } + if !strings.Contains(stderr.String(), tc.want) { + t.Errorf("stderr = %q, want %q", stderr.String(), tc.want) + } + // No chown after a failed copy. + if len(ts.chowns) != 0 { + t.Error("chown ran after failed copy") + } + // WS-11: temp file cleaned up on the failure path too. + matches, _ := filepath.Glob(filepath.Join(ts.Root, "tmp/moat-excludes.*")) + if len(matches) != 0 { + t.Errorf("exclude temp files left after failure: %v", matches) + } + } +} + +func TestPopulateWorkspaceVolumeArgAssembly(t *testing.T) { + // The assembled tar argv and exclude-file contents are the contract Go + // now owns (§6): capture them via the pipe hook. + ts := newTestSys(t, 0, true) + setupStagingTree(t, ts) + var excludeContent string + ts.pipeHook = func(src, dst Cmd) (int, int, error) { + for _, a := range src.Argv { + if strings.HasPrefix(a, "--exclude-from=") { + data, err := os.ReadFile(strings.TrimPrefix(a, "--exclude-from=")) + if err != nil { + t.Fatalf("reading exclude file: %v", err) + } + excludeContent = string(data) + } + } + return 0, 0, nil + } + ctx, _ := newTestContext(ts, Config{ + WorkspaceVolume: "1", + WorkspaceStaging: "/custom/staging", + WorkspaceExcludes: "./node_modules\n./dist/sub", + Home: "/root", + }) + if err := os.MkdirAll(filepath.Join(ts.Root, "custom/staging"), 0o755); err != nil { + t.Fatal(err) + } + if err := populateWorkspaceVolumePhase(ctx); err != nil { + t.Fatal(err) + } + + src, dst := ts.pipes[0][0], ts.pipes[0][1] + if got := strings.Join(src.Argv[2:], " "); got != "-cf - ." { + t.Errorf("src tar argv tail = %q, want '-cf - .'", got) + } + if src.Argv[0] != "tar" || !strings.HasPrefix(src.Argv[1], "--exclude-from=") { + t.Errorf("src tar argv = %v", src.Argv) + } + if got := strings.Join(dst.Argv, " "); got != "tar -xf -" { + t.Errorf("dst tar argv = %q, want 'tar -xf -'", got) + } + // WS-03: explicit staging honored (as the subprocess-visible path). + if src.Dir != ts.RealPath("/custom/staging") { + t.Errorf("src dir = %q", src.Dir) + } + if dst.Dir != ts.RealPath("/workspace") { + t.Errorf("dst dir = %q", dst.Dir) + } + // WS-04: exclude file carries the env value byte-for-byte. + if excludeContent != "./node_modules\n./dist/sub" { + t.Errorf("exclude file = %q", excludeContent) + } +} + +func TestPopulateWorkspaceVolumeChownFailureFatal(t *testing.T) { + // WS-10 companion: unlike the staging chowns, the /workspace re-own is + // unguarded — a chown failure is fatal. + ts := newTestSys(t, 0, true) + setupStagingTree(t, ts) + ts.pipeHook = func(src, dst Cmd) (int, int, error) { return 0, 0, nil } + ts.chownErr = os.ErrPermission + ctx, _ := newTestContext(ts, Config{WorkspaceVolume: "1", Home: "/root"}) + err := populateWorkspaceVolumePhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + + // And with no moatuser account the chown target is invalid: fatal too. + ts2 := newTestSys(t, 0, false) + setupStagingTree(t, ts2) + ts2.pipeHook = func(src, dst Cmd) (int, int, error) { return 0, 0, nil } + ctx2, _ := newTestContext(ts2, Config{WorkspaceVolume: "1", Home: "/root"}) + if exit, ok := populateWorkspaceVolumePhase(ctx2).(exitError); !ok || exit.code != 1 { + t.Fatal("missing moatuser should make the re-own fatal") + } +} + +func TestNamedVolumeChownPhase(t *testing.T) { + // Gate matrix (EXEC-07): runs only when all three hold. + cases := []struct { + name string + val string + euid int + moatuser bool + want int // chown calls + }{ + {"all conditions", "/vol/a /vol/[b]", 0, true, 2}, + {"unset", "", 0, true, 0}, + {"non-root", "/vol/a", 1000, true, 0}, + {"no moatuser", "/vol/a", 0, false, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ts := newTestSys(t, tc.euid, tc.moatuser) + ctx, _ := newTestContext(ts, Config{VolumeChown: tc.val, Home: "/root"}) + if err := namedVolumeChownPhase(ctx); err != nil { + t.Fatal(err) + } + if len(ts.chowns) != tc.want { + t.Errorf("chown calls = %d, want %d (%v)", len(ts.chowns), tc.want, ts.chowns) + } + }) + } + + // EXEC-08/09: glob chars stay literal, chown is non-recursive (exactly + // the listed roots, nothing beneath), errors swallowed. + ts := newTestSys(t, 0, true) + ts.chownErr = os.ErrPermission + ctx, _ := newTestContext(ts, Config{VolumeChown: "/r/[x] /r/normal", Home: "/root"}) + if err := namedVolumeChownPhase(ctx); err != nil { + t.Fatalf("best-effort chown aborted: %v", err) + } + if !ts.chowned("/r/[x]") || !ts.chowned("/r/normal") { + t.Errorf("chowns = %v", ts.chowns) + } + if len(ts.chowns) != 2 { + t.Errorf("non-recursive chown touched %d paths", len(ts.chowns)) + } +} From c2e26a5a730d5a08fe24d10a3ecffe4b0da9e634 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:55:25 +0000 Subject: [PATCH 06/17] feat(moatinit): long-lived children (socat/Xvfb/dockerd) + pre-run hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 5 of the moat-init rewrite. socat, Xvfb, and dockerd stay targeted long-lived subprocesses, spawned exactly as the shell backgrounded them — same process group (non-interactive sh has job control off, so no Setpgid; signal delivery to the container's foreground group must reach them identically), reaped opportunistically so the kill -0 liveness check matches the shell's view of a dead background job, and surviving the final exec because the handoff replaces the process image without touching children. Go owns the surrounding decisions: SSH socket dir/mode/ownership, the 20x0.1s socket wait with the two exact warnings; Xvfb :99 fire-and-forget with DISPLAY exported even on spawn failure; the docker mutex error, the dind readiness poll (30x1s, socket AND docker info — the info probe gets a 2s per-attempt timeout so a hang cannot eat the loop budget), the exact error/tail-20 diagnostics, dind group setup, and host-socket GID detection via an in-container stat (no GNU stat -c) with groupadd/usermod as best-effort subprocesses. The pre-run hook ports the root->gosu->sh -c branch verbatim (Go selects the branch; gosu runs the hook), confines the non-root hook's cwd to the child, and preserves the framed diagnostic plus the hook's literal exit code (issue #372). Cmd gains LogFile (dockerd's 2>&1 redirect) and Timeout; signal-terminated children report 128+n like $?. --- cmd/moat-init/main.go | 1 + internal/moatinit/children_phase_test.go | 406 +++++++++++++++++++++++ internal/moatinit/clipboard.go | 20 ++ internal/moatinit/docker.go | 145 ++++++++ internal/moatinit/fsutil.go | 6 + internal/moatinit/hook.go | 64 ++++ internal/moatinit/phase.go | 13 +- internal/moatinit/ssh.go | 79 +++++ internal/moatinit/sys.go | 38 ++- internal/moatinit/testsys_test.go | 35 ++ 10 files changed, 801 insertions(+), 6 deletions(-) create mode 100644 internal/moatinit/children_phase_test.go create mode 100644 internal/moatinit/clipboard.go create mode 100644 internal/moatinit/hook.go create mode 100644 internal/moatinit/ssh.go diff --git a/cmd/moat-init/main.go b/cmd/moat-init/main.go index 2d8d4c88..5babe7fa 100644 --- a/cmd/moat-init/main.go +++ b/cmd/moat-init/main.go @@ -21,6 +21,7 @@ func main() { Sys: sys, Cfg: moatinit.LoadConfig(sys), Argv: os.Args[1:], + Stdout: os.Stdout, Stderr: os.Stderr, } os.Exit(moatinit.Run(ctx)) diff --git a/internal/moatinit/children_phase_test.go b/internal/moatinit/children_phase_test.go new file mode 100644 index 00000000..ae3c772c --- /dev/null +++ b/internal/moatinit/children_phase_test.go @@ -0,0 +1,406 @@ +package moatinit + +import ( + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestSSHAgentBridgePhaseGate(t *testing.T) { + // SSH-01: unset/empty — no dir, no socat, no warning. + ts := newTestSys(t, 0, true) + ctx, stderr := newTestContext(ts, Config{}) + if err := sshAgentBridgePhase(ctx); err != nil { + t.Fatal(err) + } + if exists(ts, "/run/moat/ssh") || len(ts.detached) != 0 || stderr.Len() != 0 { + t.Error("empty MOAT_SSH_TCP_ADDR did work") + } +} + +func TestSSHAgentBridgePhaseSuccess(t *testing.T) { + ts := newTestSys(t, 0, true) + // Simulate socat creating the socket: a real unix listener at the + // rerooted path (created by the detach hook, like socat would). + ts.detachHook = func(c Cmd) (int, error) { + sockPath := strings.TrimPrefix(strings.SplitN(c.Argv[1], ",", 2)[0], "UNIX-LISTEN:") + l, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("test listener: %v", err) + } + t.Cleanup(func() { l.Close() }) + return 4242, nil + } + ctx, stderr := newTestContext(ts, Config{SSHTCPAddr: "192.168.65.2:5522"}) + if err := sshAgentBridgePhase(ctx); err != nil { + t.Fatal(err) + } + + // Directory prepared: 0755 + chowned to moatuser. + if got := statMode(t, ts, "/run/moat/ssh"); got != 0o755 { + t.Errorf("socket dir mode = %o, want 755", got) + } + if !ts.chowned("/run/moat/ssh") { + t.Error("socket dir not chowned") + } + // socat argv: forking unix listener at mode 0660 bridged to the addr. + argv := ts.detached[0].Argv + if argv[0] != "socat" || !strings.Contains(argv[1], ",fork,mode=0660") || argv[2] != "TCP:192.168.65.2:5522" { + t.Errorf("socat argv = %v", argv) + } + // Success path: socket chowned, no warnings. + if !ts.chowned("/run/moat/ssh/agent.sock") { + t.Error("socket not chowned on success path") + } + if stderr.Len() != 0 { + t.Errorf("success path warned: %q", stderr.String()) + } +} + +func TestSSHAgentBridgePhaseSocatDied(t *testing.T) { + // SSH-08: socat no longer running after the wait — exact warning, and + // the phase still succeeds (warning only). + ts := newTestSys(t, 0, true) + ts.alive[4242] = false + ctx, stderr := newTestContext(ts, Config{SSHTCPAddr: "1.2.3.4:5"}) + if err := sshAgentBridgePhase(ctx); err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr.String(), "Warning: SSH agent bridge (socat) failed to start") { + t.Errorf("stderr = %q", stderr.String()) + } + // Full 2s budget consumed (socket never appeared). + if ts.sleeps != sshSocketWaitIters { + t.Errorf("sleeps = %d, want %d", ts.sleeps, sshSocketWaitIters) + } +} + +func TestSSHAgentBridgePhaseSocketNeverAppeared(t *testing.T) { + // SSH-09: socat alive but no socket — exact warning, still non-fatal. + ts := newTestSys(t, 0, true) + ctx, stderr := newTestContext(ts, Config{SSHTCPAddr: "1.2.3.4:5"}) + if err := sshAgentBridgePhase(ctx); err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr.String(), "Warning: SSH agent socket was not created after 2s") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestClipboardPhase(t *testing.T) { + // X-CLIPBOARD-XVFB: exact "1" starts Xvfb :99 and exports DISPLAY. + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{Clipboard: "1"}) + if err := clipboardPhase(ctx); err != nil { + t.Fatal(err) + } + if got := strings.Join(ts.detached[0].Argv, " "); got != "Xvfb :99 -screen 0 1x1x8" { + t.Errorf("Xvfb argv = %q", got) + } + if ts.env["DISPLAY"] != ":99" { + t.Error("DISPLAY not exported") + } + + // GIT-CLIP-02: DISPLAY exported even when the spawn fails. + ts2 := newTestSys(t, 0, true) + ts2.detachHook = func(Cmd) (int, error) { return 0, os.ErrNotExist } + ctx2, _ := newTestContext(ts2, Config{Clipboard: "1"}) + if err := clipboardPhase(ctx2); err != nil { + t.Fatal(err) + } + if ts2.env["DISPLAY"] != ":99" { + t.Error("DISPLAY not exported after failed Xvfb spawn") + } + + // Companions: any non-"1" value is a no-op. + for _, v := range []string{"", "0", "true"} { + ts3 := newTestSys(t, 0, true) + ctx3, _ := newTestContext(ts3, Config{Clipboard: v}) + if err := clipboardPhase(ctx3); err != nil { + t.Fatal(err) + } + if len(ts3.detached) != 0 || ts3.env["DISPLAY"] != "" { + t.Errorf("Clipboard=%q did work", v) + } + } +} + +func TestDockerSetupPhaseMutex(t *testing.T) { + // DOCKER-01: both set — exit 1 with the exact three lines, before + // either mode body runs. + ts := newTestSys(t, 0, true) + ctx, stderr := newTestContext(ts, Config{DockerDIND: "1", DockerGID: "999"}) + err := dockerSetupPhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + want := "Error: MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive\n" + + "Use MOAT_DOCKER_GID when mounting host's docker socket\n" + + "Use MOAT_DOCKER_DIND when running Docker-in-Docker\n" + if stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + if len(ts.detached) != 0 || len(ts.runs) != 0 { + t.Error("mode body ran despite mutex violation") + } + + // Companion: one set — no mutex error. + ts2 := newTestSys(t, 1000, true) // non-root: dind body also skipped + ctx2, stderr2 := newTestContext(ts2, Config{DockerDIND: "1"}) + if err := dockerSetupPhase(ctx2); err != nil { + t.Fatal(err) + } + if stderr2.Len() != 0 { + t.Errorf("single-var run warned: %q", stderr2.String()) + } +} + +func TestDindSetupReadyPath(t *testing.T) { + ts := newTestSys(t, 0, true) + // dockerd "creates" the socket when spawned. + ts.detachHook = func(c Cmd) (int, error) { + if c.Argv[0] != "dockerd" { + t.Fatalf("unexpected detached child: %v", c.Argv) + } + dir := filepath.Join(ts.Root, "var/run") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + l, err := net.Listen("unix", filepath.Join(dir, "docker.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + return 777, nil + } + ctx, stderr := newTestContext(ts, Config{DockerDIND: "1"}) + if err := dockerSetupPhase(ctx); err != nil { + t.Fatal(err) + } + out := stderr.String() + for _, want := range []string{ + "Starting Docker daemon (dind mode)...", + "Waiting for Docker daemon to be ready...", + "Docker daemon is ready (took 0s)", + } { + if !strings.Contains(out, want) { + t.Errorf("stderr missing %q:\n%s", want, out) + } + } + // dockerd argv + log redirection. + if got := strings.Join(ts.detached[0].Argv, " "); got != "dockerd --storage-driver=vfs --log-level=warn" { + t.Errorf("dockerd argv = %q", got) + } + if ts.detached[0].LogFile != "/var/log/dockerd.log" { + t.Errorf("dockerd log = %q", ts.detached[0].LogFile) + } + // Group setup: docker group missing -> groupadd, then usermod. + cmds := make([]string, 0, len(ts.runs)) + for _, c := range ts.runs { + cmds = append(cmds, strings.Join(c.Argv, " ")) + } + joined := strings.Join(cmds, "\n") + if !strings.Contains(joined, "groupadd docker") || !strings.Contains(joined, "usermod -aG docker moatuser") { + t.Errorf("group setup commands = %v", cmds) + } +} + +func TestDindSetupDaemonDiedFatal(t *testing.T) { + // DOCKER-06: dockerd dies during the wait — fatal with the log tail. + ts := newTestSys(t, 0, true) + if err := os.MkdirAll(filepath.Join(ts.Root, "var/log"), 0o755); err != nil { + t.Fatal(err) + } + ts.detachHook = func(c Cmd) (int, error) { + if err := ts.WriteFile("/var/log/dockerd.log", []byte("line1\nfailed to start daemon: boom\n"), 0o644); err != nil { + t.Fatal(err) + } + return 777, nil + } + ts.alive[777] = false + ts.runHook = func(Cmd) (int, error) { return 1, nil } // docker info never succeeds + ctx, stderr := newTestContext(ts, Config{DockerDIND: "1"}) + err := dockerSetupPhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + out := stderr.String() + for _, want := range []string{ + "Error: Docker daemon failed to start", + "Check /var/log/dockerd.log for details:", + "failed to start daemon: boom", + } { + if !strings.Contains(out, want) { + t.Errorf("stderr missing %q:\n%s", want, out) + } + } +} + +func TestDindSetupTimeoutFatal(t *testing.T) { + // DOCKER-07: alive but never ready — timeout error with socket state. + ts := newTestSys(t, 0, true) + ts.runHook = func(Cmd) (int, error) { return 1, nil } + ctx, stderr := newTestContext(ts, Config{DockerDIND: "1"}) + err := dockerSetupPhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + out := stderr.String() + if !strings.Contains(out, "Error: Docker daemon did not become ready within 30 seconds") { + t.Errorf("missing timeout error:\n%s", out) + } + if !strings.Contains(out, "Socket exists: no") { + t.Errorf("missing socket state:\n%s", out) + } + if ts.sleeps != dindTimeoutSeconds { + t.Errorf("sleeps = %d, want %d", ts.sleeps, dindTimeoutSeconds) + } +} + +func TestHostSocketSetup(t *testing.T) { + // DOCKER-10/12/13: GID detected from an in-container stat; group + // created when the GID is unowned; moatuser joins the owning group. + newSockSys := func(t *testing.T) *testSys { + ts := newTestSys(t, 0, true) + dir := filepath.Join(ts.Root, "var/run") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + l, err := net.Listen("unix", filepath.Join(dir, "docker.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + return ts + } + gid := strconv.Itoa(os.Getgid()) // the test socket's actual gid + + // Case A: a group already owns the GID — reused, no groupadd. + ts := newSockSys(t) + ts.groupsByGID[gid] = "staff" + ctx, stderr := newTestContext(ts, Config{DockerGID: "999"}) + if err := dockerSetupPhase(ctx); err != nil { + t.Fatal(err) + } + cmds := make([]string, 0, len(ts.runs)) + for _, c := range ts.runs { + cmds = append(cmds, strings.Join(c.Argv, " ")) + } + if strings.Contains(strings.Join(cmds, "\n"), "groupadd") { + t.Errorf("groupadd ran for an owned GID: %v", cmds) + } + if !strings.Contains(strings.Join(cmds, "\n"), "usermod -aG staff moatuser") { + t.Errorf("usermod missing: %v", cmds) + } + if stderr.Len() != 0 { + t.Errorf("warned on the happy path: %q", stderr.String()) + } + + // Case B: unowned GID — groupadd -g moat-docker; the group map + // updates when groupadd runs (as the real getent re-resolution would). + ts2 := newSockSys(t) + ts2.runHook = func(c Cmd) (int, error) { + if c.Argv[0] == "groupadd" { + ts2.groupsByGID[c.Argv[2]] = "moat-docker" + } + return 0, nil + } + ctx2, _ := newTestContext(ts2, Config{DockerGID: "999"}) + if err := dockerSetupPhase(ctx2); err != nil { + t.Fatal(err) + } + cmds2 := make([]string, 0, len(ts2.runs)) + for _, c := range ts2.runs { + cmds2 = append(cmds2, strings.Join(c.Argv, " ")) + } + joined := strings.Join(cmds2, "\n") + if !strings.Contains(joined, "groupadd -g "+gid+" moat-docker") { + t.Errorf("groupadd missing/wrong: %v", cmds2) + } + if !strings.Contains(joined, "usermod -aG moat-docker moatuser") { + t.Errorf("usermod missing: %v", cmds2) + } + + // Case C: socket absent — host mode skipped entirely (DOCKER-09). + ts3 := newTestSys(t, 0, true) + ctx3, stderr3 := newTestContext(ts3, Config{DockerGID: "999"}) + if err := dockerSetupPhase(ctx3); err != nil { + t.Fatal(err) + } + if len(ts3.runs) != 0 || stderr3.Len() != 0 { + t.Error("host mode ran without a socket") + } +} + +func TestPreRunHookPhase(t *testing.T) { + // EXEC-01: empty/unset — no-op (a whitespace command DOES run). + ts := newTestSys(t, 1000, true) + ctx, _ := newTestContext(ts, Config{}) + if err := preRunHookPhase(ctx); err != nil { + t.Fatal(err) + } + if len(ts.runs) != 0 { + t.Error("empty hook ran") + } + + // EXEC-02: non-root runs sh -c in /workspace (child-confined cwd). + ts2 := newTestSys(t, 1000, true) + ctx2, _ := newTestContext(ts2, Config{PreRun: "npm install"}) + if err := preRunHookPhase(ctx2); err != nil { + t.Fatal(err) + } + c := ts2.runs[0] + if got := strings.Join(c.Argv, " "); got != "sh -c npm install" { + t.Errorf("non-root hook argv = %v", c.Argv) + } + if c.Dir != ts2.RealPath("/workspace") { + t.Errorf("hook dir = %q", c.Dir) + } + + // EXEC-03: root+moatuser goes through gosu with the exact command string. + ts3 := newTestSys(t, 0, true) + ctx3, _ := newTestContext(ts3, Config{PreRun: "npm install"}) + if err := preRunHookPhase(ctx3); err != nil { + t.Fatal(err) + } + want := []string{"gosu", "moatuser", "sh", "-c", "cd /workspace && npm install"} + if strings.Join(ts3.runs[0].Argv, "\x00") != strings.Join(want, "\x00") { + t.Errorf("root hook argv = %v, want %v", ts3.runs[0].Argv, want) + } + + // EXEC-04: root without moatuser — hook silently skipped, no error. + ts4 := newTestSys(t, 0, false) + ctx4, stderr4 := newTestContext(ts4, Config{PreRun: "touch /should-not-exist"}) + if err := preRunHookPhase(ctx4); err != nil { + t.Fatal(err) + } + if len(ts4.runs) != 0 || stderr4.Len() != 0 { + t.Error("root-no-moatuser hook ran or warned") + } +} + +func TestPreRunHookPhaseFailureFramedAndLiteralExit(t *testing.T) { + // EXEC-06: framed diagnostic + the hook's LITERAL exit code. + ts := newTestSys(t, 0, true) + ts.runHook = func(Cmd) (int, error) { return 42, nil } + ctx, stderr := newTestContext(ts, Config{PreRun: "echo doing-setup; exit 42"}) + err := preRunHookPhase(ctx) + exit, ok := err.(exitError) + if !ok { + t.Fatalf("err = %v, want exitError", err) + } + if exit.code != 42 { + t.Errorf("exit code = %d, want the hook's literal 42", exit.code) + } + want := "\n" + + "moat: pre_run hook failed (exit code 42)\n" + + "moat: command: echo doing-setup; exit 42\n" + + "moat: the pre_run hook runs as moatuser in /workspace before your command.\n" + + "moat: fix the command above, or remove hooks.pre_run from moat.yaml.\n" + if stderr.String() != want { + t.Errorf("framed message = %q, want %q", stderr.String(), want) + } +} diff --git a/internal/moatinit/clipboard.go b/internal/moatinit/clipboard.go new file mode 100644 index 00000000..eaca05ef --- /dev/null +++ b/internal/moatinit/clipboard.go @@ -0,0 +1,20 @@ +package moatinit + +// clipboardPhase mirrors the clipboard bridging block: when MOAT_CLIPBOARD +// is exactly "1", start a headless X server for clipboard operations (the +// host writes clipboard data and uses xclip to set the X selection) and +// export DISPLAY=:99. +// +// Xvfb is fire-and-forget: no readiness wait, output discarded, and DISPLAY +// is exported even if the spawn failed (GIT-CLIP-02) — it persists into the +// exec'd user command and later `moat` exec sessions. The child must stay +// alive for the container's lifetime, exactly like socat/dockerd. +func clipboardPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.Clipboard != "1" { + return nil + } + _, _ = sys.StartDetached(Cmd{Argv: []string{"Xvfb", ":99", "-screen", "0", "1x1x8"}}) // >/dev/null 2>&1 & + sys.Setenv("DISPLAY", ":99") + return nil +} diff --git a/internal/moatinit/docker.go b/internal/moatinit/docker.go index 686bfda7..1dc1f0ac 100644 --- a/internal/moatinit/docker.go +++ b/internal/moatinit/docker.go @@ -1,5 +1,150 @@ package moatinit +import ( + "fmt" + "strconv" + "strings" + "syscall" + "time" +) + +// dindTimeoutSeconds mirrors DIND_TIMEOUT_SECONDS: the readiness budget for +// dockerd in dind mode. +const dindTimeoutSeconds = 30 + +const dockerSocketPath = "/var/run/docker.sock" + +// dockerSetupPhase mirrors the Docker access setup region: the mutual +// exclusion guard first (DOCKER-14 — before either mode body), then at most +// one of the dind or host-socket blocks. +func dockerSetupPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if dockerMutexViolated(cfg.DockerDIND, cfg.DockerGID) { + fmt.Fprintln(ctx.Stderr, "Error: MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive") + fmt.Fprintln(ctx.Stderr, "Use MOAT_DOCKER_GID when mounting host's docker socket") + fmt.Fprintln(ctx.Stderr, "Use MOAT_DOCKER_DIND when running Docker-in-Docker") + return exitError{code: 1} + } + if dindActive(cfg.DockerDIND, sys.Geteuid()) { + return dindSetup(ctx) + } + if hostGIDActive(cfg.DockerGID, sys.Geteuid(), isSocket(sys, dockerSocketPath)) { + hostSocketSetup(ctx) + } + return nil +} + +// dindSetup starts dockerd inside the container and waits for readiness +// (DOCKER-03..08). dockerd is a targeted long-lived child; readiness +// requires BOTH the socket existing AND `docker info` succeeding (the +// socket must exist for non-root users). Failure to start or to become +// ready within the budget is fatal. +func dindSetup(ctx *Context) error { + sys := ctx.Sys + fmt.Fprintln(ctx.Stderr, "Starting Docker daemon (dind mode)...") + + // Unguarded in the script: a mkdir failure here is fatal under set -e. + if err := sys.MkdirAll("/var/run", 0o755); err != nil { + return fatalPhaseError(ctx, "creating /var/run", err) + } + + pid, err := sys.StartDetached(Cmd{ + Argv: []string{"dockerd", "--storage-driver=vfs", "--log-level=warn"}, + LogFile: "/var/log/dockerd.log", + }) + if err != nil { + fmt.Fprintln(ctx.Stderr, "Error: Docker daemon failed to start") + fmt.Fprintln(ctx.Stderr, "Check /var/log/dockerd.log for details:") + tailDockerdLog(ctx) + return exitError{code: 1} + } + + fmt.Fprintln(ctx.Stderr, "Waiting for Docker daemon to be ready...") + waited := 0 + for waited < dindTimeoutSeconds { + // docker info gets a per-attempt timeout so a hang cannot consume + // the whole 30s budget in one probe (plan Appendix B). + if isSocket(sys, dockerSocketPath) { + if rc, _ := sys.Run(Cmd{Argv: []string{"docker", "info"}, Timeout: 2 * time.Second}); rc == 0 { + fmt.Fprintf(ctx.Stderr, "Docker daemon is ready (took %ds)\n", waited) + break + } + } + if !sys.ProcessAlive(pid) { + fmt.Fprintln(ctx.Stderr, "Error: Docker daemon failed to start") + fmt.Fprintln(ctx.Stderr, "Check /var/log/dockerd.log for details:") + tailDockerdLog(ctx) + return exitError{code: 1} + } + sys.Sleep(time.Second) + waited++ + } + if waited >= dindTimeoutSeconds { + fmt.Fprintf(ctx.Stderr, "Error: Docker daemon did not become ready within %d seconds\n", dindTimeoutSeconds) + socketState := "no" + if isSocket(sys, dockerSocketPath) { + socketState = "yes" + } + fmt.Fprintf(ctx.Stderr, "Socket exists: %s\n", socketState) + fmt.Fprintln(ctx.Stderr, "Check /var/log/dockerd.log for details:") + tailDockerdLog(ctx) + return exitError{code: 1} + } + + // Give moatuser dockerd access (best-effort throughout — DOCKER-08). + if moatuserExists(sys) { + if _, ok := sys.LookupGroupByName("docker"); !ok { + _, _ = sys.Run(Cmd{Argv: []string{"groupadd", "docker"}}) + } + _, _ = sys.Run(Cmd{Argv: []string{"usermod", "-aG", "docker", "moatuser"}}) + } + return nil +} + +// tailDockerdLog prints the last 20 lines of the dockerd log (the in-process +// port of `tail -20 /var/log/dockerd.log 2>/dev/null || true`). +func tailDockerdLog(ctx *Context) { + data, err := ctx.Sys.ReadFile("/var/log/dockerd.log") + if err != nil { + return + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) > 20 { + lines = lines[len(lines)-20:] + } + for _, l := range lines { + fmt.Fprintln(ctx.Stderr, l) + } +} + +// hostSocketSetup mirrors the host-socket group block (DOCKER-10..13): the +// socket's GID is detected INSIDE the container (Docker Desktop on macOS +// translates ownership), a group is created at that GID when none exists, +// and moatuser joins the owning group. Warning-level only — never fatal. +func hostSocketSetup(ctx *Context) { + sys := ctx.Sys + info, err := sys.Stat(dockerSocketPath) + var socketGID string + if err == nil { + if st, ok := info.Sys().(*syscall.Stat_t); ok { + socketGID = strconv.FormatUint(uint64(st.Gid), 10) + } + } + if socketGID == "" { + fmt.Fprintln(ctx.Stderr, "Warning: Failed to detect docker socket GID, docker access may not work") + return + } + if _, ok := sys.LookupGroupByGID(socketGID); !ok { + _, _ = sys.Run(Cmd{Argv: []string{"groupadd", "-g", socketGID, "moat-docker"}}) // best-effort + } + // Re-resolve: the group may pre-exist under any name, or have just been + // created by the groupadd above. + dockerGroup, _ := sys.LookupGroupByGID(socketGID) + if dockerGroup != "" && moatuserExists(sys) { + _, _ = sys.Run(Cmd{Argv: []string{"usermod", "-aG", dockerGroup, "moatuser"}}) // best-effort + } +} + // dockerMutexViolated mirrors DOCKER-01: MOAT_DOCKER_DIND and MOAT_DOCKER_GID // are mutually exclusive whenever BOTH are non-empty (any values — the guard // tests emptiness, not "1"). diff --git a/internal/moatinit/fsutil.go b/internal/moatinit/fsutil.go index 6a31bc04..bf0def02 100644 --- a/internal/moatinit/fsutil.go +++ b/internal/moatinit/fsutil.go @@ -15,6 +15,12 @@ func isDir(sys Sys, path string) bool { return err == nil && info.IsDir() } +// isSocket mirrors `[ -S path ]`. +func isSocket(sys Sys, path string) bool { + info, err := sys.Stat(path) + return err == nil && info.Mode()&fs.ModeSocket != 0 +} + // moatuserExists mirrors `id moatuser >/dev/null 2>&1` (EXEC-14: every // branch uses the same existence check). func moatuserExists(sys Sys) bool { diff --git a/internal/moatinit/hook.go b/internal/moatinit/hook.go new file mode 100644 index 00000000..88a10e6a --- /dev/null +++ b/internal/moatinit/hook.go @@ -0,0 +1,64 @@ +package moatinit + +import "fmt" + +// preRunHookPhase mirrors run_pre_run_hook (EXEC-01..06): run the pre_run +// command as moatuser in /workspace before the main command, on every +// container start. +// +// Dispatch matches the exec branches exactly (EXEC-14): already non-root → +// run directly, with the working directory confined to the child (the +// script's subshell `( cd /workspace && sh -c ... )` — the entrypoint's own +// cwd never changes); root with moatuser → `gosu moatuser sh -c "cd +// /workspace && $MOAT_PRE_RUN"` (gosu is its own process, no subshell +// needed); root without moatuser → silently skipped (the final dispatch +// fails closed later anyway). +// +// A failing hook is reported with the framed diagnostic and the +// entrypoint exits with the hook's LITERAL exit code (issue #372) — without +// this, a hook failure looks like the container itself failed to start. +func preRunHookPhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.PreRun == "" { + return nil // EXEC-01: unset and empty are identical no-ops + } + + var hookStatus int + switch { + case sys.Geteuid() != 0: + rc, err := sys.Run(Cmd{ + Argv: []string{"sh", "-c", cfg.PreRun}, + Dir: sys.RealPath("/workspace"), + Stdout: ctx.Stdout, + Stderr: ctx.Stderr, + }) + hookStatus = rc + if err != nil { + // The child could not start at all (e.g. /workspace missing — + // the subshell's `cd` failure in the script): non-zero, framed. + hookStatus = 1 + } + case moatuserExists(sys): + rc, err := sys.Run(Cmd{ + Argv: []string{"gosu", "moatuser", "sh", "-c", "cd /workspace && " + cfg.PreRun}, + Stdout: ctx.Stdout, + Stderr: ctx.Stderr, + }) + hookStatus = rc + if err != nil { + hookStatus = 1 + } + default: + hookStatus = 0 // EXEC-04: root without moatuser — hook silently skipped + } + + if hookStatus != 0 { + fmt.Fprintln(ctx.Stderr, "") + fmt.Fprintf(ctx.Stderr, "moat: pre_run hook failed (exit code %d)\n", hookStatus) + fmt.Fprintf(ctx.Stderr, "moat: command: %s\n", cfg.PreRun) + fmt.Fprintln(ctx.Stderr, "moat: the pre_run hook runs as moatuser in /workspace before your command.") + fmt.Fprintln(ctx.Stderr, "moat: fix the command above, or remove hooks.pre_run from moat.yaml.") + return exitError{code: hookStatus} + } + return nil +} diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index 890ac5f2..fe4c5473 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -11,7 +11,8 @@ import ( type Context struct { Sys Sys Cfg *Config - Argv []string // the user command (the entrypoint's "$@") + Argv []string // the user command (the entrypoint's "$@") + Stdout io.Writer // inherited by children that surface output (pre_run hook) Stderr io.Writer } @@ -45,14 +46,17 @@ type Phase struct { func phases() []Phase { return []Phase{ {Name: "extra-hosts", Run: extraHostsPhase}, - // ssh-agent-bridge lands with the long-lived-children commit. + {Name: "ssh-agent-bridge", Run: sshAgentBridgePhase}, {Name: "claude-staging", Run: claudeStagingPhase}, {Name: "codex-staging", Run: codexStagingPhase}, {Name: "gemini-staging", Run: geminiStagingPhase}, {Name: "copilot-staging", Run: copilotStagingPhase}, {Name: "init-files", Run: initFilesPhase}, - // clipboard and docker land with the long-lived-children commit. + // The clipboard branch precedes the git block, which precedes + // docker setup (GIT-08). + {Name: "clipboard", Run: clipboardPhase}, {Name: "git-config", Run: gitConfigPhase}, + {Name: "docker-setup", Run: dockerSetupPhase}, // The named-volume chown block is inline in the script BEFORE the // populate/mcp/hook tail calls — script order, kept exactly. {Name: "named-volume-chown", Run: namedVolumeChownPhase}, @@ -60,7 +64,8 @@ func phases() []Phase { // mode the tar extract would otherwise clobber moat's .mcp.json. {Name: "populate-workspace-volume", Run: populateWorkspaceVolumePhase}, {Name: "workspace-mcp-json", Run: workspaceMCPJSONPhase}, - // pre-run-hook and exec-dispatch land last. + {Name: "pre-run-hook", Run: preRunHookPhase}, + // exec-dispatch lands last. } } diff --git a/internal/moatinit/ssh.go b/internal/moatinit/ssh.go new file mode 100644 index 00000000..03e82a5d --- /dev/null +++ b/internal/moatinit/ssh.go @@ -0,0 +1,79 @@ +package moatinit + +import ( + "fmt" + "time" +) + +const ( + // sshSocketWaitIters mirrors SSH_SOCKET_WAIT_ITERS: iterations * 0.1s = + // 2 second timeout for the socket to appear. + sshSocketWaitIters = 20 + + sshSocketDir = "/run/moat/ssh" + sshSocketPath = "/run/moat/ssh/agent.sock" +) + +// sshAgentBridgePhase mirrors the SSH agent bridge block (SSH region): when +// MOAT_SSH_TCP_ADDR is set, bridge a Unix socket to the TCP-based SSH agent +// proxy on the host (needed on Docker-for-macOS, where Unix sockets can't +// cross bind mounts). socat stays the bridge — a targeted long-lived child +// that must outlive the entrypoint's exec — Go owns only the surrounding +// decisions: directory/mode/ownership, the wait loop, and the exact +// warnings. +// +// The entire region is non-fatal end-to-end (SSH-12): every failure either +// skips silently or warns, and the entrypoint proceeds to the user command. +func sshAgentBridgePhase(ctx *Context) error { + cfg, sys := ctx.Cfg, ctx.Sys + if cfg.SSHTCPAddr == "" { + return nil // SSH-01: unset and empty are identical no-ops + } + + // Create the socket directory — may need root for /run; best-effort + // (SSH-02), and everything below is nested under its existence (SSH-03). + _ = sys.MkdirAll(sshSocketDir, 0o755) + if !isDir(sys, sshSocketDir) { + return nil + } + // Permissions so moatuser (a different UID) can traverse; best-effort. + _ = sys.Chmod(sshSocketDir, 0o755) + // Chown whenever moatuser exists — note there is deliberately no root + // check here (parity: as non-root the chown just fails silently). + if u, ok := sys.LookupUser("moatuser"); ok { + _ = sys.Chown(sshSocketDir, u.UID, u.GID) + } + + // Start socat bridging a forking Unix listener (socket mode 0660 — + // owner and group only) to the host TCP address. + pid, err := sys.StartDetached(Cmd{ + Argv: []string{"socat", "UNIX-LISTEN:" + sys.RealPath(sshSocketPath) + ",fork,mode=0660", "TCP:" + cfg.SSHTCPAddr}, + Stderr: ctx.Stderr, + }) + if err != nil { + // socat missing or unspawnable — the shell's `&` can't fail this + // way, but its kill -0 check lands on the same warning. + fmt.Fprintln(ctx.Stderr, "Warning: SSH agent bridge (socat) failed to start") + return nil + } + + // Wait for the socket to appear (SSH-07: break as soon as it exists). + for i := 0; i < sshSocketWaitIters; i++ { + if isSocket(sys, sshSocketPath) { + break + } + sys.Sleep(100 * time.Millisecond) + } + + switch { + case !sys.ProcessAlive(pid): + fmt.Fprintln(ctx.Stderr, "Warning: SSH agent bridge (socat) failed to start") + case !isSocket(sys, sshSocketPath): + fmt.Fprintln(ctx.Stderr, "Warning: SSH agent socket was not created after 2s") + default: + if u, ok := sys.LookupUser("moatuser"); ok { + _ = sys.Chown(sshSocketPath, u.UID, u.GID) // best-effort + } + } + return nil +} diff --git a/internal/moatinit/sys.go b/internal/moatinit/sys.go index af07b3d9..f9348448 100644 --- a/internal/moatinit/sys.go +++ b/internal/moatinit/sys.go @@ -30,6 +30,17 @@ type Cmd struct { Env []string // nil = inherit the current process environment Stdout io.Writer // nil = discard Stderr io.Writer // nil = discard + + // LogFile, when set, redirects both stdout and stderr to this file + // (container-absolute; re-rooted like other fs paths), truncating it — + // the Go form of `>/var/log/dockerd.log 2>&1`. Overrides Stdout/Stderr. + LogFile string + + // Timeout, when positive, bounds the run so a hanging probe inside a + // bounded retry loop cannot consume the loop's whole budget (plan + // Appendix B: per-attempt timeout below the loop budget). A timed-out + // command reports a non-zero exit code. + Timeout time.Duration } // Sys abstracts every identity, filesystem, subprocess, and DNS operation the @@ -290,7 +301,14 @@ func (s *OSSys) WalkDir(root string, fn fs.WalkDirFunc) error { func (s *OSSys) LookPath(file string) (string, error) { return exec.LookPath(file) } func (s *OSSys) Run(c Cmd) (int, error) { - cmd := exec.Command(c.Argv[0], c.Argv[1:]...) + var cmd *exec.Cmd + if c.Timeout > 0 { + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + cmd = exec.CommandContext(ctx, c.Argv[0], c.Argv[1:]...) + } else { + cmd = exec.Command(c.Argv[0], c.Argv[1:]...) + } cmd.Dir = c.Dir cmd.Env = c.Env cmd.Stdout = c.Stdout @@ -301,7 +319,7 @@ func (s *OSSys) Run(c Cmd) (int, error) { } var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return exitErr.ExitCode(), nil + return exitCodeOf(err), nil } return -1, err } @@ -320,6 +338,16 @@ func (s *OSSys) StartDetached(c Cmd) (int, error) { cmd.Env = c.Env cmd.Stdout = c.Stdout cmd.Stderr = c.Stderr + if c.LogFile != "" { + f, err := os.OpenFile(s.path(c.LogFile), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return 0, err + } + // The child inherits the fd; our copy closes after Start. + defer f.Close() + cmd.Stdout = f + cmd.Stderr = f + } if err := cmd.Start(); err != nil { return 0, err } @@ -393,12 +421,18 @@ func (s *OSSys) Pipe(src, dst Cmd) (int, int, error) { return srcRC, dstRC, nil } +// exitCodeOf translates a Wait error into a shell-style exit code, +// including the 128+signal convention for signal-terminated children (the +// shell's $? would report 128+n; exec.ExitError.ExitCode() reports -1). func exitCodeOf(err error) int { if err == nil { return 0 } var exitErr *exec.ExitError if errors.As(err, &exitErr) { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return 128 + int(ws.Signal()) + } return exitErr.ExitCode() } return -1 diff --git a/internal/moatinit/testsys_test.go b/internal/moatinit/testsys_test.go index 40530cc3..82f3c687 100644 --- a/internal/moatinit/testsys_test.go +++ b/internal/moatinit/testsys_test.go @@ -27,6 +27,9 @@ type testSys struct { euid int users map[string]User + groupsByName map[string]string // name -> gid + groupsByGID map[string]string // gid -> name + chowns []chownCall chownErr error // returned (after recording) to prove best-effort paths @@ -40,6 +43,10 @@ type testSys struct { pipes [][2]Cmd pipeHook func(src, dst Cmd) (int, int, error) // nil = run the real pipe + detached []Cmd + detachHook func(c Cmd) (int, error) // nil = fake pid 4242 + alive map[int]bool // ProcessAlive results (default true) + missingBinaries map[string]bool sleeps int @@ -60,10 +67,13 @@ func newTestSys(t *testing.T, euid int, withMoatuser bool) *testSys { t: t, euid: euid, users: map[string]User{}, + groupsByName: map[string]string{}, + groupsByGID: map[string]string{}, resolve4: map[string]string{}, resolveAny: map[string]string{}, missingBinaries: map[string]bool{}, env: map[string]string{}, + alive: map[int]bool{}, } if withMoatuser { ts.users["moatuser"] = User{UID: 5000, GID: 5000} @@ -127,6 +137,31 @@ func (ts *testSys) Pipe(src, dst Cmd) (int, int, error) { return ts.OSSys.Pipe(src, dst) } +func (ts *testSys) StartDetached(c Cmd) (int, error) { + ts.detached = append(ts.detached, c) + if ts.detachHook != nil { + return ts.detachHook(c) + } + return 4242, nil +} + +func (ts *testSys) ProcessAlive(pid int) bool { + if v, ok := ts.alive[pid]; ok { + return v + } + return true +} + +func (ts *testSys) LookupGroupByName(name string) (string, bool) { + gid, ok := ts.groupsByName[name] + return gid, ok +} + +func (ts *testSys) LookupGroupByGID(gid string) (string, bool) { + name, ok := ts.groupsByGID[gid] + return name, ok +} + func (ts *testSys) Getenv(key string) string { return ts.env[key] } func (ts *testSys) Setenv(key, value string) { ts.env[key] = value } func (ts *testSys) Unsetenv(key string) { delete(ts.env, key) } From 711e086cc46c9de0f5c6a1ce0f5fab304ea2bb13 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 05:58:46 +0000 Subject: [PATCH 07/17] =?UTF-8?q?feat(moatinit):=20exec=20dispatch=20?= =?UTF-8?q?=E2=80=94=20branch=20selection=20+=20gosu=20privilege=20drop=20?= =?UTF-8?q?+=20env=20scrub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 6 of the moat-init rewrite: the terminal handoff. Go computes the dispatch — non-root execs argv directly; root with moatuser execs 'gosu moatuser "$@"'; root without moatuser fails closed with the exact multi-line remediation — and syscall.Exec replaces the process image (PID preserved, detached children reparent exactly as under the shell's exec; fork+wait is prohibited). gosu owns setgroups/setgid/setuid and re-reads /etc/group at exec time, so groups added mid-run by the docker phases are picked up with no native group-resolution code. Both handoff paths build the exec environment by explicitly removing MOAT_INIT_FILES from the inherited environment (defense in depth over the init-files phase's unset); nothing else is scrubbed (parity). A failed exec maps to shell codes (127 not-found / 126 otherwise). Pipeline tests now drive Run() end-to-end to the recorded handoff, pin the full phase order (X-ORDER-GLOBAL), the pre_run literal exit-code passthrough, and the hook/exec branch-selection consistency (EXEC-14). --- internal/moatinit/execdispatch.go | 93 ++++++++++++ internal/moatinit/phase.go | 5 +- internal/moatinit/phase_test.go | 232 +++++++++++++++++++++++++++--- internal/moatinit/testsys_test.go | 28 ++++ 4 files changed, 341 insertions(+), 17 deletions(-) create mode 100644 internal/moatinit/execdispatch.go diff --git a/internal/moatinit/execdispatch.go b/internal/moatinit/execdispatch.go new file mode 100644 index 00000000..670ab6d7 --- /dev/null +++ b/internal/moatinit/execdispatch.go @@ -0,0 +1,93 @@ +package moatinit + +import ( + "errors" + "fmt" + "io/fs" + "os/exec" + "strings" +) + +// execDispatchPhase is the terminal phase: hand the process over to the +// user's command (EXEC-11..13, X-PRIVILEGE-DROP). +// +// Go computes the branch; gosu performs the actual identity transition: +// +// - already non-root (e.g. docker run --user): exec "$@" directly +// - root with moatuser: exec gosu moatuser "$@" — gosu resolves the full +// supplementary group set fresh from /etc/group at exec time, so groups +// added earlier in this same run (dind/host-socket usermod) are picked +// up; there is deliberately NO native setgroups/setgid/setuid here +// - root without moatuser: fatal — running as root defeats the container +// security model +// +// The handoff is a true exec (image replacement, PID preserved): the +// detached children (socat/Xvfb/dockerd) reparent exactly as under the +// shell's `exec`, and the exit code of the user command is the container's. +// Fork+wait would change parenting and signal behavior — prohibited. +// +// Both paths build the exec environment by explicitly removing +// MOAT_INIT_FILES from the inherited environment (INIT-10 defense in depth +// — the init-files phase already unset it, but the secret payload must be +// unable to reach the child even if that phase is ever reordered). +func execDispatchPhase(ctx *Context) error { + sys := ctx.Sys + env := scrubExecEnv(sys.Environ()) + + switch { + case sys.Geteuid() != 0: + // Already non-root (e.g. --user was passed to docker run). + return execFailure(ctx, ctx.Argv, sys.Exec(ctx.Argv, env)) + case moatuserExists(sys): + // Running as root, moatuser exists - drop privileges. + argv := append([]string{"gosu", "moatuser"}, ctx.Argv...) + return execFailure(ctx, argv, sys.Exec(argv, env)) + default: + // Running as root, no moatuser - fail with clear error. + fmt.Fprintln(ctx.Stderr, "Error: Container started as root but moatuser does not exist.") + fmt.Fprintln(ctx.Stderr, "This is a security issue - running as root defeats container isolation.") + fmt.Fprintln(ctx.Stderr, "") + fmt.Fprintln(ctx.Stderr, "If you're using a custom image, ensure it creates a 'moatuser' account:") + fmt.Fprintln(ctx.Stderr, " RUN useradd -m -u 5000 -s /bin/bash moatuser") + fmt.Fprintln(ctx.Stderr, "") + fmt.Fprintln(ctx.Stderr, "Or run the container with a non-root user:") + fmt.Fprintln(ctx.Stderr, " docker run --user 1000:1000 ...") + return exitError{code: 1} + } +} + +// scrubExecEnv removes MOAT_INIT_FILES from an environment snapshot. The +// exec'd command must see every other variable the shell would have passed +// (parity: only MOAT_INIT_FILES is scrubbed — a broader MOAT_* scrub is a +// separable hardening follow-up, deliberately not part of the parity port). +func scrubExecEnv(environ []string) []string { + out := make([]string, 0, len(environ)) + for _, kv := range environ { + if strings.HasPrefix(kv, "MOAT_INIT_FILES=") { + continue + } + out = append(out, kv) + } + return out +} + +// errHandoffComplete marks a successful process handoff. Production +// syscall.Exec never returns on success, so this only occurs when a test +// fake records the exec and returns nil; Run maps it to exit code 0 instead +// of falling through to the fail-closed FATAL. +var errHandoffComplete = errors.New("moat-init: handoff complete") + +// execFailure reports a failed exec. Success never returns, so reaching +// this with err != nil means the command could not be started at all; the +// codes mirror the shell's: 127 for not-found, 126 otherwise. +func execFailure(ctx *Context, argv []string, err error) error { + if err == nil { + return errHandoffComplete + } + fmt.Fprintf(ctx.Stderr, "moat-init: exec %s: %v\n", argv[0], err) + code := 126 + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist) { + code = 127 + } + return exitError{code: code} +} diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index fe4c5473..caf15c84 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -65,7 +65,7 @@ func phases() []Phase { {Name: "populate-workspace-volume", Run: populateWorkspaceVolumePhase}, {Name: "workspace-mcp-json", Run: workspaceMCPJSONPhase}, {Name: "pre-run-hook", Run: preRunHookPhase}, - // exec-dispatch lands last. + {Name: "exec-dispatch", Run: execDispatchPhase}, } } @@ -80,6 +80,9 @@ func phases() []Phase { func Run(ctx *Context) int { for _, p := range phases() { if err := p.Run(ctx); err != nil { + if errors.Is(err, errHandoffComplete) { + return 0 // test-only: a fake Exec recorded the handoff + } var exit exitError if errors.As(err, &exit) { return exit.code diff --git a/internal/moatinit/phase_test.go b/internal/moatinit/phase_test.go index 9b1a2d3b..0f2a82df 100644 --- a/internal/moatinit/phase_test.go +++ b/internal/moatinit/phase_test.go @@ -1,29 +1,229 @@ package moatinit import ( + "os" "strings" "testing" ) -// TestRunFailsClosedWithoutExecPhase pins the incomplete-build contract: -// until the exec-dispatch phase lands, Run must refuse to fall through to -// the user command (which would silently run it as root, skipping the -// privilege-drop contract) — it exits 1 with a loud FATAL instead. -func TestRunFailsClosedWithoutExecPhase(t *testing.T) { - var stderr strings.Builder - // An empty Config (not LoadConfig): the test process may itself run - // inside a moat sandbox with live MOAT_* variables set. - ctx := &Context{ - Sys: NewSys(), - Cfg: &Config{}, - Argv: []string{"true"}, - Stderr: &stderr, +// TestRunDrivesToExecDispatch runs the full pipeline end-to-end (empty +// config: every setup phase no-ops) and asserts it terminates in the exec +// handoff, not the fail-closed FATAL. A fake Exec records the handoff — +// the real one would replace the test process. +func TestRunDrivesToExecDispatch(t *testing.T) { + ts := newTestSys(t, 1000, false) + ctx, stderr := newTestContext(ts, Config{Home: "/tmp/h"}) + ctx.Argv = []string{"echo", "hello"} + + code := Run(ctx) + if code != 0 { + t.Fatalf("Run() = %d, want 0 (handoff), stderr: %q", code, stderr.String()) + } + if len(ts.execs) != 1 { + t.Fatalf("execs = %d, want 1", len(ts.execs)) } + if got := strings.Join(ts.execs[0].argv, " "); got != "echo hello" { + t.Errorf("exec argv = %q", got) + } + if strings.Contains(stderr.String(), "FATAL") { + t.Errorf("pipeline hit the fail-closed FATAL: %q", stderr.String()) + } +} + +// TestRunPhaseOrder pins the global ordering invariant (X-ORDER-GLOBAL): +// the fixed top-to-bottom sequence with its hard dependencies — extra-hosts +// first, populate before workspace-mcp-json before pre-run-hook, exec last. +func TestRunPhaseOrder(t *testing.T) { + names := make([]string, 0, len(phases())) + for _, p := range phases() { + names = append(names, p.Name) + } + want := []string{ + "extra-hosts", + "ssh-agent-bridge", + "claude-staging", + "codex-staging", + "gemini-staging", + "copilot-staging", + "init-files", + "clipboard", + "git-config", + "docker-setup", + "named-volume-chown", + "populate-workspace-volume", + "workspace-mcp-json", + "pre-run-hook", + "exec-dispatch", + } + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Errorf("phase order:\n%v\nwant:\n%v", names, want) + } +} + +// TestRunFatalPhaseStopsPipeline asserts a fatal phase error carries its +// exit code out of Run and later phases (including the exec) never run. +func TestRunFatalPhaseStopsPipeline(t *testing.T) { + ts := newTestSys(t, 0, true) + // Docker mutex violation: fatal in an early-middle phase. + ctx, stderr := newTestContext(ts, Config{DockerDIND: "1", DockerGID: "9", Home: "/root"}) code := Run(ctx) if code != 1 { - t.Errorf("Run() = %d, want 1 (fail closed)", code) + t.Fatalf("Run() = %d, want 1", code) + } + if len(ts.execs) != 0 { + t.Error("exec ran after a fatal phase") + } + if !strings.Contains(stderr.String(), "mutually exclusive") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +// TestRunPreRunExitCodePassthrough asserts the pre_run hook's literal exit +// code becomes the process exit code (EXEC-06 through the whole pipeline). +func TestRunPreRunExitCodePassthrough(t *testing.T) { + ts := newTestSys(t, 0, true) + ts.runHook = func(c Cmd) (int, error) { + if c.Argv[0] == "gosu" { + return 42, nil + } + return 0, nil + } + ctx, _ := newTestContext(ts, Config{PreRun: "exit 42", Home: "/root"}) + if code := Run(ctx); code != 42 { + t.Errorf("Run() = %d, want the hook's literal 42", code) + } + if len(ts.execs) != 0 { + t.Error("user command exec'd after a failed pre_run hook") + } +} + +func TestExecDispatchNonRootDirect(t *testing.T) { + // EXEC-11: non-root execs argv directly — no gosu anywhere. + ts := newTestSys(t, 1000, true) + ctx, _ := newTestContext(ts, Config{Home: "/tmp/h"}) + ctx.Argv = []string{"id", "-u"} + if err := execDispatchPhase(ctx); err != errHandoffComplete { + t.Fatalf("err = %v, want handoff", err) + } + if got := strings.Join(ts.execs[0].argv, " "); got != "id -u" { + t.Errorf("argv = %q", got) + } +} + +func TestExecDispatchRootGosuDrop(t *testing.T) { + // EXEC-12: root+moatuser prepends the gosu drop. + ts := newTestSys(t, 0, true) + ctx, _ := newTestContext(ts, Config{Home: "/root"}) + ctx.Argv = []string{"claude", "--continue"} + if err := execDispatchPhase(ctx); err != errHandoffComplete { + t.Fatalf("err = %v, want handoff", err) + } + if got := strings.Join(ts.execs[0].argv, " "); got != "gosu moatuser claude --continue" { + t.Errorf("argv = %q", got) + } +} + +func TestExecDispatchRootNoMoatuserFatal(t *testing.T) { + // EXEC-13: root without moatuser — exact multi-line fatal, no exec. + ts := newTestSys(t, 0, false) + ctx, stderr := newTestContext(ts, Config{Home: "/root"}) + err := execDispatchPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 1 { + t.Fatalf("err = %v, want exitError{1}", err) + } + if len(ts.execs) != 0 { + t.Error("user command ran despite the security fatal") + } + want := "Error: Container started as root but moatuser does not exist.\n" + + "This is a security issue - running as root defeats container isolation.\n" + + "\n" + + "If you're using a custom image, ensure it creates a 'moatuser' account:\n" + + " RUN useradd -m -u 5000 -s /bin/bash moatuser\n" + + "\n" + + "Or run the container with a non-root user:\n" + + " docker run --user 1000:1000 ...\n" + if stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestExecDispatchEnvScrub(t *testing.T) { + // INIT-10 / B-P0: MOAT_INIT_FILES is removed from the exec env on BOTH + // handoff paths; everything else passes through (parity: only that one + // variable is scrubbed). + for _, euid := range []int{0, 1000} { + ts := newTestSys(t, euid, true) + ts.env["MOAT_INIT_FILES"] = "secret\tYWJj" + ts.env["HOME"] = "/home/moatuser" + ts.env["MOAT_CLAUDE_INIT"] = "/mnt/claude" + ctx, _ := newTestContext(ts, Config{Home: "/root"}) + if err := execDispatchPhase(ctx); err != errHandoffComplete { + t.Fatalf("euid=%d: %v", euid, err) + } + env := strings.Join(ts.execs[0].env, "\n") + if strings.Contains(env, "MOAT_INIT_FILES") { + t.Errorf("euid=%d: MOAT_INIT_FILES leaked into the exec env", euid) + } + for _, want := range []string{"HOME=/home/moatuser", "MOAT_CLAUDE_INIT=/mnt/claude"} { + if !strings.Contains(env, want) { + t.Errorf("euid=%d: exec env missing %s", euid, want) + } + } + } +} + +func TestExecDispatchExecFailureCodes(t *testing.T) { + // Shell parity for a failed exec: 127 when the command is not found. + ts := newTestSys(t, 1000, false) + ts.execErr = os.ErrNotExist + ctx, stderr := newTestContext(ts, Config{Home: "/tmp/h"}) + ctx.Argv = []string{"no-such-cmd"} + err := execDispatchPhase(ctx) + exit, ok := err.(exitError) + if !ok || exit.code != 127 { + t.Fatalf("err = %v, want exitError{127}", err) + } + if !strings.Contains(stderr.String(), "no-such-cmd") { + t.Errorf("stderr = %q", stderr.String()) + } + + // Companion: other start failures report 126. + ts2 := newTestSys(t, 1000, false) + ts2.execErr = os.ErrPermission + ctx2, _ := newTestContext(ts2, Config{Home: "/tmp/h"}) + if exit, ok := execDispatchPhase(ctx2).(exitError); !ok || exit.code != 126 { + t.Fatal("permission failure should map to 126") + } +} + +// TestExecDispatchConsistentDetection pins EXEC-14: the hook dispatch and +// the exec dispatch use the same (euid, moatuser) predicate — for a fixed +// tuple both choose the same branch class. +func TestExecDispatchConsistentDetection(t *testing.T) { + cases := []struct { + euid int + moatuser bool + gosu bool // both hook and exec should use gosu + }{ + {0, true, true}, + {1000, true, false}, + {1000, false, false}, } - if !strings.Contains(stderr.String(), "FATAL: moat-init reached the end of its phase list") { - t.Errorf("missing fail-closed FATAL message, got: %q", stderr.String()) + for _, tc := range cases { + ts := newTestSys(t, tc.euid, tc.moatuser) + ctx, _ := newTestContext(ts, Config{PreRun: "true", Home: "/h"}) + if err := preRunHookPhase(ctx); err != nil { + t.Fatal(err) + } + if err := execDispatchPhase(ctx); err != errHandoffComplete { + t.Fatal(err) + } + hookGosu := len(ts.runs) > 0 && ts.runs[0].Argv[0] == "gosu" + execGosu := ts.execs[0].argv[0] == "gosu" + if hookGosu != tc.gosu || execGosu != tc.gosu { + t.Errorf("euid=%d moatuser=%v: hookGosu=%v execGosu=%v, want %v", + tc.euid, tc.moatuser, hookGosu, execGosu, tc.gosu) + } } } diff --git a/internal/moatinit/testsys_test.go b/internal/moatinit/testsys_test.go index 82f3c687..0c43193e 100644 --- a/internal/moatinit/testsys_test.go +++ b/internal/moatinit/testsys_test.go @@ -4,10 +4,17 @@ import ( "bytes" "os" "path/filepath" + "sort" "testing" "time" ) +// execCall records one process-handoff request. +type execCall struct { + argv []string + env []string +} + // chownCall records one (l)chown request so tests can assert ownership // behavior without root privileges (the real syscall would fail under go // test; recording matches the plan's "recording chown" seam). @@ -47,6 +54,9 @@ type testSys struct { detachHook func(c Cmd) (int, error) // nil = fake pid 4242 alive map[int]bool // ProcessAlive results (default true) + execs []execCall + execErr error // returned after recording; nil = "handoff succeeded" + missingBinaries map[string]bool sleeps int @@ -166,6 +176,24 @@ func (ts *testSys) Getenv(key string) string { return ts.env[key] } func (ts *testSys) Setenv(key, value string) { ts.env[key] = value } func (ts *testSys) Unsetenv(key string) { delete(ts.env, key) } +func (ts *testSys) Environ() []string { + keys := make([]string, 0, len(ts.env)) + for k := range ts.env { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k+"="+ts.env[k]) + } + return out +} + +func (ts *testSys) Exec(argv []string, env []string) error { + ts.execs = append(ts.execs, execCall{argv: argv, env: env}) + return ts.execErr +} + // chowned reports whether a chown for path was recorded. func (ts *testSys) chowned(path string) bool { for _, c := range ts.chowns { From 15304d6b7ae6df91cbcb8df20791b6e59db8d390 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:01:37 +0000 Subject: [PATCH 08/17] feat(moatinit): --plan dry-run + golden fatal-error contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 7 of the moat-init rewrite. 'moat-init --plan' prints the ordered actions the entrypoint would take for the current environment — one line per decision, side-effect-free (read-only stats and lookups only). It is both a permanent debugging affordance the shell could never offer and the release pipeline's positive functional gate: the privilege-drop and MOAT_INIT_FILES-scrub lines must appear, so a regenerated-but-defective binary fails the gate regardless of its checksum. Golden files pin the --plan output (full-featured and minimal environments) and the complete fatal stderr contract — every scripted error block, byte for byte, with its exit code (including the pre_run hook's literal 42). --- cmd/moat-init/main.go | 16 ++ internal/moatinit/plan.go | 154 ++++++++++++++++ internal/moatinit/plan_test.go | 172 ++++++++++++++++++ .../moatinit/testdata/fatal_errors.golden | 31 ++++ internal/moatinit/testdata/plan_full.golden | 20 ++ .../moatinit/testdata/plan_minimal.golden | 16 ++ 6 files changed, 409 insertions(+) create mode 100644 internal/moatinit/plan.go create mode 100644 internal/moatinit/plan_test.go create mode 100644 internal/moatinit/testdata/fatal_errors.golden create mode 100644 internal/moatinit/testdata/plan_full.golden create mode 100644 internal/moatinit/testdata/plan_minimal.golden diff --git a/cmd/moat-init/main.go b/cmd/moat-init/main.go index 5babe7fa..99d881c7 100644 --- a/cmd/moat-init/main.go +++ b/cmd/moat-init/main.go @@ -10,6 +10,7 @@ package main import ( + "fmt" "os" "github.com/majorcontext/moat/internal/moatinit" @@ -24,5 +25,20 @@ func main() { Stdout: os.Stdout, Stderr: os.Stderr, } + + // --plan: print the ordered actions the entrypoint would take for this + // environment, without performing any of them. A permanent, documented + // debugging affordance — and the release pipeline's functional gate. + if len(ctx.Argv) > 0 && ctx.Argv[0] == "--plan" { + ctx.Argv = ctx.Argv[1:] + if len(ctx.Argv) == 0 { + ctx.Argv = []string{""} + } + for _, line := range moatinit.Plan(ctx) { + fmt.Println(line) + } + return + } + os.Exit(moatinit.Run(ctx)) } diff --git a/internal/moatinit/plan.go b/internal/moatinit/plan.go new file mode 100644 index 00000000..64bf2254 --- /dev/null +++ b/internal/moatinit/plan.go @@ -0,0 +1,154 @@ +package moatinit + +import ( + "fmt" + "strings" +) + +// Plan returns the ordered actions the entrypoint would take for the +// current environment and identity, without performing any of them — a +// side-effect-free dry-run (only read-only stats and lookups). It is the +// debugging affordance the shell script could never offer, and the release +// pipeline's positive functional gate: a regenerated-but-defective binary +// that lost the privilege-drop or scrub phases fails the gate regardless of +// its checksum. +// +// One line per decision, ": ". Wording here is NOT part of +// the parity contract (the script has no equivalent); the fatal stderr +// wordings pinned by the golden tests are. +func Plan(ctx *Context) []string { + cfg, sys := ctx.Cfg, ctx.Sys + euid := sys.Geteuid() + moat := moatuserExists(sys) + home := targetHome(euid, moat, cfg.Home) + + var out []string + add := func(format string, args ...any) { out = append(out, fmt.Sprintf(format, args...)) } + + if cfg.ExtraHosts == "" { + add("extra-hosts: skip (MOAT_EXTRA_HOSTS unset)") + } else { + for _, tok := range splitExtraHosts(cfg.ExtraHosts) { + e := parseHostEntry(tok) + if e.skip() { + add("extra-hosts: skip malformed entry %q", tok) + continue + } + if hostname, resolve := e.resolveTarget(); resolve { + add("extra-hosts: resolve %q (IPv4 preferred, ~5s budget) and append to /etc/hosts as %q — fatal if unresolvable", hostname, e.name) + } else { + add("extra-hosts: append %q -> %q to /etc/hosts — fatal if unwritable", e.target, e.name) + } + } + } + + if cfg.SSHTCPAddr != "" { + add("ssh-agent-bridge: start socat %s (0660) <-> TCP:%s as a long-lived child", sshSocketPath, cfg.SSHTCPAddr) + } else { + add("ssh-agent-bridge: skip (MOAT_SSH_TCP_ADDR unset)") + } + + agents := []struct{ name, staging, dir string }{ + {"claude-staging", cfg.ClaudeInit, ".claude"}, + {"codex-staging", cfg.CodexInit, ".codex"}, + {"gemini-staging", cfg.GeminiInit, ".gemini"}, + {"copilot-staging", cfg.CopilotInit, ".copilot"}, + } + for _, a := range agents { + switch { + case a.staging == "": + add("%s: skip (staging var unset)", a.name) + case !isDir(sys, a.staging): + add("%s: skip (%s is not a directory)", a.name, a.staging) + default: + add("%s: copy allowlisted files from %s into %s/%s (credential files forced 0600)", a.name, a.staging, home, a.dir) + } + } + + if cfg.InitFiles == "" { + add("init-files: skip (MOAT_INIT_FILES unset)") + } else { + n := 0 + for _, rec := range parseInitFiles(cfg.InitFiles) { + if rec.path != "" { + n++ + } + } + add("init-files: write %d file(s) at 0600 (parents 0755), then scrub MOAT_INIT_FILES from the environment", n) + } + + if cfg.Clipboard == "1" { + add("clipboard: start Xvfb :99 as a long-lived child and export DISPLAY=:99") + } else { + add("clipboard: skip (MOAT_CLIPBOARD != 1)") + } + + if _, err := sys.LookPath("git"); err != nil { + add("git-config: skip (no git binary on PATH)") + } else { + for _, argv := range gitConfigCommands(cfg) { + add("git-config: %s (best-effort)", strings.Join(argv, " ")) + } + } + + switch { + case dockerMutexViolated(cfg.DockerDIND, cfg.DockerGID): + add("docker: FATAL — MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive") + case dindActive(cfg.DockerDIND, euid): + add("docker: start dockerd (dind, vfs) as a long-lived child, wait up to %ds, add moatuser to the docker group", dindTimeoutSeconds) + case hostGIDActive(cfg.DockerGID, euid, isSocket(sys, dockerSocketPath)): + add("docker: detect %s group inside the container and add moatuser to it", dockerSocketPath) + default: + add("docker: skip") + } + + if paths := volumeChownPaths(cfg.VolumeChown); len(paths) > 0 && euid == 0 && moat { + add("named-volume-chown: chown %s to moatuser (non-recursive, best-effort)", strings.Join(paths, " ")) + } else { + add("named-volume-chown: skip") + } + + if workspaceVolumeEnabled(cfg.WorkspaceVolume) { + n := 0 + if cfg.WorkspaceExcludes != "" { + n = len(strings.Split(cfg.WorkspaceExcludes, "\n")) + } + add("populate-workspace-volume: tar-copy %s -> /workspace (%d exclude pattern(s), both pipe exit codes checked), then chown -R moatuser — requires root, fatal otherwise", stagingDir(cfg.WorkspaceStaging), n) + } else { + add("populate-workspace-volume: skip (MOAT_WORKSPACE_VOLUME != 1)") + } + + switch { + case cfg.CodexInit != "" && isFile(sys, cfg.CodexInit+"/mcp.json"): + add("workspace-mcp-json: copy %s/mcp.json -> /workspace/.mcp.json", cfg.CodexInit) + case cfg.GeminiInit != "" && isFile(sys, cfg.GeminiInit+"/mcp.json"): + add("workspace-mcp-json: copy %s/mcp.json -> /workspace/.mcp.json", cfg.GeminiInit) + default: + add("workspace-mcp-json: skip (no staged mcp.json)") + } + + if cfg.PreRun == "" { + add("pre-run-hook: skip (MOAT_PRE_RUN unset)") + } else { + switch { + case euid != 0: + add("pre-run-hook: run %q via sh -c in /workspace (already non-root); a non-zero exit aborts with that code", cfg.PreRun) + case moat: + add("pre-run-hook: run %q via gosu moatuser sh -c in /workspace; a non-zero exit aborts with that code", cfg.PreRun) + default: + add("pre-run-hook: skip (root without moatuser)") + } + } + + cmd := strings.Join(ctx.Argv, " ") + switch { + case euid != 0: + add("privilege drop: exec %q directly (already non-root; MOAT_INIT_FILES scrubbed from the exec environment)", cmd) + case moat: + add("privilege drop: exec gosu moatuser %q (MOAT_INIT_FILES scrubbed from the exec environment)", cmd) + default: + add("privilege drop: FATAL — running as root but the moatuser account does not exist") + } + + return out +} diff --git a/internal/moatinit/plan_test.go b/internal/moatinit/plan_test.go new file mode 100644 index 00000000..6857acea --- /dev/null +++ b/internal/moatinit/plan_test.go @@ -0,0 +1,172 @@ +package moatinit + +import ( + "flag" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +func checkGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name) + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden %s (run: go test ./internal/moatinit -run %s -update): %v", path, t.Name(), err) + } + if got != string(want) { + t.Errorf("golden mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +// TestPlanGoldenFull pins the --plan output for a fully-loaded root +// environment: every feature active, every decision visible. +func TestPlanGoldenFull(t *testing.T) { + ts := newTestSys(t, 0, true) + for _, dir := range []string{"mnt/claude-init", "mnt/codex-init"} { + if err := os.MkdirAll(filepath.Join(ts.Root, dir), 0o755); err != nil { + t.Fatal(err) + } + } + stageFile(t, ts, "mnt/codex-init", "mcp.json", 0o644, "{}") + + ctx, _ := newTestContext(ts, Config{ + ExtraHosts: "moat-proxy:192.0.2.5 moat-host:@host.docker.internal bad:", + SSHTCPAddr: "192.168.65.2:5522", + ClaudeInit: "/mnt/claude-init", + CodexInit: "/mnt/codex-init", + GeminiInit: "/mnt/missing", + InitFiles: "/home/moatuser/.config/g/cfg\tYWJj\n", + Clipboard: "1", + GitUserName: "Ada", + GitSSHGitHub: "1", + DockerDIND: "1", + WorkspaceVolume: "1", + WorkspaceExcludes: "./node_modules\n./dist", + VolumeChown: "/workspace/.cache", + PreRun: "npm install", + Home: "/root", + }) + ctx.Argv = []string{"claude", "--continue"} + checkGolden(t, "plan_full.golden", strings.Join(Plan(ctx), "\n")+"\n") +} + +// TestPlanGoldenMinimal pins the all-skip plan for an empty non-root env. +func TestPlanGoldenMinimal(t *testing.T) { + ts := newTestSys(t, 1000, false) + ctx, _ := newTestContext(ts, Config{Home: "/tmp/h"}) + ctx.Argv = []string{"bash"} + checkGolden(t, "plan_minimal.golden", strings.Join(Plan(ctx), "\n")+"\n") +} + +// TestPlanIsSideEffectFree asserts the dry-run performs no writes, spawns, +// or environment mutations. +func TestPlanIsSideEffectFree(t *testing.T) { + ts := newTestSys(t, 0, true) + ts.env["MOAT_INIT_FILES"] = "sentinel" + ctx, _ := newTestContext(ts, Config{ + ExtraHosts: "moat-proxy:192.0.2.5", + SSHTCPAddr: "1.2.3.4:5", + InitFiles: "/a/b\tYWJj", + Clipboard: "1", + DockerDIND: "1", + PreRun: "npm install", + Home: "/root", + }) + _ = Plan(ctx) + if len(ts.detached)+len(ts.runs)+len(ts.pipes)+len(ts.chowns)+len(ts.execs) != 0 { + t.Error("Plan performed side effects") + } + if ts.env["MOAT_INIT_FILES"] != "sentinel" { + t.Error("Plan mutated the environment") + } + if exists(ts, "/a/b") || exists(ts, "/run/moat/ssh") { + t.Error("Plan touched the filesystem") + } +} + +// TestPlanFunctionalGateLines pins the two lines the release gate greps +// for: the privilege-drop decision and the MOAT_INIT_FILES scrub. A +// regenerated-but-defective binary that lost those phases fails the gate +// regardless of its checksum (plan §5). +func TestPlanFunctionalGateLines(t *testing.T) { + for _, tc := range []struct { + euid int + moatuser bool + want string + }{ + {0, true, "privilege drop: exec gosu moatuser"}, + {1000, false, "privilege drop: exec"}, + {0, false, "privilege drop: FATAL"}, + } { + ts := newTestSys(t, tc.euid, tc.moatuser) + ctx, _ := newTestContext(ts, Config{InitFiles: "/a/b\tYWJj", Home: "/h"}) + out := strings.Join(Plan(ctx), "\n") + if !strings.Contains(out, tc.want) { + t.Errorf("euid=%d moatuser=%v: plan missing %q:\n%s", tc.euid, tc.moatuser, tc.want, out) + } + if !strings.Contains(out, "scrub MOAT_INIT_FILES") { + t.Errorf("plan missing the scrub line:\n%s", out) + } + } +} + +// TestFatalErrorContractGolden collects every scripted fatal stderr block +// across the phases into one golden file — the exact-wording contract the +// port preserves from moat-init.sh. +func TestFatalErrorContractGolden(t *testing.T) { + var b strings.Builder + scenario := func(name string, cfg Config, prep func(*testSys)) { + ts := newTestSys(t, 1000, false) + if prep != nil { + prep(ts) + } + ctx, stderr := newTestContext(ts, cfg) + code := Run(ctx) + b.WriteString("== " + name + " (exit " + strconv.Itoa(code) + ") ==\n") + b.WriteString(stderr.String()) + } + + scenario("unresolvable extra host", Config{ExtraHosts: "moat-proxy:@nope.invalid", Home: "/h"}, func(ts *testSys) { + writeHosts(t, ts, "") + }) + scenario("unwritable /etc/hosts", Config{ExtraHosts: "moat-proxy:192.0.2.5", Home: "/h"}, nil) + scenario("docker mutex", Config{DockerDIND: "1", DockerGID: "999", Home: "/h"}, nil) + scenario("populate as non-root", Config{WorkspaceVolume: "1", Home: "/h"}, func(ts *testSys) { + writeHosts(t, ts, "") + }) + scenario("pre_run hook failure", Config{PreRun: "echo doing-setup; exit 42", Home: "/h"}, func(ts *testSys) { + ts.runHook = func(Cmd) (int, error) { return 42, nil } + }) + + // Root-without-moatuser needs euid 0. + tsRoot := newTestSys(t, 0, false) + ctxRoot, stderrRoot := newTestContext(tsRoot, Config{Home: "/root"}) + code := Run(ctxRoot) + b.WriteString("== root without moatuser (exit " + strconv.Itoa(code) + ") ==\n") + b.WriteString(stderrRoot.String()) + + // Populate rc failure needs root + moatuser + a staging tree. + tsPop := newTestSys(t, 0, true) + setupStagingTree(t, tsPop) + tsPop.pipeHook = func(src, dst Cmd) (int, int, error) { return 2, 0, nil } + ctxPop, stderrPop := newTestContext(tsPop, Config{WorkspaceVolume: "1", Home: "/root"}) + code = Run(ctxPop) + b.WriteString("== populate pipe failure (exit " + strconv.Itoa(code) + ") ==\n") + b.WriteString(stderrPop.String()) + + checkGolden(t, "fatal_errors.golden", b.String()) +} diff --git a/internal/moatinit/testdata/fatal_errors.golden b/internal/moatinit/testdata/fatal_errors.golden new file mode 100644 index 00000000..2b476b4e --- /dev/null +++ b/internal/moatinit/testdata/fatal_errors.golden @@ -0,0 +1,31 @@ +== unresolvable extra host (exit 1) == +Error: moat-init.sh could not resolve 'nope.invalid' for /etc/hosts entry 'moat-proxy'. +The container's DNS should answer this name. On Docker Desktop, verify that +'getent hosts nope.invalid' works inside this container. +== unwritable /etc/hosts (exit 1) == +Error: moat-init.sh cannot write moat-proxy to /etc/hosts (required for moat proxy resolution). +The container user (UID 1000) lacks permission to modify /etc/hosts. +Rebuild the base image so moat-init.sh runs as root, or grant CAP_DAC_OVERRIDE. +== docker mutex (exit 1) == +Error: MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive +Use MOAT_DOCKER_GID when mounting host's docker socket +Use MOAT_DOCKER_DIND when running Docker-in-Docker +== populate as non-root (exit 1) == +moat: populate_workspace_volume must run as root +== pre_run hook failure (exit 42) == + +moat: pre_run hook failed (exit code 42) +moat: command: echo doing-setup; exit 42 +moat: the pre_run hook runs as moatuser in /workspace before your command. +moat: fix the command above, or remove hooks.pre_run from moat.yaml. +== root without moatuser (exit 1) == +Error: Container started as root but moatuser does not exist. +This is a security issue - running as root defeats container isolation. + +If you're using a custom image, ensure it creates a 'moatuser' account: + RUN useradd -m -u 5000 -s /bin/bash moatuser + +Or run the container with a non-root user: + docker run --user 1000:1000 ... +== populate pipe failure (exit 1) == +moat: failed to populate workspace volume (src=2 dst=0) diff --git a/internal/moatinit/testdata/plan_full.golden b/internal/moatinit/testdata/plan_full.golden new file mode 100644 index 00000000..a080ecb0 --- /dev/null +++ b/internal/moatinit/testdata/plan_full.golden @@ -0,0 +1,20 @@ +extra-hosts: append "192.0.2.5" -> "moat-proxy" to /etc/hosts — fatal if unwritable +extra-hosts: resolve "host.docker.internal" (IPv4 preferred, ~5s budget) and append to /etc/hosts as "moat-host" — fatal if unresolvable +extra-hosts: skip malformed entry "bad:" +ssh-agent-bridge: start socat /run/moat/ssh/agent.sock (0660) <-> TCP:192.168.65.2:5522 as a long-lived child +claude-staging: copy allowlisted files from /mnt/claude-init into /home/moatuser/.claude (credential files forced 0600) +codex-staging: copy allowlisted files from /mnt/codex-init into /home/moatuser/.codex (credential files forced 0600) +gemini-staging: skip (/mnt/missing is not a directory) +copilot-staging: skip (staging var unset) +init-files: write 1 file(s) at 0600 (parents 0755), then scrub MOAT_INIT_FILES from the environment +clipboard: start Xvfb :99 as a long-lived child and export DISPLAY=:99 +git-config: git config --system --add safe.directory /workspace (best-effort) +git-config: git config --system user.name Ada (best-effort) +git-config: git config --system http.proxyAuthMethod basic (best-effort) +git-config: git config --system url.git@github.com:.insteadOf https://github.com/ (best-effort) +docker: start dockerd (dind, vfs) as a long-lived child, wait up to 30s, add moatuser to the docker group +named-volume-chown: chown /workspace/.cache to moatuser (non-recursive, best-effort) +populate-workspace-volume: tar-copy /mnt/host-workspace -> /workspace (2 exclude pattern(s), both pipe exit codes checked), then chown -R moatuser — requires root, fatal otherwise +workspace-mcp-json: copy /mnt/codex-init/mcp.json -> /workspace/.mcp.json +pre-run-hook: run "npm install" via gosu moatuser sh -c in /workspace; a non-zero exit aborts with that code +privilege drop: exec gosu moatuser "claude --continue" (MOAT_INIT_FILES scrubbed from the exec environment) diff --git a/internal/moatinit/testdata/plan_minimal.golden b/internal/moatinit/testdata/plan_minimal.golden new file mode 100644 index 00000000..cea0657c --- /dev/null +++ b/internal/moatinit/testdata/plan_minimal.golden @@ -0,0 +1,16 @@ +extra-hosts: skip (MOAT_EXTRA_HOSTS unset) +ssh-agent-bridge: skip (MOAT_SSH_TCP_ADDR unset) +claude-staging: skip (staging var unset) +codex-staging: skip (staging var unset) +gemini-staging: skip (staging var unset) +copilot-staging: skip (staging var unset) +init-files: skip (MOAT_INIT_FILES unset) +clipboard: skip (MOAT_CLIPBOARD != 1) +git-config: git config --system --add safe.directory /workspace (best-effort) +git-config: git config --system http.proxyAuthMethod basic (best-effort) +docker: skip +named-volume-chown: skip +populate-workspace-volume: skip (MOAT_WORKSPACE_VOLUME != 1) +workspace-mcp-json: skip (no staged mcp.json) +pre-run-hook: skip (MOAT_PRE_RUN unset) +privilege drop: exec "bash" directly (already non-root; MOAT_INIT_FILES scrubbed from the exec environment) From 7886e076feb925cbec73c006d024794b7f0eccf1 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:06:53 +0000 Subject: [PATCH 09/17] feat(e2e): entrypoint parity harness, gap fixtures, and dispatcher probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8 of the moat-init rewrite. A fixed state-dumper command emits a machine-parseable manifest — identity with the supplementary set compared order-independently, the exec'd environment with proxy auth tokens redacted to a fixed placeholder (and a direct MOAT_INIT_FILES leak scan, since both legs leaking would still diff clean), /etc/hosts, the system git config, file trees with modes+ownership (mtimes masked; the statsig dir compared as a content hash), and a census of the expected long-lived children — and the harness diffs it between MOAT_INIT_IMPL=sh and =go. The baseline scenario runs on every available runtime (Docker and Apple reparent children differently); the remaining scenarios cover the plan's coverage-gap fixtures on Docker: pre_run hook, workspace-volume populate, clipboard/Xvfb, and multi-record MOAT_INIT_FILES with a deep parent chain. Negative probes assert the failing pre_run hook's framed diagnostic + literal exit code on both implementations, the dispatcher's closed-enum fatal on an unknown MOAT_INIT_IMPL, and that the Go leg's detached children survive the exec handoff. run.Create() now forwards MOAT_INIT_IMPL/MOAT_INIT_LEGACY from the moat process's own environment — the operator-only channel the harness drives (user-supplied sources remain rejected). --- internal/e2e/entrypoint_parity_test.go | 341 +++++++++++++++++++++++++ internal/run/envguard.go | 17 ++ internal/run/envguard_test.go | 22 ++ internal/run/manager_create.go | 4 + 4 files changed, 384 insertions(+) create mode 100644 internal/e2e/entrypoint_parity_test.go diff --git a/internal/e2e/entrypoint_parity_test.go b/internal/e2e/entrypoint_parity_test.go new file mode 100644 index 00000000..9d45a4cd --- /dev/null +++ b/internal/e2e/entrypoint_parity_test.go @@ -0,0 +1,341 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/container" + "github.com/majorcontext/moat/internal/run" + "github.com/majorcontext/moat/internal/storage" +) + +// stateDumperScript is the fixed user command both parity legs run. It +// emits a machine-parseable manifest between markers covering what a naive +// file+id snapshot would miss (plan §7): identity with the supplementary +// set order-normalized, the exec'd environment (proxy auth tokens redacted +// to a fixed placeholder, per-run values dropped), /etc/hosts, the system +// git config, file trees with modes+ownership (mtimes masked by omission; +// the statsig dir compared as a content hash so a copy corruption cannot +// hide behind a mask), a census of the expected long-lived children, and a +// MOAT_INIT_FILES leak scan. +const stateDumperScript = ` +echo MOAT-MANIFEST-BEGIN +echo "[identity]" +id -u +id -g +id -G | tr " " "\n" | sort -n | paste -sd " " - +echo "[env]" +env | sort \ + | grep -v "^HOSTNAME=" \ + | grep -v "^SHLVL=" \ + | grep -v "^_=" \ + | sed -E "s#^(HTTPS?_PROXY|https?_proxy)=http://moat:[^@]*@#\1=http://moat:REDACTED@#" \ + | sed -E "s#^(MOAT_SSH_TCP_ADDR)=.*#\1=REDACTED#" \ + | sed -E "s#^(SSH_AUTH_SOCK)=.*#\1=REDACTED#" +echo "[hosts]" +cat /etc/hosts 2>/dev/null || echo none +echo "[gitconfig]" +{ git config --system --list 2>/dev/null || echo none; } | sort +echo "[tree]" +for d in "$HOME/.claude" "$HOME/.codex" "$HOME/.gemini" "$HOME/.copilot" /workspace; do + if [ -e "$d" ]; then + echo "-- $d" + find "$d" -path "*/statsig" -prune -o -printf "%y %M %u %g %P\n" 2>/dev/null | sort + fi +done +if [ -f "$HOME/.claude.json" ]; then stat -c "%A %U %G .claude.json" "$HOME/.claude.json"; else echo "no .claude.json"; fi +echo "[statsig]" +if [ -d "$HOME/.claude/statsig" ]; then + (cd "$HOME/.claude/statsig" && find . -type f | sort | xargs cat 2>/dev/null | sha256sum) +else + echo none +fi +echo "[children]" +for want in socat Xvfb dockerd; do + found=absent + for c in /proc/[0-9]*/comm; do + if [ "$(cat "$c" 2>/dev/null)" = "$want" ]; then found=running; break; fi + done + echo "$want $found" +done +echo "[init-files-leak]" +if env | grep -q "^MOAT_INIT_FILES="; then echo LEAKED; else echo clean; fi +echo MOAT-MANIFEST-END +` + +// runEntrypointLeg starts one parity leg with the given entrypoint +// implementation selected via the operator-only host env channel, waits for +// completion, and returns the extracted manifest plus the full log text. +func runEntrypointLeg(t *testing.T, impl, name string, opts run.Options) (manifest, allLogs string, waitErr error) { + t.Helper() + t.Setenv("MOAT_INIT_IMPL", impl) + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mgr, err := run.NewManagerWithOptions(run.ManagerOptions{NoSandbox: &[]bool{true}[0]}) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + defer mgr.Close() + + opts.Name = name + "-" + impl + r, err := mgr.Create(ctx, opts) + if err != nil { + t.Fatalf("Create(%s): %v", impl, err) + } + defer mgr.Destroy(context.Background(), r.ID) + + if err := mgr.Start(ctx, r.ID); err != nil { + t.Fatalf("Start(%s): %v", impl, err) + } + waitErr = mgr.Wait(ctx, r.ID) + time.Sleep(200 * time.Millisecond) + + store, err := storage.NewRunStore(storage.DefaultBaseDir(), r.ID) + if err != nil { + t.Fatalf("NewRunStore(%s): %v", impl, err) + } + logs, err := store.ReadLogs(0, 5000) + if err != nil { + t.Fatalf("ReadLogs(%s): %v", impl, err) + } + var b strings.Builder + for _, entry := range logs { + b.WriteString(entry.Line) + b.WriteString("\n") + } + allLogs = b.String() + + begin := strings.Index(allLogs, "MOAT-MANIFEST-BEGIN") + end := strings.Index(allLogs, "MOAT-MANIFEST-END") + if begin >= 0 && end > begin { + manifest = allLogs[begin:end] + } + return manifest, allLogs, waitErr +} + +// diffManifests reports the first differing line for readable failures. +func diffManifests(t *testing.T, name, sh, goM string) { + t.Helper() + if sh == goM { + return + } + shLines, goLines := strings.Split(sh, "\n"), strings.Split(goM, "\n") + for i := 0; i < len(shLines) || i < len(goLines); i++ { + var a, b string + if i < len(shLines) { + a = shLines[i] + } + if i < len(goLines) { + b = goLines[i] + } + if a != b { + t.Errorf("%s: manifests diverge at line %d:\n sh: %q\n go: %q", name, i+1, a, b) + break + } + } + t.Errorf("%s: full manifests differ\n===== sh =====\n%s\n===== go =====\n%s", name, sh, goM) +} + +// parityScenario is one config the harness diffs across implementations. +type parityScenario struct { + name string + opts func(t *testing.T) run.Options +} + +func parityScenarios() []parityScenario { + dumperCmd := []string{"sh", "-c", stateDumperScript} + return []parityScenario{ + { + // Plain run: privilege drop, baseline env, no features. + name: "baseline", + opts: func(t *testing.T) run.Options { + return run.Options{Workspace: createTestWorkspace(t), Cmd: dumperCmd} + }, + }, + { + // Gap fixture: pre_run hook (success path) leaves its marker in + // /workspace as moatuser before the dumper runs. + name: "pre-run-hook", + opts: func(t *testing.T) run.Options { + return run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + Config: &config.Config{ + Hooks: config.HooksConfig{PreRun: "date +%s > /workspace/.pre-run.timestamp && echo marker > /workspace/.pre-run-marker"}, + }, + } + }, + }, + { + // Gap fixture: MOAT_WORKSPACE_VOLUME full populate — tar copy, + // ownership hand-off, and the .mcp.json ordering all covered by + // the [tree] section. + name: "workspace-volume", + opts: func(t *testing.T) run.Options { + return run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + WorkspaceMode: config.WorkspaceModeVolume, + } + }, + }, + { + // Gap fixture: clipboard — Xvfb child census + DISPLAY in env. + name: "clipboard", + opts: func(t *testing.T) run.Options { + return run.Options{Workspace: createTestWorkspace(t), Cmd: dumperCmd, Clipboard: true} + }, + }, + { + // Gap fixture: multi-record MOAT_INIT_FILES with a deep parent + // chain — 0600 files, 0755 parents, ancestor chown, and the + // INIT-10 scrub all visible in [tree] + [init-files-leak]. + name: "init-files-multi", + opts: func(t *testing.T) run.Options { + rec := func(path, content string) string { + return path + "\t" + base64.StdEncoding.EncodeToString([]byte(content)) + } + records := rec("/home/moatuser/.config/deep/nested/tool/config.toml", "secret-one") + "\n" + + rec("/home/moatuser/.parityrc", "secret-two") + return run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + Env: []string{"MOAT_INIT_FILES=" + records}, + } + }, + }, + } +} + +// TestEntrypointParityBaseline diffs the full manifest between the sh and +// go entrypoints on every available runtime (Docker and Apple reparent +// children differently — the census must match per-runtime). +func TestEntrypointParityBaseline(t *testing.T) { + testOnAllRuntimes(t, func(t *testing.T, rt container.Runtime) { + sc := parityScenarios()[0] + shM, shLogs, _ := runEntrypointLeg(t, "sh", "parity-base", sc.opts(t)) + goM, goLogs, _ := runEntrypointLeg(t, "go", "parity-base", sc.opts(t)) + if shM == "" { + t.Fatalf("sh leg produced no manifest; logs:\n%s", shLogs) + } + if goM == "" { + t.Fatalf("go leg produced no manifest; logs:\n%s", goLogs) + } + diffManifests(t, sc.name, shM, goM) + }) +} + +// TestEntrypointParityScenarios diffs the remaining scenarios on Docker +// (the full-matrix leg per the plan's runtime matrix). +func TestEntrypointParityScenarios(t *testing.T) { + requireDocker(t) + for _, sc := range parityScenarios()[1:] { + sc := sc + t.Run(sc.name, func(t *testing.T) { + shM, shLogs, _ := runEntrypointLeg(t, "sh", "parity-"+sc.name, sc.opts(t)) + goM, goLogs, _ := runEntrypointLeg(t, "go", "parity-"+sc.name, sc.opts(t)) + if shM == "" { + t.Fatalf("sh leg produced no manifest; logs:\n%s", shLogs) + } + if goM == "" { + t.Fatalf("go leg produced no manifest; logs:\n%s", goLogs) + } + diffManifests(t, sc.name, shM, goM) + // The scrub is load-bearing enough to assert directly, not just + // via cross-leg equality (both legs leaking would still match). + for impl, m := range map[string]string{"sh": shM, "go": goM} { + if strings.Contains(m, "LEAKED") { + t.Errorf("%s leg leaked MOAT_INIT_FILES into the exec env", impl) + } + } + }) + } +} + +// TestEntrypointParityPreRunFailure is the negative probe: a failing +// pre_run hook must produce the framed #372 diagnostic and the hook's +// literal exit code on BOTH implementations, and the user command must not +// run. +func TestEntrypointParityPreRunFailure(t *testing.T) { + requireDocker(t) + for _, impl := range []string{"sh", "go"} { + impl := impl + t.Run(impl, func(t *testing.T) { + opts := run.Options{ + Workspace: createTestWorkspace(t), + Cmd: []string{"sh", "-c", "echo SHOULD-NOT-RUN"}, + Config: &config.Config{ + Hooks: config.HooksConfig{PreRun: "echo doing-setup; exit 7"}, + }, + } + manifest, logs, waitErr := runEntrypointLeg(t, impl, "parity-prerun-fail", opts) + if manifest != "" { + t.Error("manifest produced despite failing hook") + } + for _, want := range []string{ + "moat: pre_run hook failed (exit code 7)", + "moat: command: echo doing-setup; exit 7", + } { + if !strings.Contains(logs, want) { + t.Errorf("logs missing %q:\n%s", want, logs) + } + } + if strings.Contains(logs, "SHOULD-NOT-RUN") { + t.Error("user command ran after a failing pre_run hook") + } + if waitErr == nil || !strings.Contains(waitErr.Error(), "7") { + t.Errorf("wait error = %v, want the hook's literal exit code 7", waitErr) + } + }) + } +} + +// TestEntrypointDispatcherClosedEnum is the dispatcher negative probe: an +// unknown MOAT_INIT_IMPL value must fail loudly, never fall back to an +// unintended entrypoint. +func TestEntrypointDispatcherClosedEnum(t *testing.T) { + requireDocker(t) + opts := run.Options{ + Workspace: createTestWorkspace(t), + Cmd: []string{"sh", "-c", "echo SHOULD-NOT-RUN"}, + } + _, logs, waitErr := runEntrypointLeg(t, "bogus", "parity-bad-impl", opts) + if !strings.Contains(logs, "Error: invalid MOAT_INIT_IMPL 'bogus'") { + t.Errorf("logs missing the closed-enum error:\n%s", logs) + } + if strings.Contains(logs, "SHOULD-NOT-RUN") { + t.Error("user command ran under an invalid dispatcher value") + } + if waitErr == nil { + t.Error("run succeeded despite an invalid MOAT_INIT_IMPL") + } +} + +// TestEntrypointGoLongLivedChildren verifies the Go leg's detached children +// survive the exec handoff: the SSH bridge/Xvfb census in the manifest runs +// AFTER the entrypoint has been replaced by the user command, so a +// "running" entry proves the child outlived the exec. +func TestEntrypointGoLongLivedChildren(t *testing.T) { + requireDocker(t) + opts := run.Options{ + Workspace: createTestWorkspace(t), + Cmd: []string{"sh", "-c", stateDumperScript}, + Clipboard: true, + } + manifest, logs, _ := runEntrypointLeg(t, "go", "parity-children", opts) + if manifest == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + if !strings.Contains(manifest, "Xvfb running") { + t.Errorf("Xvfb did not survive the exec handoff:\n%s", manifest) + } +} diff --git a/internal/run/envguard.go b/internal/run/envguard.go index 29cc3b36..af167607 100644 --- a/internal/run/envguard.go +++ b/internal/run/envguard.go @@ -60,6 +60,23 @@ func validateReservedEnv(cfg *config.Config, explicitEnv []string) error { return nil } +// operatorInitEnv returns the entrypoint-dispatcher variables to inject +// into the container, read from the moat PROCESS's own environment — the +// operator-only channel. Users cannot set these through moat.yaml or -e +// (validateReservedEnv rejects them); an operator exports them on the host +// to select the entrypoint implementation: the parity harness drives both +// legs this way, and MOAT_INIT_LEGACY=1 is the one-release rollback lever +// after the Go cutover. +func operatorInitEnv(getenv func(string) string) []string { + var env []string + for _, key := range reservedInitEnvVars { + if v := getenv(key); v != "" { + env = append(env, key+"="+v) + } + } + return env +} + func reservedEnvError(name, source string) error { return fmt.Errorf("%s is reserved for moat's entrypoint dispatcher and cannot be set via %s.\n"+ "It selects which container entrypoint implementation runs and is managed by moat itself.\n"+ diff --git a/internal/run/envguard_test.go b/internal/run/envguard_test.go index 31a76e93..e83da5a6 100644 --- a/internal/run/envguard_test.go +++ b/internal/run/envguard_test.go @@ -51,6 +51,28 @@ func TestValidateReservedEnv(t *testing.T) { } } +// TestOperatorInitEnv covers the operator-only injection channel: host +// process env in, container env entries out — and the companion, nothing +// injected when the host env is clean. +func TestOperatorInitEnv(t *testing.T) { + host := map[string]string{"MOAT_INIT_IMPL": "go", "MOAT_INIT_LEGACY": "", "PATH": "/bin"} + got := operatorInitEnv(func(k string) string { return host[k] }) + if len(got) != 1 || got[0] != "MOAT_INIT_IMPL=go" { + t.Errorf("operatorInitEnv = %v, want [MOAT_INIT_IMPL=go]", got) + } + if got := operatorInitEnv(func(string) string { return "" }); len(got) != 0 { + t.Errorf("clean host env injected %v", got) + } + if got := operatorInitEnv(func(k string) string { + if k == "MOAT_INIT_LEGACY" { + return "1" + } + return "" + }); len(got) != 1 || got[0] != "MOAT_INIT_LEGACY=1" { + t.Errorf("operatorInitEnv = %v, want [MOAT_INIT_LEGACY=1]", got) + } +} + // TestReservedInitVarsUnfiltered pins the division of labor: the reserved // dispatcher vars are NOT part of the proxy-var filter (which only runs when // a proxy is active and warns-and-skips). They must be rejected by diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index 6da47cf9..ceef58bd 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -909,6 +909,10 @@ region = %s proxyEnv = append(proxyEnv, "MOAT_CLIPBOARD=1", "DISPLAY=:99") } + // Forward the operator-only entrypoint dispatcher controls from the moat + // process's own environment (user-supplied sources are rejected above). + proxyEnv = append(proxyEnv, operatorInitEnv(os.Getenv)...) + // Add explicit env vars (highest priority - can override config), // but filter proxy-related vars when proxy is active. for _, e := range opts.Env { From f8e7b5897c3e0be5a21ed0fc9a9c17163bb91a24 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:09:03 +0000 Subject: [PATCH 10/17] docs: document the moat-init Go entrypoint, dispatcher, and --plan dry-run Commit 9 of the moat-init rewrite (docs leg). Adds the container-startup section to the sandboxing concept page (entrypoint responsibilities, the dual-ship dispatcher, reserved MOAT_INIT_IMPL/MOAT_INIT_LEGACY controls, and the --plan dry-run with a working moat exec invocation) and the CHANGELOG entry. The default implementation deliberately stays 'sh': the plan gates the cutover flip on the e2e parity harness passing on real runtimes plus a documented manual macOS sign-off (Docker Desktop DNS + Apple reaping legs are untestable in CI). The flip is a one-line dispatcher change (moat-init-dispatch.sh default) once those gates pass. --- CHANGELOG.md | 1 + docs/content/concepts/01-sandboxing.md | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d646a42d..cf21b7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added +- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init-go --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) diff --git a/docs/content/concepts/01-sandboxing.md b/docs/content/concepts/01-sandboxing.md index fda8de9a..0877e5d0 100644 --- a/docs/content/concepts/01-sandboxing.md +++ b/docs/content/concepts/01-sandboxing.md @@ -52,6 +52,18 @@ When using `docker+gvisor`, the container runs inside gVisor, but Docker-in-Dock Both modes require Docker as the container runtime. Apple containers do not support Docker socket mounting or privileged mode. See [Dependencies](../reference/06-dependencies.md#docker-dependencies) for configuration details. +## Container startup (the moat-init entrypoint) + +Every Moat container starts through the `moat-init` entrypoint, which runs before your command to set up features declared in `moat.yaml`: `/etc/hosts` entries for the proxy, the SSH agent bridge, agent config staging (Claude/Codex/Gemini/Copilot), provider credential files, git configuration, Docker access, workspace volume population, and the `pre_run` hook. Its final act is dropping privileges (via `gosu`) to the non-root `moatuser` account and replacing itself with your command. + +Two implementations of the entrypoint ship in every image during the current migration window: the original shell script and a Go binary with identical behavior. A dispatcher selects between them; the shell implementation is the default. `MOAT_INIT_IMPL` and `MOAT_INIT_LEGACY` are reserved control variables managed by Moat itself — setting them in `moat.yaml` `env:` or via `-e` fails the run. + +The Go implementation supports a dry-run: running `moat-init-go --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: + +```bash +moat exec -- /usr/local/bin/moat-init-go --plan +``` + ## Limitations Container isolation is not a security boundary against a determined attacker. It provides: From bb89582e831dedb02321bf135e9de77f5d26e9aa Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:44:32 +0000 Subject: [PATCH 11/17] fix(moatinit): address adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed findings from the multi-agent review (4/23 upheld), plus cheap parity gaps the refuters dismissed on materiality only: - Close the moat.yaml secrets: bypass of the reserved dispatcher-env guard — secret KEYS are user-chosen and land in the container env verbatim, so validateReservedEnv now covers cfg.Secrets too - Implement the plan-promised stub gates instead of claiming them: internal/initbin/gate (goreleaser before hook) refuses stub/stale blobs and execs the regenerated binary's --plan as the positive functional check; make test-e2e now regenerates the binaries first; the parity harness skips its go legs when the test binary embeds a stub; the initbin doc comment now describes what actually exists - Normalize Docker's per-container /etc/hosts self-entry out of the parity manifest (the [hosts] section could never match across legs) - Empty user command: exec dispatch no longer panics — non-root exits 0 (the script's bare exec is a no-op and the script ends), root path hands gosu its own usage error - Shell-style path concatenation for TARGET_HOME destinations, so an empty HOME yields the script's root-anchored /.claude, never a cwd-relative path - pre_run hook inherits the container's stdin (Cmd.Stdin), matching the shell; empty dockerd log tails print nothing - Companion tests: whitespace-only pre_run runs (EXEC-01), empty excludes copy everything (WS-06), secrets-guard cases; the parity exit-code assertion now requires 'exit code 7', not a bare '7' --- .goreleaser.yaml | 3 + Makefile | 2 +- cmd/moat-init/main.go | 1 + internal/e2e/entrypoint_parity_test.go | 20 ++++- internal/initbin/gate/gate.go | 103 +++++++++++++++++++++++ internal/initbin/initbin.go | 14 +-- internal/moatinit/agents.go | 13 +-- internal/moatinit/children_phase_test.go | 11 +++ internal/moatinit/docker.go | 4 +- internal/moatinit/execdispatch.go | 7 ++ internal/moatinit/hook.go | 2 + internal/moatinit/phase.go | 1 + internal/moatinit/sys.go | 2 + internal/moatinit/volume_phase_test.go | 25 ++++++ internal/run/envguard.go | 13 ++- internal/run/envguard_test.go | 6 ++ 16 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 internal/initbin/gate/gate.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e6e9037f..18ff3223 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,6 +6,9 @@ before: hooks: - go mod tidy - go generate ./... + # Ship-refusal + functional gate for the embedded moat-init binaries: + # refuses stub/stale blobs and execs the regenerated binary's --plan. + - go run ./internal/initbin/gate builds: - main: ./cmd/moat diff --git a/Makefile b/Makefile index 36827409..3cd425db 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ test: test-unit test-e2e test-bats ## Run all tests (unit + E2E + hooks) test-unit: ## Run unit tests with race detector (use ARGS for filtering, e.g., ARGS='-run TestName') go test -race $(ARGS) ./... -test-e2e: ## Run E2E tests (use ARGS for filtering, e.g., ARGS='-run TestName') +test-e2e: generate-init ## Run E2E tests (use ARGS for filtering, e.g., ARGS='-run TestName') go test -tags=e2e -timeout=30m $(ARGS) ./internal/e2e/ test-bats: ## Run bats tests for Claude Code hooks diff --git a/cmd/moat-init/main.go b/cmd/moat-init/main.go index 99d881c7..e7d05cf3 100644 --- a/cmd/moat-init/main.go +++ b/cmd/moat-init/main.go @@ -22,6 +22,7 @@ func main() { Sys: sys, Cfg: moatinit.LoadConfig(sys), Argv: os.Args[1:], + Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, } diff --git a/internal/e2e/entrypoint_parity_test.go b/internal/e2e/entrypoint_parity_test.go index 9d45a4cd..999a657e 100644 --- a/internal/e2e/entrypoint_parity_test.go +++ b/internal/e2e/entrypoint_parity_test.go @@ -12,6 +12,7 @@ import ( "github.com/majorcontext/moat/internal/config" "github.com/majorcontext/moat/internal/container" + "github.com/majorcontext/moat/internal/initbin" "github.com/majorcontext/moat/internal/run" "github.com/majorcontext/moat/internal/storage" ) @@ -40,7 +41,15 @@ env | sort \ | sed -E "s#^(MOAT_SSH_TCP_ADDR)=.*#\1=REDACTED#" \ | sed -E "s#^(SSH_AUTH_SOCK)=.*#\1=REDACTED#" echo "[hosts]" -cat /etc/hosts 2>/dev/null || echo none +# Docker writes a unique self-entry " " into every +# container's /etc/hosts; drop the self line (each leg is its own +# container) so the moat-appended entries are what gets compared. +SELF="$(cat /etc/hostname 2>/dev/null)" +if [ -n "$SELF" ]; then + grep -v "$SELF" /etc/hosts 2>/dev/null || echo none +else + cat /etc/hosts 2>/dev/null || echo none +fi echo "[gitconfig]" { git config --system --list 2>/dev/null || echo none; } | sort echo "[tree]" @@ -75,6 +84,12 @@ echo MOAT-MANIFEST-END // completion, and returns the extracted manifest plus the full log text. func runEntrypointLeg(t *testing.T, impl, name string, opts run.Options) (manifest, allLogs string, waitErr error) { t.Helper() + if impl == "go" && initbin.IsStub(initbin.Binary()) { + // The test binary embeds the same initbin blobs writeEntrypoint + // ships: a stub go leg would exec the fail-closed placeholder as + // PID 1 and fail confusingly instead of exercising parity. + t.Skip("embedded moat-init binary is the committed stub — run 'make generate-init' (or 'make test-e2e', which does) first") + } t.Setenv("MOAT_INIT_IMPL", impl) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) @@ -292,7 +307,8 @@ func TestEntrypointParityPreRunFailure(t *testing.T) { if strings.Contains(logs, "SHOULD-NOT-RUN") { t.Error("user command ran after a failing pre_run hook") } - if waitErr == nil || !strings.Contains(waitErr.Error(), "7") { + // "exit code 7" (not a bare "7", which 17/27/127 would satisfy). + if waitErr == nil || !strings.Contains(waitErr.Error(), "exit code 7") { t.Errorf("wait error = %v, want the hook's literal exit code 7", waitErr) } }) diff --git a/internal/initbin/gate/gate.go b/internal/initbin/gate/gate.go new file mode 100644 index 00000000..29d4a205 --- /dev/null +++ b/internal/initbin/gate/gate.go @@ -0,0 +1,103 @@ +// Command gate is the release pipeline's ship-refusal + positive functional +// gate for the embedded moat-init binaries (plan §5). Run AFTER `go generate +// ./internal/initbin` (go run recompiles, so the embedded bytes reflect the +// current embed/ files): +// +// 1. Ship refusal: refuse if either embedded blob is still the committed +// fail-closed stub — a release must never ship a stub as a container +// entrypoint candidate. +// 2. Checksum: the embedded bytes must match checksums.txt (catches a +// stale or hand-edited blob). +// 3. Functional: on a linux host, exec the host-arch binary with --plan +// against a fixed environment and require the privilege-drop and +// MOAT_INIT_FILES-scrub lines. Checksum matching alone cannot catch a +// regenerated-but-defective blob (wrong commit, truncated output) whose +// checksums.txt was regenerated alongside it — execing the binary can. +// +// Wired into the goreleaser before hooks; exits non-zero with a diagnostic +// on any gate failure. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/majorcontext/moat/internal/initbin" +) + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "initbin gate: "+format+"\n", args...) + os.Exit(1) +} + +func main() { + blobs := map[string][]byte{ + "moat-init-linux-amd64": initbin.BinaryFor("amd64"), + "moat-init-linux-arm64": initbin.BinaryFor("arm64"), + } + + // Gate 1: ship refusal. + for name, b := range blobs { + if len(b) == 0 { + fail("%s: no embedded bytes", name) + } + if initbin.IsStub(b) { + fail("%s is the committed stub — run 'go generate ./internal/initbin' before releasing", name) + } + } + + // Gate 2: checksums. + want := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(initbin.Checksums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + fail("malformed checksums.txt line: %q", line) + } + want[fields[1]] = fields[0] + } + for name, b := range blobs { + sum := sha256.Sum256(b) + if hex.EncodeToString(sum[:]) != want[name] { + fail("%s does not match checksums.txt — stale or hand-edited blob", name) + } + } + + // Gate 3: functional (linux hosts only — the blobs are linux + // executables; macOS release builds rely on gates 1-2 plus CI running + // this gate on a linux runner). + if runtime.GOOS == "linux" { + bin := initbin.Binary() + if bin == nil { + fail("no embedded binary for host arch %s", runtime.GOARCH) + } + tmp, err := os.MkdirTemp("", "moat-init-gate") + if err != nil { + fail("temp dir: %v", err) + } + defer os.RemoveAll(tmp) + path := filepath.Join(tmp, "moat-init") + if werr := os.WriteFile(path, bin, 0o755); werr != nil { + fail("writing binary: %v", werr) + } + cmd := exec.Command(path, "--plan", "echo", "gate") + cmd.Env = []string{"HOME=/tmp", "PATH=/usr/bin:/bin", "MOAT_INIT_FILES=/tmp/gate\tYWJj"} + out, err := cmd.Output() + if err != nil { + fail("--plan run failed: %v (a defective blob cannot serve as PID 1)", err) + } + plan := string(out) + for _, wantLine := range []string{"privilege drop:", "scrub MOAT_INIT_FILES"} { + if !strings.Contains(plan, wantLine) { + fail("--plan output missing %q — the binary lost a load-bearing phase:\n%s", wantLine, plan) + } + } + } + + fmt.Println("initbin gate: ok") +} diff --git a/internal/initbin/initbin.go b/internal/initbin/initbin.go index d7949d79..039a23b6 100644 --- a/internal/initbin/initbin.go +++ b/internal/initbin/initbin.go @@ -60,11 +60,15 @@ func Binary() []byte { } // IsStub reports whether b is the committed fail-closed placeholder rather -// than a real cross-compiled entrypoint. Release checks refuse to ship an -// image whose entrypoint bytes are the stub; the stub itself also fails -// loudly at runtime (defense in depth — a checksum test alone cannot catch a -// regenerated-but-defective blob, which is why the release pipeline also -// execs the binary; see the plan's positive functional gate). +// than a real cross-compiled entrypoint. Three layers keep a stub from +// serving as PID 1: the release gate (internal/initbin/gate, wired into the +// goreleaser before hooks) refuses to release stub bytes and execs the +// regenerated binary's --plan as a positive functional check; the parity +// harness skips its go legs when the test binary embeds a stub; and the +// stub itself fails loudly at runtime — the backstop for channels that +// bypass generation entirely (`go install`, bare `go build`), where the +// dispatcher's sh default keeps runs working and MOAT_INIT_IMPL=go fails +// closed with the stub's message. func IsStub(b []byte) bool { return bytes.HasPrefix(b, []byte(stubMarker)) } diff --git a/internal/moatinit/agents.go b/internal/moatinit/agents.go index 2740036f..9824ff08 100644 --- a/internal/moatinit/agents.go +++ b/internal/moatinit/agents.go @@ -83,7 +83,10 @@ func stageAgent(ctx *Context, agent, staging, agentDir string, entries []stagedE } home := targetHome(sys.Geteuid(), moatuserExists(sys), cfg.Home) - destDir := filepath.Join(home, agentDir) + // Shell-style concatenation, not filepath.Join: the script builds + // "$TARGET_HOME/.claude", so an empty HOME yields the root-anchored + // "/.claude" (which then fails loudly), never a cwd-relative path. + destDir := home + "/" + agentDir if err := sys.MkdirAll(destDir, 0o755); err != nil { return fatalPhaseError(ctx, "creating "+destDir, err) } @@ -95,16 +98,16 @@ func stageAgent(ctx *Context, agent, staging, agentDir string, entries []stagedE if !isDir(sys, src) { continue } - if err := sys.CopyTreePreserving(src, filepath.Join(destDir, e.name)); err != nil { + if err := sys.CopyTreePreserving(src, destDir+"/"+e.name); err != nil { return fatalPhaseError(ctx, "staging "+agent+" "+e.name, err) } default: if !isFile(sys, src) { continue } - dst := filepath.Join(destDir, e.name) + dst := destDir + "/" + e.name if e.home { - dst = filepath.Join(home, e.name) + dst = home + "/" + e.name } if err := sys.CopyFilePreserving(src, dst); err != nil { return fatalPhaseError(ctx, "staging "+agent+" "+e.name, err) @@ -126,7 +129,7 @@ func stageAgent(ctx *Context, agent, staging, agentDir string, entries []stagedE if !e.home { continue } - dst := filepath.Join(home, e.name) + dst := home + "/" + e.name if isFile(sys, dst) { _ = sys.Chown(dst, u.UID, u.GID) } diff --git a/internal/moatinit/children_phase_test.go b/internal/moatinit/children_phase_test.go index ae3c772c..b4d9c7da 100644 --- a/internal/moatinit/children_phase_test.go +++ b/internal/moatinit/children_phase_test.go @@ -346,6 +346,17 @@ func TestPreRunHookPhase(t *testing.T) { t.Error("empty hook ran") } + // EXEC-01 companion: a whitespace-only hook is NOT empty ([ -z ] is + // false for " ") — it runs. + tsWS := newTestSys(t, 1000, true) + ctxWS, _ := newTestContext(tsWS, Config{PreRun: " "}) + if err := preRunHookPhase(ctxWS); err != nil { + t.Fatal(err) + } + if len(tsWS.runs) != 1 { + t.Error("whitespace-only hook did not run") + } + // EXEC-02: non-root runs sh -c in /workspace (child-confined cwd). ts2 := newTestSys(t, 1000, true) ctx2, _ := newTestContext(ts2, Config{PreRun: "npm install"}) diff --git a/internal/moatinit/docker.go b/internal/moatinit/docker.go index 1dc1f0ac..4268eccf 100644 --- a/internal/moatinit/docker.go +++ b/internal/moatinit/docker.go @@ -105,8 +105,8 @@ func dindSetup(ctx *Context) error { // port of `tail -20 /var/log/dockerd.log 2>/dev/null || true`). func tailDockerdLog(ctx *Context) { data, err := ctx.Sys.ReadFile("/var/log/dockerd.log") - if err != nil { - return + if err != nil || len(data) == 0 { + return // tail of a missing/empty log prints nothing } lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") if len(lines) > 20 { diff --git a/internal/moatinit/execdispatch.go b/internal/moatinit/execdispatch.go index 670ab6d7..cb32e39e 100644 --- a/internal/moatinit/execdispatch.go +++ b/internal/moatinit/execdispatch.go @@ -37,6 +37,13 @@ func execDispatchPhase(ctx *Context) error { switch { case sys.Geteuid() != 0: // Already non-root (e.g. --user was passed to docker run). + // With no command at all, the script's `exec "$@"` is a no-op and + // the script simply ends: exit 0 without running anything. (On the + // root path below, `exec gosu moatuser` with no command is handed + // to gosu, whose own usage error is the parity behavior.) + if len(ctx.Argv) == 0 { + return exitError{code: 0} + } return execFailure(ctx, ctx.Argv, sys.Exec(ctx.Argv, env)) case moatuserExists(sys): // Running as root, moatuser exists - drop privileges. diff --git a/internal/moatinit/hook.go b/internal/moatinit/hook.go index 88a10e6a..29b2c4dc 100644 --- a/internal/moatinit/hook.go +++ b/internal/moatinit/hook.go @@ -29,6 +29,7 @@ func preRunHookPhase(ctx *Context) error { rc, err := sys.Run(Cmd{ Argv: []string{"sh", "-c", cfg.PreRun}, Dir: sys.RealPath("/workspace"), + Stdin: ctx.Stdin, Stdout: ctx.Stdout, Stderr: ctx.Stderr, }) @@ -41,6 +42,7 @@ func preRunHookPhase(ctx *Context) error { case moatuserExists(sys): rc, err := sys.Run(Cmd{ Argv: []string{"gosu", "moatuser", "sh", "-c", "cd /workspace && " + cfg.PreRun}, + Stdin: ctx.Stdin, Stdout: ctx.Stdout, Stderr: ctx.Stderr, }) diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index caf15c84..e253bb1e 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -12,6 +12,7 @@ type Context struct { Sys Sys Cfg *Config Argv []string // the user command (the entrypoint's "$@") + Stdin io.Reader // inherited by interactive children (pre_run hook) Stdout io.Writer // inherited by children that surface output (pre_run hook) Stderr io.Writer } diff --git a/internal/moatinit/sys.go b/internal/moatinit/sys.go index f9348448..c3494840 100644 --- a/internal/moatinit/sys.go +++ b/internal/moatinit/sys.go @@ -28,6 +28,7 @@ type Cmd struct { Argv []string Dir string // working directory ("" = inherit) Env []string // nil = inherit the current process environment + Stdin io.Reader // nil = /dev/null Stdout io.Writer // nil = discard Stderr io.Writer // nil = discard @@ -311,6 +312,7 @@ func (s *OSSys) Run(c Cmd) (int, error) { } cmd.Dir = c.Dir cmd.Env = c.Env + cmd.Stdin = c.Stdin cmd.Stdout = c.Stdout cmd.Stderr = c.Stderr err := cmd.Run() diff --git a/internal/moatinit/volume_phase_test.go b/internal/moatinit/volume_phase_test.go index 7f09d4a0..61ec8c95 100644 --- a/internal/moatinit/volume_phase_test.go +++ b/internal/moatinit/volume_phase_test.go @@ -97,6 +97,31 @@ func TestPopulateWorkspaceVolumeRealTar(t *testing.T) { } } +// TestPopulateWorkspaceVolumeEmptyExcludes is the WS-06 companion to the +// exclude-applying test: an empty exclude file excludes nothing, so the +// whole staging tree lands in /workspace. +func TestPopulateWorkspaceVolumeEmptyExcludes(t *testing.T) { + if _, err := exec.LookPath("tar"); err != nil { + t.Skip("tar not installed") + } + ts := newTestSys(t, 0, true) + setupStagingTree(t, ts) + ctx, _ := newTestContext(ts, Config{WorkspaceVolume: "1", Home: "/root"}) + if err := populateWorkspaceVolumePhase(ctx); err != nil { + t.Fatal(err) + } + for _, p := range []string{ + "/workspace/main.go", + "/workspace/node_modules/pkg.json", + "/workspace/dist/sub/bundle.js", + "/workspace/dist/keep/artifact.txt", + } { + if !exists(ts, p) { + t.Errorf("%s missing with empty excludes", p) + } + } +} + func TestPopulateWorkspaceVolumeGate(t *testing.T) { // WS-01: any non-"1" value is a no-op — checked BEFORE the root guard, // so a disabled populate as non-root is fine. diff --git a/internal/run/envguard.go b/internal/run/envguard.go index af167607..a6b6a05b 100644 --- a/internal/run/envguard.go +++ b/internal/run/envguard.go @@ -37,9 +37,11 @@ func isReservedInitVar(name string) bool { } // validateReservedEnv rejects reserved entrypoint-dispatcher variables in -// user-supplied environment sources (moat.yaml env: and -e/--env flags). An -// -e entry without '=' is a host-passthrough form and is matched on its full -// name. +// every user-supplied environment source: moat.yaml env:, moat.yaml +// secrets: (secret KEYS are user-chosen and appended to the container env +// verbatim, so a `secrets: {MOAT_INIT_IMPL: env://X}` entry would otherwise +// smuggle the switch past the env guard), and -e/--env flags. An -e entry +// without '=' is a host-passthrough form and is matched on its full name. func validateReservedEnv(cfg *config.Config, explicitEnv []string) error { if cfg != nil { for k := range cfg.Env { @@ -47,6 +49,11 @@ func validateReservedEnv(cfg *config.Config, explicitEnv []string) error { return reservedEnvError(k, "moat.yaml env") } } + for k := range cfg.Secrets { + if isReservedInitVar(k) { + return reservedEnvError(k, "moat.yaml secrets") + } + } } for _, e := range explicitEnv { name := e diff --git a/internal/run/envguard_test.go b/internal/run/envguard_test.go index e83da5a6..c00a8a22 100644 --- a/internal/run/envguard_test.go +++ b/internal/run/envguard_test.go @@ -30,6 +30,12 @@ func TestValidateReservedEnv(t *testing.T) { {"empty value in -e", nil, []string{"MOAT_INIT_IMPL="}, "MOAT_INIT_IMPL is reserved"}, // Companion: prefix/suffix near-misses are not reserved. {"near-miss names pass", &config.Config{Env: map[string]string{"MOAT_INIT_IMPL_X": "1", "XMOAT_INIT_IMPL": "1"}}, []string{"MOAT_INIT=1"}, ""}, + // Secret KEYS are user-chosen and land in the container env + // verbatim — the guard must cover them too. + {"MOAT_INIT_IMPL as secret key", &config.Config{Secrets: map[string]string{"MOAT_INIT_IMPL": "env://X"}}, nil, "MOAT_INIT_IMPL is reserved"}, + {"MOAT_INIT_LEGACY as secret key", &config.Config{Secrets: map[string]string{"moat_init_legacy": "env://X"}}, nil, "is reserved"}, + // Companion: benign secret keys pass. + {"benign secret keys pass", &config.Config{Secrets: map[string]string{"API_KEY": "env://X"}}, nil, ""}, } for _, tt := range tests { From 56f40b6539e9a0a91b58a27fa6aacee7912b379d Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:45:37 +0000 Subject: [PATCH 12/17] refactor(deps): rename the shipped Go entrypoint binary to moat-commit The in-image Go implementation moves from /usr/local/bin/moat-init-go to /usr/local/bin/moat-commit (context file, COPY, chmod, dispatcher exec target, tests, and docs). The dispatcher path (/usr/local/bin/moat-init), the shell leg (moat-init-sh), the cmd/moat-init source package, and the embedded blob names are unchanged. --- CHANGELOG.md | 2 +- docs/content/concepts/01-sandboxing.md | 4 ++-- internal/deps/builder.go | 2 +- internal/deps/dockerfile.go | 8 ++++---- internal/deps/moat_init_dispatch_test.go | 12 ++++++------ internal/deps/registry.go | 2 +- internal/deps/scripts/moat-init-dispatch.sh | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf21b7ed..e28f8867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added -- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init-go --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-commit --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) diff --git a/docs/content/concepts/01-sandboxing.md b/docs/content/concepts/01-sandboxing.md index 0877e5d0..bd4b0afc 100644 --- a/docs/content/concepts/01-sandboxing.md +++ b/docs/content/concepts/01-sandboxing.md @@ -58,10 +58,10 @@ Every Moat container starts through the `moat-init` entrypoint, which runs befor Two implementations of the entrypoint ship in every image during the current migration window: the original shell script and a Go binary with identical behavior. A dispatcher selects between them; the shell implementation is the default. `MOAT_INIT_IMPL` and `MOAT_INIT_LEGACY` are reserved control variables managed by Moat itself — setting them in `moat.yaml` `env:` or via `-e` fails the run. -The Go implementation supports a dry-run: running `moat-init-go --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: +The Go implementation supports a dry-run: running `moat-commit --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: ```bash -moat exec -- /usr/local/bin/moat-init-go --plan +moat exec -- /usr/local/bin/moat-commit --plan ``` ## Limitations diff --git a/internal/deps/builder.go b/internal/deps/builder.go index 5b48d635..a9668486 100644 --- a/internal/deps/builder.go +++ b/internal/deps/builder.go @@ -126,7 +126,7 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { // The "moat-init-v2" label is a deliberate salt bump: images cached before // the dispatcher existed hashed only the script under the "moat-init" label, // and a warm-cache lookup must not resolve to a pre-dispatcher image that -// lacks the moat-init-go/moat-init-sh split. Note this re-keys the `moat +// lacks the moat-commit/moat-init-sh split. Note this re-keys the `moat // run` build/lookup path only — a workflow pinning a concrete moat/run: // tag still resolves the old image and must re-tag/rebuild at cutover. func initHashComponent() string { diff --git a/internal/deps/dockerfile.go b/internal/deps/dockerfile.go index aa538ae9..ce9722c3 100644 --- a/internal/deps/dockerfile.go +++ b/internal/deps/dockerfile.go @@ -620,7 +620,7 @@ func formatHookCommand(cmd string) string { // // During the shell->Go migration window the ENTRYPOINT is a dispatcher that // selects between the shell script (moat-init-sh, the default) and the Go -// binary (moat-init-go) via the operator-only MOAT_INIT_IMPL / +// binary (moat-commit) via the operator-only MOAT_INIT_IMPL / // MOAT_INIT_LEGACY variables, so one cached image carries both // implementations. The Go binary is arch-matched: run images are always // built for the host's own architecture, so the runtime.GOARCH blob from @@ -634,9 +634,9 @@ func writeEntrypoint(b *strings.Builder, opts *ImageSpec, dockerMode DockerMode, b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init-sh\n") chmodPaths := "/usr/local/bin/moat-init /usr/local/bin/moat-init-sh" if goBin := initbin.Binary(); goBin != nil { - contextFiles["moat-init-go"] = goBin - b.WriteString("COPY moat-init-go /usr/local/bin/moat-init-go\n") - chmodPaths += " /usr/local/bin/moat-init-go" + contextFiles["moat-commit"] = goBin + b.WriteString("COPY moat-commit /usr/local/bin/moat-commit\n") + chmodPaths += " /usr/local/bin/moat-commit" } b.WriteString("RUN chmod +x " + chmodPaths + "\n") b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n") diff --git a/internal/deps/moat_init_dispatch_test.go b/internal/deps/moat_init_dispatch_test.go index 89dbb388..bc5fb745 100644 --- a/internal/deps/moat_init_dispatch_test.go +++ b/internal/deps/moat_init_dispatch_test.go @@ -23,8 +23,8 @@ func TestWriteEntrypointDualShip(t *testing.T) { for _, want := range []string{ "COPY moat-init-dispatch.sh /usr/local/bin/moat-init\n", "COPY moat-init.sh /usr/local/bin/moat-init-sh\n", - "COPY moat-init-go /usr/local/bin/moat-init-go\n", - "RUN chmod +x /usr/local/bin/moat-init /usr/local/bin/moat-init-sh /usr/local/bin/moat-init-go\n", + "COPY moat-commit /usr/local/bin/moat-commit\n", + "RUN chmod +x /usr/local/bin/moat-init /usr/local/bin/moat-init-sh /usr/local/bin/moat-commit\n", "ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n", } { if !strings.Contains(df, want) { @@ -38,8 +38,8 @@ func TestWriteEntrypointDualShip(t *testing.T) { if got := string(result.ContextFiles["moat-init-dispatch.sh"]); got != MoatInitDispatcher { t.Error("context file moat-init-dispatch.sh does not carry MoatInitDispatcher") } - if got := result.ContextFiles["moat-init-go"]; string(got) != string(initbin.Binary()) { - t.Error("context file moat-init-go does not carry the arch-matched embedded binary") + if got := result.ContextFiles["moat-commit"]; string(got) != string(initbin.Binary()) { + t.Error("context file moat-commit does not carry the arch-matched embedded binary") } // Offline-build contract: the entrypoint must be materialized from @@ -63,7 +63,7 @@ func TestWriteEntrypointCompanionNoInit(t *testing.T) { if err != nil { t.Fatalf("GenerateDockerfile error: %v", err) } - for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-init-go"} { + for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-commit"} { if _, ok := result.ContextFiles[name]; ok { t.Errorf("context file %s present in a no-init image", name) } @@ -82,7 +82,7 @@ func TestDispatcherContract(t *testing.T) { for _, want := range []string{ `impl="${MOAT_INIT_IMPL:-sh}"`, "unset MOAT_INIT_IMPL MOAT_INIT_LEGACY", - "exec /usr/local/bin/moat-init-go \"$@\"", + "exec /usr/local/bin/moat-commit \"$@\"", "exec /usr/local/bin/moat-init-sh \"$@\"", "Error: invalid MOAT_INIT_IMPL", "Error: invalid MOAT_INIT_LEGACY", diff --git a/internal/deps/registry.go b/internal/deps/registry.go index 28f873b3..8ee656b9 100644 --- a/internal/deps/registry.go +++ b/internal/deps/registry.go @@ -17,7 +17,7 @@ var MoatInitScript string // implementations during the moat-init shell->Go migration window // (docs/plans/2026-07-01-moat-init-go-rewrite-plan.md). It is installed as // /usr/local/bin/moat-init (the ENTRYPOINT); the script and the Go binary -// are installed next to it as moat-init-sh and moat-init-go. +// are installed next to it as moat-init-sh and moat-commit. // //go:embed scripts/moat-init-dispatch.sh var MoatInitDispatcher string diff --git a/internal/deps/scripts/moat-init-dispatch.sh b/internal/deps/scripts/moat-init-dispatch.sh index 9217df87..7dc1249d 100644 --- a/internal/deps/scripts/moat-init-dispatch.sh +++ b/internal/deps/scripts/moat-init-dispatch.sh @@ -3,7 +3,7 @@ # # Selects which moat-init implementation runs as PID 1: # /usr/local/bin/moat-init-sh - the original shell entrypoint -# /usr/local/bin/moat-init-go - the Go entrypoint (cmd/moat-init) +# /usr/local/bin/moat-commit - the Go entrypoint (cmd/moat-init) # # MOAT_INIT_IMPL and MOAT_INIT_LEGACY are operator-only controls injected by # the moat host binary; run.Create() rejects them in moat.yaml env and -e @@ -39,6 +39,6 @@ esac unset MOAT_INIT_IMPL MOAT_INIT_LEGACY if [ "$impl" = "go" ]; then - exec /usr/local/bin/moat-init-go "$@" + exec /usr/local/bin/moat-commit "$@" fi exec /usr/local/bin/moat-init-sh "$@" From ba334279450f8a525c1d18bb8e2afffa179b82c3 Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 06:53:29 +0000 Subject: [PATCH 13/17] test(moatinit): differential shell-vs-Go pressure harness + edge-case fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a differential harness that runs the REAL moat-init.sh and the REAL compiled Go entrypoint side by side against ~20 crafted environments on the non-root branch — comparing exit codes, contract stderr (byte-for-byte where scripted), the child-visible environment, and full HOME trees (paths, types, modes, content hashes). Covers all four agent staging blocks, multi-record init files with the env scrub, clipboard DISPLAY export, git system config (identical config-file bytes, quoted identity values, insteadOf both gates), docker mutex/dind/populate/chown guards, pre_run success/literal-42/signal-143/whitespace hooks, malformed extra-hosts, the ~5s resolve-failure fatal (byte-identical three-line error), exit-code passthrough, and 127 on a missing command. Plus live-shell differential corpora for the IFS= record splitter and base64 acceptance, and the documented invalid-base64 residue divergence (shell leaves a truncated file; Go leaves nothing). The harness caught one real parity bug, fixed here: coreutils base64 -d REJECTS carriage returns while Go's decoder silently strips them, so a CRLF-joined MOAT_INIT_FILES would fail closed under the shell but decode under Go — decodeInitContent now rejects CR like base64 -d. Also pins the edge cases fixed in the review pass: empty argv (non-root bare-exec no-op exits 0; root hands bare 'gosu moatuser' to gosu) and empty HOME staying root-anchored (/.codex, never cwd-relative). --- internal/moatinit/agents_phase_test.go | 16 + internal/moatinit/initfiles.go | 26 +- internal/moatinit/initfiles_test.go | 12 +- internal/moatinit/phase_test.go | 27 ++ internal/moatinit/shellparity_test.go | 565 +++++++++++++++++++++++++ 5 files changed, 634 insertions(+), 12 deletions(-) create mode 100644 internal/moatinit/shellparity_test.go diff --git a/internal/moatinit/agents_phase_test.go b/internal/moatinit/agents_phase_test.go index e7e3fd38..f90f40bf 100644 --- a/internal/moatinit/agents_phase_test.go +++ b/internal/moatinit/agents_phase_test.go @@ -197,6 +197,22 @@ func TestCopilotStaging(t *testing.T) { } } +func TestAgentStagingEmptyHomeStaysRootAnchored(t *testing.T) { + // The script builds "$TARGET_HOME/.codex" by concatenation, so an empty + // HOME yields the root-anchored "/.codex" — never a cwd-relative path. + // Under the injected root that absolute path is creatable, proving the + // destination stayed anchored. + ts := newTestSys(t, 1000, false) + staging := stageFile(t, ts, "mnt/codex-init", "config.toml", 0o644, "cfg") + ctx, _ := newTestContext(ts, Config{CodexInit: staging, Home: ""}) + if err := codexStagingPhase(ctx); err != nil { + t.Fatal(err) + } + if !exists(ts, "/.codex/config.toml") { + t.Error("empty HOME did not stage to the root-anchored /.codex") + } +} + func TestAgentBlockIndependence(t *testing.T) { // AGENT-BLOCK-INDEPENDENCE-ORDER: with only Codex set, .claude and // .gemini are absent; blocks are guarded solely by their own var. diff --git a/internal/moatinit/initfiles.go b/internal/moatinit/initfiles.go index 7113373f..b65aae60 100644 --- a/internal/moatinit/initfiles.go +++ b/internal/moatinit/initfiles.go @@ -128,15 +128,23 @@ func initFilesPhase(ctx *Context) error { return nil } -// decodeInitContent decodes a record's base64 payload (INIT-06). Go's -// StdEncoding decoder already ignores \r and \n like coreutils `base64 -d` -// (embedded newlines cannot occur here anyway — a newline would split the -// record), and rejects other non-alphabet bytes exactly as `base64 -d` -// rejects "invalid input". Decoding happens to a buffer BEFORE any file is -// touched, so an invalid payload aborts fail-closed without leaving a -// partial secret on disk (plan Appendix B P1; the shell's `base64 -d > -// "$filepath"` could leave a truncated file behind before aborting — the -// buffer-first port is the sanctioned hardening of that same fatal path). +// decodeInitContent decodes a record's base64 payload (INIT-06) with +// coreutils `base64 -d` acceptance semantics: newlines are tolerated +// anywhere (they cannot occur inside a record anyway — a newline splits the +// record), but carriage returns are INVALID INPUT — Go's decoder would +// silently strip \r where the shell fails closed (a CRLF-joined +// MOAT_INIT_FILES leaves a trailing \r on each payload), and the +// differential shell-parity harness pins that divergence. All other +// non-alphabet bytes are rejected exactly like `base64 -d`. +// +// Decoding happens to a buffer BEFORE any file is touched, so an invalid +// payload aborts fail-closed without leaving a partial secret on disk (plan +// Appendix B P1; the shell's `base64 -d > "$filepath"` can leave a +// truncated file behind before aborting — the buffer-first port is the +// sanctioned hardening of that same fatal path). func decodeInitContent(content string) ([]byte, error) { + if strings.ContainsRune(content, '\r') { + return nil, base64.CorruptInputError(strings.IndexByte(content, '\r')) + } return base64.StdEncoding.DecodeString(content) } diff --git a/internal/moatinit/initfiles_test.go b/internal/moatinit/initfiles_test.go index 85b7e553..989fdfc4 100644 --- a/internal/moatinit/initfiles_test.go +++ b/internal/moatinit/initfiles_test.go @@ -81,9 +81,15 @@ func TestDecodeInitContent(t *testing.T) { t.Errorf("decodeInitContent(\"\") = (%q, %v), want empty, nil", got, err) } - // Wrapped payloads (embedded CR/LF) decode like coreutils base64 -d. - if got, err := decodeInitContent("YW\r\nJj"); err != nil || string(got) != "abc" { - t.Errorf("wrapped payload = (%q, %v), want (abc, nil)", got, err) + // Newline-wrapped payloads decode like coreutils base64 -d... + if got, err := decodeInitContent("YW\nJj"); err != nil || string(got) != "abc" { + t.Errorf("newline-wrapped payload = (%q, %v), want (abc, nil)", got, err) + } + // ...but carriage returns are invalid input, exactly as base64 -d + // rejects them (a CRLF-joined record must fail closed, not silently + // decode where the shell would abort). + if _, err := decodeInitContent("YW\r\nJj"); err == nil { + t.Error("CR-tainted payload decoded; base64 -d rejects it") } // Companion: invalid base64 fails closed (the phase aborts before any diff --git a/internal/moatinit/phase_test.go b/internal/moatinit/phase_test.go index 0f2a82df..da18c07d 100644 --- a/internal/moatinit/phase_test.go +++ b/internal/moatinit/phase_test.go @@ -173,6 +173,33 @@ func TestExecDispatchEnvScrub(t *testing.T) { } } +func TestExecDispatchEmptyArgv(t *testing.T) { + // Non-root with no command: the script's bare `exec` is a no-op and the + // script simply ends — exit 0, nothing exec'd (and no panic). + ts := newTestSys(t, 1000, false) + ctx, _ := newTestContext(ts, Config{Home: "/tmp/h"}) + ctx.Argv = nil + err := execDispatchPhase(ctx) + if exit, ok := err.(exitError); !ok || exit.code != 0 { + t.Fatalf("err = %v, want exitError{0}", err) + } + if len(ts.execs) != 0 { + t.Error("empty argv still exec'd something on the non-root path") + } + + // Root+moatuser with no command: `exec gosu moatuser` runs and gosu + // itself reports the missing command (parity — Go does not pre-empt it). + ts2 := newTestSys(t, 0, true) + ctx2, _ := newTestContext(ts2, Config{Home: "/root"}) + ctx2.Argv = nil + if err := execDispatchPhase(ctx2); err != errHandoffComplete { + t.Fatalf("err = %v, want handoff to gosu", err) + } + if got := strings.Join(ts2.execs[0].argv, " "); got != "gosu moatuser" { + t.Errorf("argv = %q, want bare 'gosu moatuser'", got) + } +} + func TestExecDispatchExecFailureCodes(t *testing.T) { // Shell parity for a failed exec: 127 when the command is not found. ts := newTestSys(t, 1000, false) diff --git a/internal/moatinit/shellparity_test.go b/internal/moatinit/shellparity_test.go new file mode 100644 index 00000000..cb69a4e9 --- /dev/null +++ b/internal/moatinit/shellparity_test.go @@ -0,0 +1,565 @@ +//go:build linux + +package moatinit + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "testing" +) + +// This file is a differential pressure harness: it runs the REAL +// internal/deps/scripts/moat-init.sh and the REAL compiled Go entrypoint +// side by side against crafted environments and compares observable +// behavior — exit codes, contract stderr, the child-visible environment, +// and the resulting file trees (paths, types, modes, content). +// +// It covers the non-root branch only (the test process is not root; the +// root/gosu legs are the e2e parity harness's job) and avoids every phase +// that touches shared absolute paths (/etc/hosts writes, /run/moat/ssh, +// /workspace/.mcp.json, populate) except where the phase's own guard makes +// the case side-effect-free (root guards, malformed-entry skips, +// resolve-before-write failures). + +var ( + buildOnce sync.Once + goBinPath string + buildErr error +) + +func builtGoEntrypoint(t *testing.T) string { + t.Helper() + buildOnce.Do(func() { + dir, err := os.MkdirTemp("", "moat-init-parity") + if err != nil { + buildErr = err + return + } + goBinPath = filepath.Join(dir, "moat-commit") + cmd := exec.Command("go", "build", "-o", goBinPath, "github.com/majorcontext/moat/cmd/moat-init") + if out, err := cmd.CombinedOutput(); err != nil { + buildErr = fmt.Errorf("building entrypoint: %v\n%s", err, out) + } + }) + if buildErr != nil { + t.Fatal(buildErr) + } + return goBinPath +} + +func scriptPath(t *testing.T) string { + t.Helper() + p, err := filepath.Abs("../deps/scripts/moat-init.sh") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p); err != nil { + t.Fatalf("moat-init.sh not found: %v", err) + } + return p +} + +// legResult is one implementation's observable outcome. +type legResult struct { + exit int + stdout string + stderr string + tree string // normalized listing of the leg's HOME +} + +// runLeg executes one implementation with an isolated HOME and the given +// MOAT_* env, returning the observable outcome. baseEnv entries may contain +// the placeholder @HOME@ which is substituted with the leg's home dir. +func runLeg(t *testing.T, argv []string, home string, env map[string]string, cmdArgs []string) legResult { + t.Helper() + full := append(append([]string{}, argv...), cmdArgs...) + cmd := exec.Command(full[0], full[1:]...) + cmd.Dir = home // deterministic cwd for both legs + cmd.Env = []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + home, + } + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+strings.ReplaceAll(v, "@HOME@", home)) + } + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if err != nil { + var exitErr *exec.ExitError + if ok := errAs(err, &exitErr); ok { + code = exitErr.ExitCode() + } else { + t.Fatalf("running %v: %v", full, err) + } + } + return legResult{exit: code, stdout: stdout.String(), stderr: stderr.String(), tree: treeListing(t, home)} +} + +func errAs(err error, target **exec.ExitError) bool { + e, ok := err.(*exec.ExitError) + if ok { + *target = e + } + return ok +} + +// treeListing renders a home dir as "relpath type mode sha256[:12]" lines, +// mtime-free and root-relative so two legs' trees compare byte-for-byte. +func treeListing(t *testing.T, root string) string { + t.Helper() + var lines []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(root, p) + if rel == "." { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + switch { + case d.Type()&fs.ModeSymlink != 0: + dest, _ := os.Readlink(p) + lines = append(lines, fmt.Sprintf("%s symlink -> %s", rel, dest)) + case d.IsDir(): + lines = append(lines, fmt.Sprintf("%s dir %o", rel, info.Mode().Perm())) + default: + data, err := os.ReadFile(p) + if err != nil { + return err + } + sum := sha256.Sum256(data) + lines = append(lines, fmt.Sprintf("%s file %o %s", rel, info.Mode().Perm(), hex.EncodeToString(sum[:])[:12])) + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +// normalizeChildEnv filters an `env` dump down to comparable lines: the +// shell leg adds PWD/SHLVL/OLDPWD/_ that the exec'd-direct Go leg does not, +// and HOME differs per leg. +func normalizeChildEnv(out, home string) string { + keep := make([]string, 0, 16) + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + switch { + case strings.HasPrefix(line, "PWD="), strings.HasPrefix(line, "OLDPWD="), + strings.HasPrefix(line, "SHLVL="), strings.HasPrefix(line, "_="): + continue + } + keep = append(keep, strings.ReplaceAll(line, home, "@HOME@")) + } + sort.Strings(keep) + return strings.Join(keep, "\n") +} + +// stage writes a staging file with an explicit mode. +func stageParity(t *testing.T, dir, name string, mode os.FileMode, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } +} + +// parityCase is one differential scenario. +type parityCase struct { + name string + // setup stages shared fixtures and returns the MOAT_* env (values may + // use @HOME@) plus the user command. stderrExact compares stderr + // byte-for-byte; envCompare compares the normalized child `env` output + // (the command must then be []string{"env"}). + setup func(t *testing.T, shared string) (env map[string]string, cmd []string) + stderrExact bool + // stderrFramed compares stderr only from the framed "moat:" block on: + // a signal-killed hook makes the SHELL itself print a job-status line + // ("Terminated") before the framed message — incidental shell output, + // not part of the scripted contract. + stderrFramed bool + envCompare bool + wantExit int +} + +func TestShellGoDifferentialParity(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("no sh on PATH") + } + goBin := builtGoEntrypoint(t) + script := scriptPath(t) + xvfbPresent := func() bool { _, err := exec.LookPath("Xvfb"); return err == nil }() + + cases := []parityCase{ + { + name: "baseline env passthrough", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"SOME_USER_VAR": "kept"}, []string{"env"} + }, + envCompare: true, + }, + { + name: "claude staging full allowlist", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + staging := filepath.Join(shared, "claude-init") + stageParity(t, staging, "settings.json", 0o640, `{"s":1}`) + stageParity(t, staging, ".credentials.json", 0o644, `{"token":"x"}`) + stageParity(t, staging, "remote-settings.json", 0o644, `{"r":1}`) + stageParity(t, staging, "stats-cache.json", 0o644, `{}`) + stageParity(t, staging, "CLAUDE.md", 0o644, "ctx") + stageParity(t, staging, ".claude.json", 0o644, `{"ok":true}`) + stageParity(t, filepath.Join(staging, "statsig"), "cache.db", 0o600, "st") + stageParity(t, staging, "stray.txt", 0o644, "must not copy") + stageParity(t, staging, "mcp.json", 0o644, "not on claude allowlist") + return map[string]string{"MOAT_CLAUDE_INIT": staging}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "codex and gemini staging modes", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + codex := filepath.Join(shared, "codex-init") + stageParity(t, codex, "config.toml", 0o644, "cfg") + stageParity(t, codex, "auth.json", 0o644, `{"k":"v"}`) + stageParity(t, codex, "AGENTS.md", 0o644, "agents") + gemini := filepath.Join(shared, "gemini-init") + stageParity(t, gemini, "settings.json", 0o640, `{}`) + stageParity(t, gemini, "oauth_creds.json", 0o644, `{}`) + return map[string]string{"MOAT_CODEX_INIT": codex, "MOAT_GEMINI_INIT": gemini}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "copilot staging", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + st := filepath.Join(shared, "copilot-init") + stageParity(t, st, "config.json", 0o644, `{}`) + stageParity(t, st, "settings.json", 0o600, `{}`) + stageParity(t, st, "permissions-config.json", 0o644, `{}`) + return map[string]string{"MOAT_COPILOT_INIT": st}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "init files multi-record deep chain + env scrub", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + rec := func(path, content string) string { + return path + "\t" + base64.StdEncoding.EncodeToString([]byte(content)) + } + records := rec("@HOME@/.config/deep/nested/tool/config.toml", "secret-one") + "\n" + + rec("@HOME@/.toolrc", "secret-two") + "\n" + return map[string]string{"MOAT_INIT_FILES": records}, []string{"env"} + }, + envCompare: true, + }, + { + name: "clipboard exports DISPLAY", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + if xvfbPresent { + t.Skip("Xvfb installed; skipping to avoid spawning a real X server") + } + return map[string]string{"MOAT_CLIPBOARD": "1"}, []string{"env"} + }, + envCompare: true, + }, + { + name: "git system config identical", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + return map[string]string{ + "GIT_CONFIG_SYSTEM": "@HOME@/system-gitconfig", + "MOAT_GIT_USER_NAME": `Ada "quoted" Lovelace`, + "MOAT_GIT_USER_EMAIL": "ada@example.com", + "MOAT_GIT_SSH_GITHUB": "1", + }, []string{"true"} + }, + stderrExact: true, + }, + { + name: "git insteadOf opt-out", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + return map[string]string{ + "GIT_CONFIG_SYSTEM": "@HOME@/system-gitconfig", + "MOAT_GIT_SSH_GITHUB": "0", + }, []string{"true"} + }, + stderrExact: true, + }, + { + name: "docker mutex fatal", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_DOCKER_DIND": "1", "MOAT_DOCKER_GID": "999"}, []string{"true"} + }, + stderrExact: true, + wantExit: 1, + }, + { + name: "dind silently skipped as non-root", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_DOCKER_DIND": "1"}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "populate root guard fatal", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_WORKSPACE_VOLUME": "1"}, []string{"true"} + }, + stderrExact: true, + wantExit: 1, + }, + { + name: "volume chown skipped as non-root", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_VOLUME_CHOWN": "/nonexistent/vol"}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "pre_run hook success writes marker", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_PRE_RUN": "echo hooked > \"$HOME/.hook-marker\""}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "pre_run hook failure passes literal 42", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_PRE_RUN": "echo doing-setup; exit 42"}, []string{"sh", "-c", "echo SHOULD-NOT-RUN"} + }, + stderrExact: true, + wantExit: 42, + }, + { + name: "pre_run hook signal-killed reports 143", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_PRE_RUN": "kill -TERM $$"}, []string{"true"} + }, + stderrFramed: true, + wantExit: 143, + }, + { + name: "pre_run whitespace-only hook runs", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_PRE_RUN": " "}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "extra hosts malformed entries all skipped", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return map[string]string{"MOAT_EXTRA_HOSTS": "moat-proxy: :1.2.3.4 foo x:x"}, []string{"true"} + }, + stderrExact: true, + }, + { + name: "exec exit code passthrough", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return nil, []string{"sh", "-c", "exit 9"} + }, + stderrExact: true, + wantExit: 9, + }, + { + name: "exec command not found is 127", + setup: func(t *testing.T, shared string) (map[string]string, []string) { + return nil, []string{"definitely-not-a-real-command-xyz"} + }, + wantExit: 127, // stderr wording is tool-generated and differs; codes must match + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + shared := t.TempDir() + env, cmdArgs := tc.setup(t, shared) + + homeSh, homeGo := t.TempDir(), t.TempDir() + sh := runLeg(t, []string{"sh", script}, homeSh, env, cmdArgs) + goL := runLeg(t, []string{goBin}, homeGo, env, cmdArgs) + + if sh.exit != goL.exit { + t.Errorf("exit codes diverge: sh=%d go=%d\nsh stderr:\n%s\ngo stderr:\n%s", + sh.exit, goL.exit, sh.stderr, goL.stderr) + } + if sh.exit != tc.wantExit { + t.Errorf("sh exit = %d, want %d (stderr:\n%s)", sh.exit, tc.wantExit, sh.stderr) + } + if tc.stderrExact { + shErr := strings.ReplaceAll(sh.stderr, homeSh, "@HOME@") + goErr := strings.ReplaceAll(goL.stderr, homeGo, "@HOME@") + if shErr != goErr { + t.Errorf("stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", shErr, goErr) + } + } + if tc.stderrFramed { + frame := func(s string) string { + idx := strings.Index(s, "moat: ") + if idx < 0 { + return s + } + return s[idx:] + } + if frame(sh.stderr) != frame(goL.stderr) { + t.Errorf("framed stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", + frame(sh.stderr), frame(goL.stderr)) + } + } + if tc.envCompare { + shEnv := normalizeChildEnv(sh.stdout, homeSh) + goEnv := normalizeChildEnv(goL.stdout, homeGo) + if shEnv != goEnv { + t.Errorf("child env diverges:\n--- sh ---\n%s\n--- go ---\n%s", shEnv, goEnv) + } + } + if sh.tree != goL.tree { + t.Errorf("home trees diverge:\n--- sh ---\n%s\n--- go ---\n%s", sh.tree, goL.tree) + } + }) + } +} + +// TestShellGoDifferentialResolveFailure is split out (≈10s of real retry +// budget across both legs) and skipped under -short: an unresolvable +// '@'-target must fail closed with the identical three-line error in both +// implementations. +func TestShellGoDifferentialResolveFailure(t *testing.T) { + if testing.Short() { + t.Skip("10s retry budget; skipped with -short") + } + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not installed (script leg needs it)") + } + goBin := builtGoEntrypoint(t) + script := scriptPath(t) + env := map[string]string{"MOAT_EXTRA_HOSTS": "moat-proxy:@nope.invalid"} + + sh := runLeg(t, []string{"sh", script}, t.TempDir(), env, []string{"true"}) + goL := runLeg(t, []string{goBin}, t.TempDir(), env, []string{"true"}) + + if sh.exit != 1 || goL.exit != 1 { + t.Fatalf("exits: sh=%d go=%d, want 1/1", sh.exit, goL.exit) + } + if sh.stderr != goL.stderr { + t.Errorf("stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", sh.stderr, goL.stderr) + } +} + +// TestShellGoInitFilesInvalidBase64 pins the one sanctioned residue +// divergence on the invalid-payload fatal: both implementations abort +// non-zero before exec, the shell leaves a truncated/empty file behind +// (redirect-then-decode), the Go port decodes to a buffer first and leaves +// nothing. +func TestShellGoInitFilesInvalidBase64(t *testing.T) { + goBin := builtGoEntrypoint(t) + script := scriptPath(t) + env := map[string]string{"MOAT_INIT_FILES": "@HOME@/.sec/cfg\t!!!not-base64!!!"} + + homeSh, homeGo := t.TempDir(), t.TempDir() + sh := runLeg(t, []string{"sh", script}, homeSh, env, []string{"sh", "-c", "echo SHOULD-NOT-RUN"}) + goL := runLeg(t, []string{goBin}, homeGo, env, []string{"sh", "-c", "echo SHOULD-NOT-RUN"}) + + if sh.exit == 0 || goL.exit == 0 { + t.Fatalf("invalid base64 must be fatal: sh=%d go=%d", sh.exit, goL.exit) + } + for name, r := range map[string]legResult{"sh": sh, "go": goL} { + if strings.Contains(r.stdout, "SHOULD-NOT-RUN") { + t.Errorf("%s leg exec'd the command after a fatal init-files record", name) + } + } + // Shell residue: the redirect truncates the file before base64 fails. + if _, err := os.Stat(filepath.Join(homeSh, ".sec/cfg")); err != nil { + t.Errorf("expected the shell leg's partial file (documents the baseline): %v", err) + } + // Go: decode-to-buffer leaves nothing (sanctioned hardening, plan B-P1). + if _, err := os.Stat(filepath.Join(homeGo, ".sec/cfg")); !os.IsNotExist(err) { + t.Error("go leg left a partial secret file behind") + } +} + +// TestSplitInitRecordMatchesLiveShell differentially checks the record +// splitter against the script's actual `IFS= read -r` loop for a +// corpus of adversarial record shapes. +func TestSplitInitRecordMatchesLiveShell(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("no sh on PATH") + } + corpus := []string{ + "a\tXX", "\tXX", "\t\tXX", "a", "a\tb\tc", "a\t\tb", "a\tb\t", + "a\tb\t\t", "a\t", "", "path with space\tQUJD", "\t", "\t\t", + "a\tb c d", "üñï\tßase64", "a\t b", " a\tb", "a \tb", + } + for _, line := range corpus { + gotPath, gotContent := splitInitRecord(line) + // \036 (record separator, octal — portable in POSIX printf, unlike + // \xHH) delimits the two captured fields. + out, err := exec.Command("sh", "-c", + `printf '%s\n' "$1" | while IFS="$(printf '\t')" read -r filepath content; do printf '%s\036%s' "$filepath" "$content"; done`, + "sh", line).Output() + if err != nil { + t.Fatalf("shell probe for %q: %v", line, err) + } + wantPath, wantContent := "", "" + if parts := strings.SplitN(string(out), "\x1e", 2); len(parts) == 2 { + wantPath, wantContent = parts[0], parts[1] + } + if gotPath != wantPath || gotContent != wantContent { + t.Errorf("splitInitRecord(%q) = (%q, %q); live sh read gives (%q, %q)", + line, gotPath, gotContent, wantPath, wantContent) + } + } +} + +// TestDecodeInitContentMatchesCoreutils differentially checks base64 +// accept/reject parity against `base64 -d` for a corpus of payload shapes. +func TestDecodeInitContentMatchesCoreutils(t *testing.T) { + if _, err := exec.LookPath("base64"); err != nil { + t.Skip("base64 not installed") + } + corpus := []string{ + "", "YWJj", "YW\r\nJj", "YWJjZA==", "YWJjZA=", "!!!", "YWJj ", " YWJj", + "Y W J j", "====", "AA==", strings.Repeat("QUJDREVGRw==", 1), + } + for _, payload := range corpus { + goBytes, goErr := decodeInitContent(payload) + cmd := exec.Command("base64", "-d") + cmd.Stdin = strings.NewReader(payload) + shBytes, shErr := cmd.Output() + if (goErr == nil) != (shErr == nil) { + t.Errorf("decode divergence for %q: go err=%v, base64 -d err=%v", payload, goErr, shErr) + continue + } + if goErr == nil && string(goBytes) != string(shBytes) { + t.Errorf("decoded bytes diverge for %q: go=%q sh=%q", payload, goBytes, shBytes) + } + } +} From f2d76ed48c5149c48cc05bb917c2d185e917d4ac Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Thu, 16 Jul 2026 15:18:57 +0000 Subject: [PATCH 14/17] refactor(deps): name the shipped Go entrypoint binary moat-init Correcting the earlier moat-commit rename (a typo for the intended name). The Go implementation is now installed at /usr/local/bin/moat-init, which matches its cmd/moat-init source package and is the path the entrypoint takes over directly at cutover. Because the migration-window dispatcher had occupied that path, the three in-image pieces are re-laid-out: /usr/local/bin/moat-init Go entrypoint (was moat-commit) /usr/local/bin/moat-init.sh legacy shell entrypoint (was moat-init-sh) /usr/local/bin/moat-init-dispatch dispatcher, the ENTRYPOINT (was moat-init) The dispatcher content change re-keys the image cache on its own (the tag folds in the dispatcher bytes), so no additional salt bump is needed. At cutover the dispatcher and shell are dropped and the ENTRYPOINT becomes /usr/local/bin/moat-init directly. Updated writeEntrypoint, the dispatcher script, the dispatch/entrypoint tests, the differential harness's temp binary name, the volume-ownership-helper comment (now path-agnostic), docs, and the CHANGELOG. --- CHANGELOG.md | 2 +- docs/content/concepts/01-sandboxing.md | 4 +-- internal/container/docker.go | 6 ++--- internal/deps/builder.go | 7 ++--- internal/deps/dockerfile.go | 30 +++++++++++---------- internal/deps/moat_init_dispatch_test.go | 20 +++++++------- internal/deps/registry.go | 4 +-- internal/deps/scripts/moat-init-dispatch.sh | 8 +++--- internal/moatinit/shellparity_test.go | 2 +- 9 files changed, 43 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28f8867..c384b08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added -- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-commit --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) diff --git a/docs/content/concepts/01-sandboxing.md b/docs/content/concepts/01-sandboxing.md index bd4b0afc..f7aa1444 100644 --- a/docs/content/concepts/01-sandboxing.md +++ b/docs/content/concepts/01-sandboxing.md @@ -58,10 +58,10 @@ Every Moat container starts through the `moat-init` entrypoint, which runs befor Two implementations of the entrypoint ship in every image during the current migration window: the original shell script and a Go binary with identical behavior. A dispatcher selects between them; the shell implementation is the default. `MOAT_INIT_IMPL` and `MOAT_INIT_LEGACY` are reserved control variables managed by Moat itself — setting them in `moat.yaml` `env:` or via `-e` fails the run. -The Go implementation supports a dry-run: running `moat-commit --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: +The Go implementation supports a dry-run: running `moat-init --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: ```bash -moat exec -- /usr/local/bin/moat-commit --plan +moat exec -- /usr/local/bin/moat-init --plan ``` ## Limitations diff --git a/internal/container/docker.go b/internal/container/docker.go index 956d71f6..4938426c 100644 --- a/internal/container/docker.go +++ b/internal/container/docker.go @@ -236,9 +236,9 @@ func volumeOwnershipPlan(cfg Config) (helperMounts []mount.Mount, cmd []string, // volumeOwnershipHelperConfig builds the container config for the ownership helper. // -// The chown command goes in Entrypoint, NOT Cmd. moat-built images set -// ENTRYPOINT ["/usr/local/bin/moat-init"], which (running as root) drops to moatuser -// via gosu before exec'ing its arguments — so a Cmd-only helper would run chown as +// The chown command goes in Entrypoint, NOT Cmd. moat-built images set the +// moat-init entrypoint, which (running as root) drops to moatuser via gosu +// before exec'ing its arguments — so a Cmd-only helper would run chown as // moatuser (uid 5000), which lacks CAP_CHOWN, and fail with EPERM. Overriding // Entrypoint runs chown directly as the root container user. cfg.Image is the run // image (already pulled by CreateContainer's ensureImage) and has chown. diff --git a/internal/deps/builder.go b/internal/deps/builder.go index a9668486..b9f4ec71 100644 --- a/internal/deps/builder.go +++ b/internal/deps/builder.go @@ -126,9 +126,10 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { // The "moat-init-v2" label is a deliberate salt bump: images cached before // the dispatcher existed hashed only the script under the "moat-init" label, // and a warm-cache lookup must not resolve to a pre-dispatcher image that -// lacks the moat-commit/moat-init-sh split. Note this re-keys the `moat -// run` build/lookup path only — a workflow pinning a concrete moat/run: -// tag still resolves the old image and must re-tag/rebuild at cutover. +// lacks the dispatcher/moat-init.sh/moat-init split. Note this re-keys the +// `moat run` build/lookup path only — a workflow pinning a concrete +// moat/run: tag still resolves the old image and must re-tag/rebuild +// at cutover. func initHashComponent() string { h := sha256.New() h.Write([]byte(MoatInitScript)) diff --git a/internal/deps/dockerfile.go b/internal/deps/dockerfile.go index ce9722c3..19df27b2 100644 --- a/internal/deps/dockerfile.go +++ b/internal/deps/dockerfile.go @@ -618,28 +618,30 @@ func formatHookCommand(cmd string) string { // binary and COPY'd from the local build context — zero network at image // build time. // -// During the shell->Go migration window the ENTRYPOINT is a dispatcher that -// selects between the shell script (moat-init-sh, the default) and the Go -// binary (moat-commit) via the operator-only MOAT_INIT_IMPL / -// MOAT_INIT_LEGACY variables, so one cached image carries both -// implementations. The Go binary is arch-matched: run images are always -// built for the host's own architecture, so the runtime.GOARCH blob from -// internal/initbin is the right one. +// During the shell->Go migration window the ENTRYPOINT is a dispatcher +// (moat-init-dispatch) that selects between the legacy shell script +// (moat-init.sh, the default) and the Go binary (moat-init) via the +// operator-only MOAT_INIT_IMPL / MOAT_INIT_LEGACY variables, so one cached +// image carries both implementations. At cutover the dispatcher and shell +// are dropped and the ENTRYPOINT becomes /usr/local/bin/moat-init directly. +// The Go binary is arch-matched: run images are always built for the host's +// own architecture, so the runtime.GOARCH blob from internal/initbin is the +// right one. func writeEntrypoint(b *strings.Builder, opts *ImageSpec, dockerMode DockerMode, contextFiles map[string][]byte) { if opts.needsInit(dockerMode) { contextFiles["moat-init.sh"] = []byte(MoatInitScript) contextFiles["moat-init-dispatch.sh"] = []byte(MoatInitDispatcher) b.WriteString("# Moat initialization entrypoint (privilege drop + feature setup)\n") - b.WriteString("COPY moat-init-dispatch.sh /usr/local/bin/moat-init\n") - b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init-sh\n") - chmodPaths := "/usr/local/bin/moat-init /usr/local/bin/moat-init-sh" + b.WriteString("COPY moat-init-dispatch.sh /usr/local/bin/moat-init-dispatch\n") + b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init.sh\n") + chmodPaths := "/usr/local/bin/moat-init-dispatch /usr/local/bin/moat-init.sh" if goBin := initbin.Binary(); goBin != nil { - contextFiles["moat-commit"] = goBin - b.WriteString("COPY moat-commit /usr/local/bin/moat-commit\n") - chmodPaths += " /usr/local/bin/moat-commit" + contextFiles["moat-init"] = goBin + b.WriteString("COPY moat-init /usr/local/bin/moat-init\n") + chmodPaths += " /usr/local/bin/moat-init" } b.WriteString("RUN chmod +x " + chmodPaths + "\n") - b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n") + b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init-dispatch\"]\n") } else { b.WriteString(fmt.Sprintf("# Run as non-root user\nUSER %s\n", containerUser)) } diff --git a/internal/deps/moat_init_dispatch_test.go b/internal/deps/moat_init_dispatch_test.go index bc5fb745..ef2fdcfa 100644 --- a/internal/deps/moat_init_dispatch_test.go +++ b/internal/deps/moat_init_dispatch_test.go @@ -21,11 +21,11 @@ func TestWriteEntrypointDualShip(t *testing.T) { df := result.Dockerfile for _, want := range []string{ - "COPY moat-init-dispatch.sh /usr/local/bin/moat-init\n", - "COPY moat-init.sh /usr/local/bin/moat-init-sh\n", - "COPY moat-commit /usr/local/bin/moat-commit\n", - "RUN chmod +x /usr/local/bin/moat-init /usr/local/bin/moat-init-sh /usr/local/bin/moat-commit\n", - "ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n", + "COPY moat-init-dispatch.sh /usr/local/bin/moat-init-dispatch\n", + "COPY moat-init.sh /usr/local/bin/moat-init.sh\n", + "COPY moat-init /usr/local/bin/moat-init\n", + "RUN chmod +x /usr/local/bin/moat-init-dispatch /usr/local/bin/moat-init.sh /usr/local/bin/moat-init\n", + "ENTRYPOINT [\"/usr/local/bin/moat-init-dispatch\"]\n", } { if !strings.Contains(df, want) { t.Errorf("Dockerfile missing %q\nGenerated Dockerfile:\n%s", want, df) @@ -38,8 +38,8 @@ func TestWriteEntrypointDualShip(t *testing.T) { if got := string(result.ContextFiles["moat-init-dispatch.sh"]); got != MoatInitDispatcher { t.Error("context file moat-init-dispatch.sh does not carry MoatInitDispatcher") } - if got := result.ContextFiles["moat-commit"]; string(got) != string(initbin.Binary()) { - t.Error("context file moat-commit does not carry the arch-matched embedded binary") + if got := result.ContextFiles["moat-init"]; string(got) != string(initbin.Binary()) { + t.Error("context file moat-init does not carry the arch-matched embedded binary") } // Offline-build contract: the entrypoint must be materialized from @@ -63,7 +63,7 @@ func TestWriteEntrypointCompanionNoInit(t *testing.T) { if err != nil { t.Fatalf("GenerateDockerfile error: %v", err) } - for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-commit"} { + for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-init"} { if _, ok := result.ContextFiles[name]; ok { t.Errorf("context file %s present in a no-init image", name) } @@ -82,8 +82,8 @@ func TestDispatcherContract(t *testing.T) { for _, want := range []string{ `impl="${MOAT_INIT_IMPL:-sh}"`, "unset MOAT_INIT_IMPL MOAT_INIT_LEGACY", - "exec /usr/local/bin/moat-commit \"$@\"", - "exec /usr/local/bin/moat-init-sh \"$@\"", + "exec /usr/local/bin/moat-init \"$@\"", + "exec /usr/local/bin/moat-init.sh \"$@\"", "Error: invalid MOAT_INIT_IMPL", "Error: invalid MOAT_INIT_LEGACY", } { diff --git a/internal/deps/registry.go b/internal/deps/registry.go index 8ee656b9..c1f5d240 100644 --- a/internal/deps/registry.go +++ b/internal/deps/registry.go @@ -16,8 +16,8 @@ var MoatInitScript string // MoatInitDispatcher selects between the shell and Go entrypoint // implementations during the moat-init shell->Go migration window // (docs/plans/2026-07-01-moat-init-go-rewrite-plan.md). It is installed as -// /usr/local/bin/moat-init (the ENTRYPOINT); the script and the Go binary -// are installed next to it as moat-init-sh and moat-commit. +// /usr/local/bin/moat-init-dispatch (the ENTRYPOINT); the legacy script and +// the Go binary are installed next to it as moat-init.sh and moat-init. // //go:embed scripts/moat-init-dispatch.sh var MoatInitDispatcher string diff --git a/internal/deps/scripts/moat-init-dispatch.sh b/internal/deps/scripts/moat-init-dispatch.sh index 7dc1249d..b2b6da7c 100644 --- a/internal/deps/scripts/moat-init-dispatch.sh +++ b/internal/deps/scripts/moat-init-dispatch.sh @@ -2,8 +2,8 @@ # moat-init-dispatch.sh - Entrypoint dispatcher for the shell->Go migration. # # Selects which moat-init implementation runs as PID 1: -# /usr/local/bin/moat-init-sh - the original shell entrypoint -# /usr/local/bin/moat-commit - the Go entrypoint (cmd/moat-init) +# /usr/local/bin/moat-init.sh - the original shell entrypoint +# /usr/local/bin/moat-init - the Go entrypoint (cmd/moat-init) # # MOAT_INIT_IMPL and MOAT_INIT_LEGACY are operator-only controls injected by # the moat host binary; run.Create() rejects them in moat.yaml env and -e @@ -39,6 +39,6 @@ esac unset MOAT_INIT_IMPL MOAT_INIT_LEGACY if [ "$impl" = "go" ]; then - exec /usr/local/bin/moat-commit "$@" + exec /usr/local/bin/moat-init "$@" fi -exec /usr/local/bin/moat-init-sh "$@" +exec /usr/local/bin/moat-init.sh "$@" diff --git a/internal/moatinit/shellparity_test.go b/internal/moatinit/shellparity_test.go index cb69a4e9..6474e5d0 100644 --- a/internal/moatinit/shellparity_test.go +++ b/internal/moatinit/shellparity_test.go @@ -44,7 +44,7 @@ func builtGoEntrypoint(t *testing.T) string { buildErr = err return } - goBinPath = filepath.Join(dir, "moat-commit") + goBinPath = filepath.Join(dir, "moat-init") cmd := exec.Command("go", "build", "-o", goBinPath, "github.com/majorcontext/moat/cmd/moat-init") if out, err := cmd.CombinedOutput(); err != nil { buildErr = fmt.Errorf("building entrypoint: %v\n%s", err, out) From aedef65b2fcc15dd47db6df72086f003ee6b8e0c Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Fri, 17 Jul 2026 03:26:43 +0000 Subject: [PATCH 15/17] refactor(moatinit): remove the shell entrypoint and dispatcher; Go binary is the sole entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go moat-init entrypoint has been validated working, so the migration scaffolding is retired. Deletes: - internal/deps/scripts/moat-init.sh (611-line legacy entrypoint) - internal/deps/scripts/moat-init-dispatch.sh (the sh/go selector) - MoatInitScript / MoatInitDispatcher embeds - MOAT_INIT_IMPL / MOAT_INIT_LEGACY and the whole reserved-env guard (internal/run/envguard.go + operatorInitEnv), now dead with the vars gone writeEntrypoint now COPYs the arch-matched moat-init binary to /usr/local/bin/moat-init and sets it as the ENTRYPOINT directly — no dispatcher, no shell leg. The cache-key component drops to the binary hash under a bumped moat-init-v3 salt (so images cached under the v2 dual-ship scheme re-key once). Test surface follows the code: - deleted the strings.Contains(MoatInitScript, ...) shell-marker tests (git identity/proxy-auth/pre-run/volume-populate) — their behavior is covered by internal/moatinit unit + integration tests - the sh-vs-go differential harness (shellparity_test.go) is trimmed to the two live-tool oracles that don't need the shell entrypoint (IFS= read splitting vs sh; base64 accept/reject vs base64 -d) - the e2e parity harness becomes a single-impl acceptance harness (entrypoint_acceptance_test.go): runs the Go entrypoint in a real container and asserts privilege drop, staged file modes/ownership, env scrub, DISPLAY export, and Xvfb surviving the exec handoff - the deps dispatch test becomes moat_init_entrypoint_test.go Docs + CHANGELOG updated to describe a single Go entrypoint. Net: +509 / -2126 lines. --- CHANGELOG.md | 2 +- docs/content/concepts/01-sandboxing.md | 6 +- internal/deps/builder.go | 33 +- internal/deps/dockerfile.go | 38 +- internal/deps/dockerfile_test.go | 151 +---- internal/deps/moat_init_dispatch_test.go | 133 ---- internal/deps/moat_init_entrypoint_test.go | 95 +++ internal/deps/moat_init_volume_test.go | 51 +- internal/deps/registry.go | 12 - internal/deps/scripts/moat-init-dispatch.sh | 44 -- internal/deps/scripts/moat-init.sh | 641 -------------------- internal/e2e/entrypoint_acceptance_test.go | 343 +++++++++++ internal/e2e/entrypoint_parity_test.go | 357 ----------- internal/initbin/initbin.go | 13 +- internal/moatinit/doc.go | 6 +- internal/moatinit/phase.go | 2 +- internal/moatinit/shellparity_test.go | 506 +-------------- internal/run/envguard.go | 91 --- internal/run/envguard_test.go | 96 --- internal/run/manager_create.go | 11 - 20 files changed, 507 insertions(+), 2124 deletions(-) delete mode 100644 internal/deps/moat_init_dispatch_test.go create mode 100644 internal/deps/moat_init_entrypoint_test.go delete mode 100644 internal/deps/scripts/moat-init-dispatch.sh delete mode 100644 internal/deps/scripts/moat-init.sh create mode 100644 internal/e2e/entrypoint_acceptance_test.go delete mode 100644 internal/e2e/entrypoint_parity_test.go delete mode 100644 internal/run/envguard.go delete mode 100644 internal/run/envguard_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c384b08b..b102aece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added -- **Go container entrypoint (dual-shipped)** — the `moat-init` container entrypoint has been ported from a 611-line shell script to a Go binary (`internal/moatinit`) with behavioral parity as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. Both implementations ship in every image behind a dispatcher; the shell implementation remains the default this release while an e2e parity harness diffs full container-state manifests between the two. The Go entrypoint adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init --plan`). `MOAT_INIT_IMPL`/`MOAT_INIT_LEGACY` are reserved operator controls: setting them in `moat.yaml` `env:` or `-e` now fails the run. Image cache keys are re-salted so previously cached images rebuild with the dispatcher; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +- **Go container entrypoint** — the `moat-init` container entrypoint has been rewritten from a 611-line shell script to a Go binary (`internal/moatinit`, embedded and shipped as `/usr/local/bin/moat-init`), with the shell behavior as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. The rewrite lifts the entrypoint's logic (env parsing, branch/mode selection, ordering, error classification, exclude computation, privilege-drop selection) into unit-testable Go while still delegating the mechanical, security-sensitive steps to the audited tools already in the image (`gosu` for the privilege drop, `socat` for the SSH bridge, `tar` for the workspace copy). It adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init --plan`). Image cache keys are re-salted, so cached run images rebuild once; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) diff --git a/docs/content/concepts/01-sandboxing.md b/docs/content/concepts/01-sandboxing.md index f7aa1444..1f8a0c76 100644 --- a/docs/content/concepts/01-sandboxing.md +++ b/docs/content/concepts/01-sandboxing.md @@ -54,11 +54,9 @@ Both modes require Docker as the container runtime. Apple containers do not supp ## Container startup (the moat-init entrypoint) -Every Moat container starts through the `moat-init` entrypoint, which runs before your command to set up features declared in `moat.yaml`: `/etc/hosts` entries for the proxy, the SSH agent bridge, agent config staging (Claude/Codex/Gemini/Copilot), provider credential files, git configuration, Docker access, workspace volume population, and the `pre_run` hook. Its final act is dropping privileges (via `gosu`) to the non-root `moatuser` account and replacing itself with your command. +Every Moat container starts through the `moat-init` entrypoint (`/usr/local/bin/moat-init`), a small Go program that runs before your command to set up features declared in `moat.yaml`: `/etc/hosts` entries for the proxy, the SSH agent bridge, agent config staging (Claude/Codex/Gemini/Copilot), provider credential files, git configuration, Docker access, workspace volume population, and the `pre_run` hook. Its final act is dropping privileges (via `gosu`) to the non-root `moatuser` account and replacing itself with your command. -Two implementations of the entrypoint ship in every image during the current migration window: the original shell script and a Go binary with identical behavior. A dispatcher selects between them; the shell implementation is the default. `MOAT_INIT_IMPL` and `MOAT_INIT_LEGACY` are reserved control variables managed by Moat itself — setting them in `moat.yaml` `env:` or via `-e` fails the run. - -The Go implementation supports a dry-run: running `moat-init --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: +The entrypoint supports a dry-run: running `moat-init --plan` inside a container prints the ordered actions the entrypoint would take for the current environment — one line per decision — without performing any of them. This is useful when debugging why a feature did or did not activate: ```bash moat exec -- /usr/local/bin/moat-init --plan diff --git a/internal/deps/builder.go b/internal/deps/builder.go index b9f4ec71..999f0ae3 100644 --- a/internal/deps/builder.go +++ b/internal/deps/builder.go @@ -55,11 +55,11 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { hashInput += ",clipboard:xvfb" } - // When the moat-init entrypoint is used, hash its contents so that - // changes to any entrypoint piece invalidate cached images. Without this, - // users on runtimes without --add-host (Apple) can end up running stale - // images that lack critical initialization logic. Mirror the conditions - // in needsInit() plus any dep-driven DockerMode. + // When the moat-init entrypoint is used, hash the embedded binary so that + // a rebuilt entrypoint invalidates cached images. Without this, users on + // runtimes without --add-host (Apple) can end up running stale images + // that lack critical initialization logic. Mirror the conditions in + // needsInit() plus any dep-driven DockerMode. dockerModePresent := false for _, d := range deps { if d.DockerMode != "" { @@ -119,21 +119,16 @@ func ImageTag(deps []Dependency, opts *ImageSpec) string { } // initHashComponent is the cache-key contribution of the moat-init -// entrypoint. It covers every piece writeEntrypoint ships: the shell script, -// the dispatcher, and the embedded Go binary — so a change to any of them +// entrypoint: the SHA-256 of the embedded Go binary, so a rebuilt entrypoint // re-keys freshly built images. // -// The "moat-init-v2" label is a deliberate salt bump: images cached before -// the dispatcher existed hashed only the script under the "moat-init" label, -// and a warm-cache lookup must not resolve to a pre-dispatcher image that -// lacks the dispatcher/moat-init.sh/moat-init split. Note this re-keys the -// `moat run` build/lookup path only — a workflow pinning a concrete -// moat/run: tag still resolves the old image and must re-tag/rebuild -// at cutover. +// The "moat-init-v3" label is a deliberate salt bump so a warm-cache lookup +// cannot resolve to an image cached under an earlier scheme (v1 hashed the +// shell script; v2 hashed the script + dispatcher + binary during the +// dual-ship window). This re-keys the `moat run` build/lookup path only — a +// workflow pinning a concrete moat/run: tag still resolves the old +// image and must re-tag/rebuild. func initHashComponent() string { - h := sha256.New() - h.Write([]byte(MoatInitScript)) - h.Write([]byte(MoatInitDispatcher)) - h.Write(initbin.Binary()) - return "moat-init-v2:" + hex.EncodeToString(h.Sum(nil))[:8] + h := sha256.Sum256(initbin.Binary()) + return "moat-init-v3:" + hex.EncodeToString(h[:])[:8] } diff --git a/internal/deps/dockerfile.go b/internal/deps/dockerfile.go index 19df27b2..d13089f5 100644 --- a/internal/deps/dockerfile.go +++ b/internal/deps/dockerfile.go @@ -611,37 +611,27 @@ func formatHookCommand(cmd string) string { } // writeEntrypoint writes the entrypoint configuration and working directory. -// When the init entrypoint is needed, its pieces are added as context files -// and COPYed into the image. This avoids embedding large base64 blobs inline -// in a RUN command, which triggers gRPC transport errors in Apple's container -// builder. Everything is materialized from bytes embedded in the moat host -// binary and COPY'd from the local build context — zero network at image -// build time. +// When the init entrypoint is needed, the compiled moat-init binary +// (cmd/moat-init, embedded in internal/initbin) is added as a context file +// and COPYed to /usr/local/bin/moat-init as the image ENTRYPOINT. Copying a +// prebuilt binary rather than embedding a large base64 blob inline in a RUN +// command avoids the gRPC transport errors Apple's container builder hits; +// everything is materialized from bytes in the moat host binary and COPY'd +// from the local build context — zero network at image build time. // -// During the shell->Go migration window the ENTRYPOINT is a dispatcher -// (moat-init-dispatch) that selects between the legacy shell script -// (moat-init.sh, the default) and the Go binary (moat-init) via the -// operator-only MOAT_INIT_IMPL / MOAT_INIT_LEGACY variables, so one cached -// image carries both implementations. At cutover the dispatcher and shell -// are dropped and the ENTRYPOINT becomes /usr/local/bin/moat-init directly. -// The Go binary is arch-matched: run images are always built for the host's -// own architecture, so the runtime.GOARCH blob from internal/initbin is the -// right one. +// The binary is arch-matched: run images are always built for the host's own +// architecture, so the runtime.GOARCH blob from internal/initbin is the right +// one. On architectures moat does not build run images for, Binary() is nil +// and there is no entrypoint to install (such a host cannot run moat images). func writeEntrypoint(b *strings.Builder, opts *ImageSpec, dockerMode DockerMode, contextFiles map[string][]byte) { if opts.needsInit(dockerMode) { - contextFiles["moat-init.sh"] = []byte(MoatInitScript) - contextFiles["moat-init-dispatch.sh"] = []byte(MoatInitDispatcher) - b.WriteString("# Moat initialization entrypoint (privilege drop + feature setup)\n") - b.WriteString("COPY moat-init-dispatch.sh /usr/local/bin/moat-init-dispatch\n") - b.WriteString("COPY moat-init.sh /usr/local/bin/moat-init.sh\n") - chmodPaths := "/usr/local/bin/moat-init-dispatch /usr/local/bin/moat-init.sh" if goBin := initbin.Binary(); goBin != nil { contextFiles["moat-init"] = goBin + b.WriteString("# Moat initialization entrypoint (privilege drop + feature setup)\n") b.WriteString("COPY moat-init /usr/local/bin/moat-init\n") - chmodPaths += " /usr/local/bin/moat-init" + b.WriteString("RUN chmod +x /usr/local/bin/moat-init\n") + b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n") } - b.WriteString("RUN chmod +x " + chmodPaths + "\n") - b.WriteString("ENTRYPOINT [\"/usr/local/bin/moat-init-dispatch\"]\n") } else { b.WriteString(fmt.Sprintf("# Run as non-root user\nUSER %s\n", containerUser)) } diff --git a/internal/deps/dockerfile_test.go b/internal/deps/dockerfile_test.go index 44e0c0b5..b7694e70 100644 --- a/internal/deps/dockerfile_test.go +++ b/internal/deps/dockerfile_test.go @@ -2,10 +2,6 @@ package deps import ( - "errors" - "os" - "os/exec" - "runtime" "strings" "testing" @@ -250,17 +246,17 @@ func TestGenerateDockerfileWithSSH(t *testing.T) { t.Error("Dockerfile should install socat") } - // Check that the entrypoint script is COPYed (not inline base64) - if !strings.Contains(result.Dockerfile, "COPY moat-init.sh /usr/local/bin/moat-init") { - t.Error("Dockerfile should COPY moat-init script") + // Check that the entrypoint binary is COPYed (not inline base64) + if !strings.Contains(result.Dockerfile, "COPY moat-init /usr/local/bin/moat-init") { + t.Error("Dockerfile should COPY the moat-init entrypoint binary") } if !strings.Contains(result.Dockerfile, "ENTRYPOINT") { t.Error("Dockerfile should set ENTRYPOINT to moat-init") } - // Check that context files include the init script - if _, ok := result.ContextFiles["moat-init.sh"]; !ok { - t.Error("ContextFiles should include moat-init.sh") + // Check that context files include the entrypoint binary + if _, ok := result.ContextFiles["moat-init"]; !ok { + t.Error("ContextFiles should include moat-init") } } @@ -286,30 +282,30 @@ func TestGenerateDockerfileContextFiles(t *testing.T) { t.Fatalf("GenerateDockerfile error: %v", err) } - content, ok := result.ContextFiles["moat-init.sh"] + content, ok := result.ContextFiles["moat-init"] if !ok { - t.Fatal("ContextFiles should include moat-init.sh") + t.Fatal("ContextFiles should include moat-init") } if len(content) == 0 { - t.Error("moat-init.sh content should not be empty") + t.Error("moat-init content should not be empty") } - if !strings.Contains(result.Dockerfile, "COPY moat-init.sh /usr/local/bin/moat-init") { - t.Error("Dockerfile should COPY moat-init.sh") + if !strings.Contains(result.Dockerfile, "COPY moat-init /usr/local/bin/moat-init") { + t.Error("Dockerfile should COPY moat-init") } }) } - // No init script when none of the triggers are active + // No entrypoint binary when none of the triggers are active t.Run("NoInit", func(t *testing.T) { result, err := GenerateDockerfile(nil, nil) if err != nil { t.Fatalf("GenerateDockerfile error: %v", err) } - if _, ok := result.ContextFiles["moat-init.sh"]; ok { - t.Error("ContextFiles should not include moat-init.sh when no init is needed") + if _, ok := result.ContextFiles["moat-init"]; ok { + t.Error("ContextFiles should not include moat-init when no init is needed") } - if strings.Contains(result.Dockerfile, "COPY moat-init.sh") { - t.Error("Dockerfile should not COPY moat-init.sh when no init is needed") + if strings.Contains(result.Dockerfile, "COPY moat-init") { + t.Error("Dockerfile should not COPY moat-init when no init is needed") } }) } @@ -1733,121 +1729,6 @@ func TestGenerateDockerfileNonInteractiveDeps(t *testing.T) { } } -func TestMoatInitScriptGitIdentity(t *testing.T) { - // Verify the embedded moat-init.sh contains git identity setup - if !strings.Contains(MoatInitScript, "MOAT_GIT_USER_NAME") { - t.Error("moat-init.sh should handle MOAT_GIT_USER_NAME env var") - } - if !strings.Contains(MoatInitScript, "MOAT_GIT_USER_EMAIL") { - t.Error("moat-init.sh should handle MOAT_GIT_USER_EMAIL env var") - } - if !strings.Contains(MoatInitScript, "git config --system user.name") { - t.Error("moat-init.sh should set git user.name via --system config") - } - if !strings.Contains(MoatInitScript, "git config --system user.email") { - t.Error("moat-init.sh should set git user.email via --system config") - } -} - -func TestMoatInitScriptGitProxyAuth(t *testing.T) { - // HTTPS git through the moat proxy needs Basic proxy auth to survive the - // 407 CONNECT challenge (issue #370). - if !strings.Contains(MoatInitScript, "git config --system http.proxyAuthMethod basic") { - t.Error("moat-init.sh should set http.proxyAuthMethod=basic via --system config") - } - // SSH routing for github.com is still gated on both grants being present. - if !strings.Contains(MoatInitScript, `git config --system url."git@github.com:".insteadOf "https://github.com/"`) { - t.Error("moat-init.sh should keep the github.com SSH url.insteadOf rewrite") - } -} - -// extractShellFunc returns the source of a POSIX-shell function, from its -// "name() {" header to the closing "}" on its own line, within script. -func extractShellFunc(t *testing.T, script, name string) string { - t.Helper() - start := strings.Index(script, name+"() {") - if start < 0 { - t.Fatalf("function %q not found in script", name) - } - rest := script[start:] - end := strings.Index(rest, "\n}\n") - if end < 0 { - t.Fatalf("could not find end of function %q", name) - } - return rest[:end+2] // include the closing "}" -} - -// TestMoatInitPreRunHookBehavior runs the real run_pre_run_hook function from -// the embedded moat-init.sh and asserts its behavior (issue #372): a failing -// hook must report a framed error and exit with the hook's status (not abort -// the entrypoint silently), while a successful or absent hook continues. -func TestMoatInitPreRunHookBehavior(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("moat-init.sh is POSIX shell; not run on Windows") - } - if os.Geteuid() == 0 { - t.Skip("test exercises the non-root hook branch; running as root takes the gosu path") - } - - // The shipped function cd's into /workspace; point it at a writable temp - // dir so the test is portable. Only the path changes — the status capture, - // failure framing, and exit logic under test are the real script's. - fn := extractShellFunc(t, MoatInitScript, "run_pre_run_hook") - fn = strings.ReplaceAll(fn, "/workspace", t.TempDir()) - - run := func(preRun string) (string, int) { - t.Helper() - // Mirror the entrypoint's `set -e`; __CONTINUED__ prints only if the - // hook returned (i.e. did not exit), proving the main command would run. - harness := "set -e\n" + fn + "\nrun_pre_run_hook\necho __CONTINUED__\n" - cmd := exec.Command("sh", "-c", harness) - cmd.Env = append(os.Environ(), "MOAT_PRE_RUN="+preRun) - out, err := cmd.CombinedOutput() - if err == nil { - return string(out), 0 - } - var ee *exec.ExitError - if errors.As(err, &ee) { - return string(out), ee.ExitCode() - } - t.Fatalf("running hook harness: %v", err) - return "", -1 - } - - t.Run("failing hook is framed and exits with its status", func(t *testing.T) { - out, code := run("echo doing-setup; exit 42") - if code != 42 { - t.Errorf("exit code = %d, want 42\noutput:\n%s", code, out) - } - if !strings.Contains(out, "pre_run hook failed (exit code 42)") { - t.Errorf("missing framed failure message\noutput:\n%s", out) - } - if strings.Contains(out, "__CONTINUED__") { - t.Errorf("entrypoint continued past a failed hook\noutput:\n%s", out) - } - }) - - t.Run("successful hook continues to the command", func(t *testing.T) { - out, code := run("echo doing-setup") - if code != 0 { - t.Errorf("exit code = %d, want 0\noutput:\n%s", code, out) - } - if !strings.Contains(out, "__CONTINUED__") { - t.Errorf("entrypoint did not continue after a successful hook\noutput:\n%s", out) - } - if strings.Contains(out, "pre_run hook failed") { - t.Errorf("reported failure for a successful hook\noutput:\n%s", out) - } - }) - - t.Run("absent hook is a no-op", func(t *testing.T) { - out, code := run("") - if code != 0 || !strings.Contains(out, "__CONTINUED__") { - t.Errorf("unset MOAT_PRE_RUN should be a no-op; code=%d\noutput:\n%s", code, out) - } - }) -} - func TestImageSpecNeedsInit(t *testing.T) { tests := []struct { name string diff --git a/internal/deps/moat_init_dispatch_test.go b/internal/deps/moat_init_dispatch_test.go deleted file mode 100644 index ef2fdcfa..00000000 --- a/internal/deps/moat_init_dispatch_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package deps - -import ( - "crypto/sha256" - "encoding/hex" - "strings" - "testing" - - "github.com/majorcontext/moat/internal/initbin" -) - -// TestWriteEntrypointDualShip asserts the migration-window image layout: the -// dispatcher is the ENTRYPOINT at the original moat-init path, with the shell -// script and the Go binary installed next to it, all from local context files -// (no network fetch at image build time). -func TestWriteEntrypointDualShip(t *testing.T) { - result, err := GenerateDockerfile(nil, &ImageSpec{NeedsSSH: true}) - if err != nil { - t.Fatalf("GenerateDockerfile error: %v", err) - } - df := result.Dockerfile - - for _, want := range []string{ - "COPY moat-init-dispatch.sh /usr/local/bin/moat-init-dispatch\n", - "COPY moat-init.sh /usr/local/bin/moat-init.sh\n", - "COPY moat-init /usr/local/bin/moat-init\n", - "RUN chmod +x /usr/local/bin/moat-init-dispatch /usr/local/bin/moat-init.sh /usr/local/bin/moat-init\n", - "ENTRYPOINT [\"/usr/local/bin/moat-init-dispatch\"]\n", - } { - if !strings.Contains(df, want) { - t.Errorf("Dockerfile missing %q\nGenerated Dockerfile:\n%s", want, df) - } - } - - if got := string(result.ContextFiles["moat-init.sh"]); got != MoatInitScript { - t.Error("context file moat-init.sh does not carry MoatInitScript") - } - if got := string(result.ContextFiles["moat-init-dispatch.sh"]); got != MoatInitDispatcher { - t.Error("context file moat-init-dispatch.sh does not carry MoatInitDispatcher") - } - if got := result.ContextFiles["moat-init"]; string(got) != string(initbin.Binary()) { - t.Error("context file moat-init does not carry the arch-matched embedded binary") - } - - // Offline-build contract: the entrypoint must be materialized from - // embedded bytes, never fetched or compiled at image build time. - if strings.Contains(df, "curl") && strings.Contains(df, "moat-init") { - for _, line := range strings.Split(df, "\n") { - if strings.Contains(line, "moat-init") && strings.Contains(line, "curl") { - t.Errorf("entrypoint line fetches over the network: %q", line) - } - } - } - if strings.Contains(df, "FROM golang") { - t.Errorf("Dockerfile uses a golang build stage for the entrypoint:\n%s", df) - } -} - -// TestWriteEntrypointCompanionNoInit asserts the companion case: images that -// do not need moat-init get none of the entrypoint pieces. -func TestWriteEntrypointCompanionNoInit(t *testing.T) { - result, err := GenerateDockerfile(nil, nil) - if err != nil { - t.Fatalf("GenerateDockerfile error: %v", err) - } - for _, name := range []string{"moat-init.sh", "moat-init-dispatch.sh", "moat-init"} { - if _, ok := result.ContextFiles[name]; ok { - t.Errorf("context file %s present in a no-init image", name) - } - } - if strings.Contains(result.Dockerfile, "ENTRYPOINT") { - t.Errorf("no-init image should not set an ENTRYPOINT:\n%s", result.Dockerfile) - } -} - -// TestDispatcherContract pins the dispatcher's load-bearing properties: the -// closed MOAT_INIT_IMPL/MOAT_INIT_LEGACY enum (fatal on anything else), -// unsetting both before the handoff, and exec (never fork+wait) into the -// selected implementation. -func TestDispatcherContract(t *testing.T) { - d := MoatInitDispatcher - for _, want := range []string{ - `impl="${MOAT_INIT_IMPL:-sh}"`, - "unset MOAT_INIT_IMPL MOAT_INIT_LEGACY", - "exec /usr/local/bin/moat-init \"$@\"", - "exec /usr/local/bin/moat-init.sh \"$@\"", - "Error: invalid MOAT_INIT_IMPL", - "Error: invalid MOAT_INIT_LEGACY", - } { - if !strings.Contains(d, want) { - t.Errorf("dispatcher missing %q", want) - } - } - // The enum is read once, before any phase: the dispatcher must not - // invoke either implementation by any means other than exec. - if strings.Count(d, "exec ") != 2 { - t.Errorf("dispatcher should exec exactly twice (go leg, sh leg); got %d", strings.Count(d, "exec ")) - } -} - -// TestInitHashComponentReKeys asserts the cache-key salt bump: the moat-init -// component no longer matches the pre-dispatcher scheme (label or value), so -// images cached before the dual-ship cannot satisfy a post-dual-ship lookup. -func TestInitHashComponentReKeys(t *testing.T) { - comp := initHashComponent() - - if !strings.HasPrefix(comp, "moat-init-v2:") { - t.Fatalf("initHashComponent() = %q, want moat-init-v2: prefix", comp) - } - - // The pre-commit component was "moat-init:" + sha256(script)[:8]. Assert - // both directions: the old label is gone, and the new value is not the - // old value under a new name (it must fold in the dispatcher + binary). - oldHash := sha256.Sum256([]byte(MoatInitScript)) - oldValue := hex.EncodeToString(oldHash[:])[:8] - if strings.HasPrefix(comp, "moat-init:") { - t.Errorf("initHashComponent() = %q still uses the v1 label", comp) - } - if strings.HasSuffix(comp, oldValue) { - t.Errorf("initHashComponent() = %q hashes only the script; must include dispatcher + binary", comp) - } - - // And the tag itself changes for an init-bearing spec vs the v1 scheme. - tag := ImageTag(nil, &ImageSpec{NeedsSSH: true}) - oldInput := ",ssh:agent,moat-init:" + oldValue - oldTag := func() string { - h := sha256.Sum256([]byte(oldInput)) - return "moat/run:" + hex.EncodeToString(h[:])[:16] - }() - if tag == oldTag { - t.Error("ImageTag matches the pre-dispatcher tag; cache was not re-keyed") - } -} diff --git a/internal/deps/moat_init_entrypoint_test.go b/internal/deps/moat_init_entrypoint_test.go new file mode 100644 index 00000000..338fba80 --- /dev/null +++ b/internal/deps/moat_init_entrypoint_test.go @@ -0,0 +1,95 @@ +package deps + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/initbin" +) + +// TestWriteEntrypointGoBinary asserts the image layout: the compiled +// moat-init binary is COPYed from a local context file and set as the +// ENTRYPOINT, with no network fetch or build stage. +func TestWriteEntrypointGoBinary(t *testing.T) { + result, err := GenerateDockerfile(nil, &ImageSpec{NeedsSSH: true}) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + df := result.Dockerfile + + for _, want := range []string{ + "COPY moat-init /usr/local/bin/moat-init\n", + "RUN chmod +x /usr/local/bin/moat-init\n", + "ENTRYPOINT [\"/usr/local/bin/moat-init\"]\n", + } { + if !strings.Contains(df, want) { + t.Errorf("Dockerfile missing %q\nGenerated Dockerfile:\n%s", want, df) + } + } + + if got := result.ContextFiles["moat-init"]; string(got) != string(initbin.Binary()) { + t.Error("context file moat-init does not carry the arch-matched embedded binary") + } + + // Offline-build contract: the entrypoint is materialized from embedded + // bytes, never fetched or compiled at image build time. + if strings.Contains(df, "FROM golang") { + t.Errorf("Dockerfile uses a golang build stage for the entrypoint:\n%s", df) + } + for _, line := range strings.Split(df, "\n") { + if strings.Contains(line, "moat-init") && strings.Contains(line, "curl") { + t.Errorf("entrypoint line fetches over the network: %q", line) + } + } +} + +// TestWriteEntrypointCompanionNoInit asserts the companion case: images that +// do not need moat-init get no entrypoint binary and no ENTRYPOINT. +func TestWriteEntrypointCompanionNoInit(t *testing.T) { + result, err := GenerateDockerfile(nil, nil) + if err != nil { + t.Fatalf("GenerateDockerfile error: %v", err) + } + if _, ok := result.ContextFiles["moat-init"]; ok { + t.Error("context file moat-init present in a no-init image") + } + if strings.Contains(result.Dockerfile, "ENTRYPOINT") { + t.Errorf("no-init image should not set an ENTRYPOINT:\n%s", result.Dockerfile) + } +} + +// TestInitHashComponentReKeys asserts the cache-key salt bump: the moat-init +// component uses the v3 label and hashes the embedded binary, so a warm-cache +// lookup cannot resolve to an image cached under the v1 (script) or v2 +// (script+dispatcher+binary) scheme. +func TestInitHashComponentReKeys(t *testing.T) { + comp := initHashComponent() + if !strings.HasPrefix(comp, "moat-init-v3:") { + t.Fatalf("initHashComponent() = %q, want moat-init-v3: prefix", comp) + } + + // The component is the binary hash under the v3 label... + sum := sha256.Sum256(initbin.Binary()) + want := "moat-init-v3:" + hex.EncodeToString(sum[:])[:8] + if comp != want { + t.Errorf("initHashComponent() = %q, want %q", comp, want) + } + + // ...and the earlier scheme labels are gone (both directions of the + // drift guard: no v1 "moat-init:" prefix, no v2 label). + if strings.HasPrefix(comp, "moat-init:") || strings.HasPrefix(comp, "moat-init-v2:") { + t.Errorf("initHashComponent() = %q still uses a superseded label", comp) + } + + // A pre-cutover v2-scheme tag must not satisfy a current lookup. + tag := ImageTag(nil, &ImageSpec{NeedsSSH: true}) + oldV2 := func() string { + h := sha256.Sum256([]byte(",ssh:agent,moat-init-v2:deadbeef")) + return "moat/run:" + hex.EncodeToString(h[:])[:16] + }() + if tag == oldV2 { + t.Error("ImageTag matches a v2-scheme tag; cache was not re-keyed") + } +} diff --git a/internal/deps/moat_init_volume_test.go b/internal/deps/moat_init_volume_test.go index 6d9ffe6f..d6e17339 100644 --- a/internal/deps/moat_init_volume_test.go +++ b/internal/deps/moat_init_volume_test.go @@ -6,15 +6,14 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "testing" ) -// TestVolumeCopyInPipeline exercises the tar pipeline that -// populate_workspace_volume() in moat-init.sh uses. The script does NOT pass -// --no-dereference: GNU tar 1.34 (in the container image) does not recognize the -// long flag name, and symlink-preservation is already tar's default for `-cf`. -// See TestMoatInitNoNoDeref, which asserts the flag is absent from the script. +// TestVolumeCopyInPipeline exercises the tar pipeline that the moat-init +// entrypoint's populate-workspace-volume phase runs (internal/moatinit +// shells to tar for the byte copy). It does NOT pass --no-dereference: GNU +// tar 1.34 (in the container image) does not recognize the long flag name, +// and symlink-preservation is already tar's default for `-cf`. // // The pipeline under test: // @@ -134,46 +133,6 @@ func TestVolumeCopyInPipelineEmptyExcludes(t *testing.T) { } } -// TestMoatInitScriptVolumePopulate checks that the embedded moat-init.sh -// contains the key markers for the populate_workspace_volume function and that -// it runs before the privilege drop (so chown /workspace can succeed as root). -func TestMoatInitScriptVolumePopulate(t *testing.T) { - if !strings.Contains(MoatInitScript, "populate_workspace_volume") { - t.Error("moat-init.sh should define populate_workspace_volume") - } - if !strings.Contains(MoatInitScript, "MOAT_WORKSPACE_VOLUME") { - t.Error("moat-init.sh should guard on MOAT_WORKSPACE_VOLUME") - } - if !strings.Contains(MoatInitScript, "MOAT_WORKSPACE_STAGING") { - t.Error("moat-init.sh should reference MOAT_WORKSPACE_STAGING") - } - if !strings.Contains(MoatInitScript, "MOAT_WORKSPACE_EXCLUDES") { - t.Error("moat-init.sh should reference MOAT_WORKSPACE_EXCLUDES") - } - // The script must NOT pass --no-dereference: that long option only exists in - // GNU tar 1.35+, but the container base (debian bookworm) ships GNU tar 1.34, - // which rejects it and aborts the copy. Symlink preservation is tar's default, - // so no flag is needed (TestVolumeCopyInPipeline verifies symlinks survive). - if strings.Contains(MoatInitScript, "--no-dereference") { - t.Error("moat-init.sh must not use tar --no-dereference (unsupported by GNU tar 1.34 in the container base)") - } - if !strings.Contains(MoatInitScript, "chown -R moatuser:moatuser /workspace") { - t.Error("moat-init.sh should chown /workspace to moatuser") - } - - // The call/definition must come before the privilege drop (exec gosu - // moatuser), so the root-only chown -R /workspace runs before we drop to - // moatuser. Guards against a refactor that reorders the entrypoint. - popIdx := strings.Index(MoatInitScript, "populate_workspace_volume") - gosuIdx := strings.Index(MoatInitScript, "exec gosu moatuser") - if popIdx == -1 || gosuIdx == -1 { - t.Fatalf("missing markers: populate_workspace_volume=%d, exec gosu moatuser=%d", popIdx, gosuIdx) - } - if popIdx >= gosuIdx { - t.Errorf("populate_workspace_volume (index %d) must appear before the privilege drop 'exec gosu moatuser' (index %d)", popIdx, gosuIdx) - } -} - func mkdir(t *testing.T, p string) { t.Helper() if err := os.MkdirAll(p, 0o755); err != nil { diff --git a/internal/deps/registry.go b/internal/deps/registry.go index c1f5d240..9ef5a28f 100644 --- a/internal/deps/registry.go +++ b/internal/deps/registry.go @@ -10,18 +10,6 @@ import ( //go:embed registry.yaml var registryData []byte -//go:embed scripts/moat-init.sh -var MoatInitScript string - -// MoatInitDispatcher selects between the shell and Go entrypoint -// implementations during the moat-init shell->Go migration window -// (docs/plans/2026-07-01-moat-init-go-rewrite-plan.md). It is installed as -// /usr/local/bin/moat-init-dispatch (the ENTRYPOINT); the legacy script and -// the Go binary are installed next to it as moat-init.sh and moat-init. -// -//go:embed scripts/moat-init-dispatch.sh -var MoatInitDispatcher string - // registry holds all available dependencies. It is read-only after init(). var registry map[string]DepSpec diff --git a/internal/deps/scripts/moat-init-dispatch.sh b/internal/deps/scripts/moat-init-dispatch.sh deleted file mode 100644 index b2b6da7c..00000000 --- a/internal/deps/scripts/moat-init-dispatch.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/sh -# moat-init-dispatch.sh - Entrypoint dispatcher for the shell->Go migration. -# -# Selects which moat-init implementation runs as PID 1: -# /usr/local/bin/moat-init.sh - the original shell entrypoint -# /usr/local/bin/moat-init - the Go entrypoint (cmd/moat-init) -# -# MOAT_INIT_IMPL and MOAT_INIT_LEGACY are operator-only controls injected by -# the moat host binary; run.Create() rejects them in moat.yaml env and -e -# flags (they select a security-critical PID 1, so a user-settable switch -# would be an attack surface). Both are read exactly once, here, before any -# phase runs, and are never re-read after user-controlled code executes. -# They are unset before the handoff so the selected implementation and the -# user command see the same environment they would without the dispatcher. -# -# Closed enum, fatal on anything else: a typo must fail loudly, not fall -# back to an unintended entrypoint. -set -e - -impl="${MOAT_INIT_IMPL:-sh}" - -case "${MOAT_INIT_LEGACY:-}" in - "") ;; - 1) impl=sh ;; - *) - echo "Error: invalid MOAT_INIT_LEGACY '${MOAT_INIT_LEGACY}' (expected '1' or unset)" >&2 - exit 1 - ;; -esac - -case "$impl" in - sh|go) ;; - *) - echo "Error: invalid MOAT_INIT_IMPL '${MOAT_INIT_IMPL}' (expected 'sh' or 'go')" >&2 - exit 1 - ;; -esac - -unset MOAT_INIT_IMPL MOAT_INIT_LEGACY - -if [ "$impl" = "go" ]; then - exec /usr/local/bin/moat-init "$@" -fi -exec /usr/local/bin/moat-init.sh "$@" diff --git a/internal/deps/scripts/moat-init.sh b/internal/deps/scripts/moat-init.sh deleted file mode 100644 index eccd7525..00000000 --- a/internal/deps/scripts/moat-init.sh +++ /dev/null @@ -1,641 +0,0 @@ -#!/bin/sh -# moat-init.sh - Container initialization script -# This script runs before the user's command to set up moat features. -# Features are enabled via environment variables. -# -# When running as root, this script performs privileged setup (SSH socket), -# then drops to moatuser for command execution. When already running as a -# non-root user (e.g., on Linux with host UID mapping), it skips privilege -# dropping since the user is already non-root. - -set -e - -# Configuration constants -SSH_SOCKET_WAIT_ITERS=20 # iterations * 0.1s = 2 second timeout for SSH socket -DIND_TIMEOUT_SECONDS=30 # timeout for Docker daemon startup in dind mode -MOAT_DNS_WAIT_ITERS=25 # iterations * 0.2s = 5 second timeout for container DNS - -# Synthetic Host Entries -# When MOAT_EXTRA_HOSTS is set, append space-separated "name:target" pairs to -# /etc/hosts. Used on runtimes where the host side cannot supply a usable IP -# via --add-host: Apple containers (no such flag) and Docker Desktop on -# macOS/Windows (host-gateway resolves to the docker0 bridge, which is -# unreachable from custom bridge networks created for services). -# -# target may be a literal IP (e.g. "192.168.64.1") or a hostname prefixed -# with "@" (e.g. "@host.docker.internal"). The "@" form tells us to resolve -# the hostname via the container's DNS at startup — this is how we reach -# Docker Desktop's host, which is only addressable by the container-only -# DNS name host.docker.internal. DNS resolution is retried briefly because -# Docker Desktop's embedded DNS may not be ready the instant the ENTRYPOINT -# runs. -# -# Must run before any process that resolves these hostnames (e.g. the user's -# command, which sees MOAT_HOST_GATEWAY). -# -# Fail-closed: if we cannot resolve the target or write to /etc/hosts, we -# must not start the user command. Silent failure would leave -# moat-proxy/moat-host unresolvable, HTTP_PROXY broken, and network policy -# silently degraded. The clear error here is preferable to a seemingly -# working container that bypasses policy. -if [ -n "$MOAT_EXTRA_HOSTS" ]; then - for entry in $MOAT_EXTRA_HOSTS; do - name=${entry%%:*} - target=${entry#*:} - if [ -z "$name" ] || [ -z "$target" ] || [ "$name" = "$target" ]; then - continue - fi - - case "$target" in - @*) - hostname=${target#@} - ip="" - i=0 - while [ "$i" -lt "$MOAT_DNS_WAIT_ITERS" ]; do - # Prefer IPv4 because the host is reached via Docker Desktop's - # IPv4-only mapping; an IPv6 entry like "::1" would resolve to the - # container's own loopback and silently not reach the host. Fall - # back to any address if the name has only IPv6 records. - candidate=$(getent ahostsv4 "$hostname" 2>/dev/null | awk '{print $1; exit}') - if [ -z "$candidate" ]; then - candidate=$(getent hosts "$hostname" 2>/dev/null | awk '{print $1; exit}') - fi - if [ -n "$candidate" ]; then - ip="$candidate" - break - fi - sleep 0.2 - i=$((i + 1)) - done - if [ -z "$ip" ]; then - echo "Error: moat-init.sh could not resolve '$hostname' for /etc/hosts entry '$name'." >&2 - echo "The container's DNS should answer this name. On Docker Desktop, verify that" >&2 - echo "'getent hosts $hostname' works inside this container." >&2 - exit 1 - fi - ;; - *) - ip=$target - ;; - esac - - if ! printf '%s %s\n' "$ip" "$name" >> /etc/hosts 2>/dev/null; then - echo "Error: moat-init.sh cannot write $name to /etc/hosts (required for moat proxy resolution)." >&2 - echo "The container user (UID $(id -u)) lacks permission to modify /etc/hosts." >&2 - echo "Rebuild the base image so moat-init.sh runs as root, or grant CAP_DAC_OVERRIDE." >&2 - exit 1 - fi - done -fi - -# SSH Agent Bridge -# When MOAT_SSH_TCP_ADDR is set, create a Unix socket that bridges to the -# TCP-based SSH agent proxy running on the host. This is needed for Docker -# on macOS where Unix sockets can't be shared via bind mounts. -if [ -n "$MOAT_SSH_TCP_ADDR" ]; then - # Create socket directory - may need root for /run - mkdir -p /run/moat/ssh 2>/dev/null || true - if [ -d /run/moat/ssh ]; then - # Set directory permissions so moatuser can access it - chmod 755 /run/moat/ssh 2>/dev/null || true - if id moatuser >/dev/null 2>&1; then - chown moatuser:moatuser /run/moat/ssh 2>/dev/null || true - fi - # Start socat to bridge TCP to Unix socket - # Socket created with mode 0660 - accessible by owner and group only - socat UNIX-LISTEN:/run/moat/ssh/agent.sock,fork,mode=0660 TCP:"$MOAT_SSH_TCP_ADDR" & - SOCAT_PID=$! - # Wait for socket to be created (SSH_SOCKET_WAIT_ITERS * 0.1s timeout) - i=0 - while [ "$i" -lt "$SSH_SOCKET_WAIT_ITERS" ]; do - [ -S /run/moat/ssh/agent.sock ] && break - sleep 0.1 - i=$((i + 1)) - done - # Verify socat is still running and socket was created - if ! kill -0 "$SOCAT_PID" 2>/dev/null; then - echo "Warning: SSH agent bridge (socat) failed to start" >&2 - elif [ ! -S /run/moat/ssh/agent.sock ]; then - echo "Warning: SSH agent socket was not created after 2s" >&2 - else - # Ensure socket is owned by moatuser if it exists - if id moatuser >/dev/null 2>&1; then - chown moatuser:moatuser /run/moat/ssh/agent.sock 2>/dev/null || true - fi - fi - fi -fi - -# Claude Code Setup -# When MOAT_CLAUDE_INIT is set to the staging directory path, copy files -# from the staging area to their final locations. This is needed because: -# 1. Apple containers only support directory mounts, not file mounts -# 2. We need ~/.claude to be a real directory so projects/ can be mounted inside it -# -# IMPORTANT: We determine the target home directory based on whether we'll drop -# privileges to moatuser. If running as root with moatuser available, files go -# to /home/moatuser. Otherwise, files go to the current $HOME. -if [ -n "$MOAT_CLAUDE_INIT" ] && [ -d "$MOAT_CLAUDE_INIT" ]; then - # Determine target home directory - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - TARGET_HOME="/home/moatuser" - else - TARGET_HOME="$HOME" - fi - - # Create ~/.claude directory - mkdir -p "$TARGET_HOME/.claude" - - # Copy settings.json if present (preserve permissions) - [ -f "$MOAT_CLAUDE_INIT/settings.json" ] && \ - cp -p "$MOAT_CLAUDE_INIT/settings.json" "$TARGET_HOME/.claude/" - - # Plugins are baked into the image at build time via `claude plugin install` - # in the Dockerfile. The settings.json written above provides the marketplace - # config so Claude Code knows about enabled plugins and their sources at runtime. - - # Copy credentials if present (ensure restricted permissions for security) - if [ -f "$MOAT_CLAUDE_INIT/.credentials.json" ]; then - cp -p "$MOAT_CLAUDE_INIT/.credentials.json" "$TARGET_HOME/.claude/" - chmod 600 "$TARGET_HOME/.claude/.credentials.json" - fi - - # Copy remote-settings.json if present (server-managed settings cache) - # This prevents Claude Code from prompting for managed settings approval - # on every container startup by providing the cached approval state. - if [ -f "$MOAT_CLAUDE_INIT/remote-settings.json" ]; then - cp -p "$MOAT_CLAUDE_INIT/remote-settings.json" "$TARGET_HOME/.claude/" - chmod 600 "$TARGET_HOME/.claude/remote-settings.json" - fi - - # Copy statsig directory if present (feature flags, preserve permissions) - [ -d "$MOAT_CLAUDE_INIT/statsig" ] && \ - cp -rp "$MOAT_CLAUDE_INIT/statsig" "$TARGET_HOME/.claude/" - - # Copy stats-cache.json if present (usage stats, preserve permissions) - [ -f "$MOAT_CLAUDE_INIT/stats-cache.json" ] && \ - cp -p "$MOAT_CLAUDE_INIT/stats-cache.json" "$TARGET_HOME/.claude/" - - # Copy CLAUDE.md if present (runtime context for agent awareness) - [ -f "$MOAT_CLAUDE_INIT/CLAUDE.md" ] && \ - cp -p "$MOAT_CLAUDE_INIT/CLAUDE.md" "$TARGET_HOME/.claude/" - - # Copy .claude.json to home directory (onboarding state, preserve permissions) - [ -f "$MOAT_CLAUDE_INIT/.claude.json" ] && \ - cp -p "$MOAT_CLAUDE_INIT/.claude.json" "$TARGET_HOME/" - - # Ensure moatuser owns all the files if we're running as root - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown -R moatuser:moatuser "$TARGET_HOME/.claude" 2>/dev/null || true - [ -f "$TARGET_HOME/.claude.json" ] && chown moatuser:moatuser "$TARGET_HOME/.claude.json" 2>/dev/null || true - fi -fi - -# Codex CLI Setup -# When MOAT_CODEX_INIT is set to the staging directory path, copy files -# from the staging area to their final locations (~/.codex). -if [ -n "$MOAT_CODEX_INIT" ] && [ -d "$MOAT_CODEX_INIT" ]; then - # Determine target home directory - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - TARGET_HOME="/home/moatuser" - else - TARGET_HOME="$HOME" - fi - - # Create ~/.codex directory - mkdir -p "$TARGET_HOME/.codex" - - # Copy config.toml if present (preserve permissions) - [ -f "$MOAT_CODEX_INIT/config.toml" ] && \ - cp -p "$MOAT_CODEX_INIT/config.toml" "$TARGET_HOME/.codex/" - - # Copy auth.json if present (ensure restricted permissions for security) - if [ -f "$MOAT_CODEX_INIT/auth.json" ]; then - cp -p "$MOAT_CODEX_INIT/auth.json" "$TARGET_HOME/.codex/" - chmod 600 "$TARGET_HOME/.codex/auth.json" - fi - - # Copy AGENTS.md if present (runtime context for agent awareness) - [ -f "$MOAT_CODEX_INIT/AGENTS.md" ] && \ - cp -p "$MOAT_CODEX_INIT/AGENTS.md" "$TARGET_HOME/.codex/" - - # Ensure moatuser owns all the files if we're running as root - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown -R moatuser:moatuser "$TARGET_HOME/.codex" 2>/dev/null || true - fi -fi - -# Gemini CLI Setup -# When MOAT_GEMINI_INIT is set to the staging directory path, copy files -# from the staging area to their final locations (~/.gemini). -if [ -n "$MOAT_GEMINI_INIT" ] && [ -d "$MOAT_GEMINI_INIT" ]; then - # Determine target home directory - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - TARGET_HOME="/home/moatuser" - else - TARGET_HOME="$HOME" - fi - - # Create ~/.gemini directory - mkdir -p "$TARGET_HOME/.gemini" - - # Copy settings.json if present (preserve permissions) - [ -f "$MOAT_GEMINI_INIT/settings.json" ] && \ - cp -p "$MOAT_GEMINI_INIT/settings.json" "$TARGET_HOME/.gemini/" - - # Copy oauth_creds.json if present (ensure restricted permissions for security) - if [ -f "$MOAT_GEMINI_INIT/oauth_creds.json" ]; then - cp -p "$MOAT_GEMINI_INIT/oauth_creds.json" "$TARGET_HOME/.gemini/" - chmod 600 "$TARGET_HOME/.gemini/oauth_creds.json" - fi - - # Copy GEMINI.md if present (runtime context for agent awareness) - [ -f "$MOAT_GEMINI_INIT/GEMINI.md" ] && \ - cp -p "$MOAT_GEMINI_INIT/GEMINI.md" "$TARGET_HOME/.gemini/" - - # Ensure moatuser owns all the files if we're running as root - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown -R moatuser:moatuser "$TARGET_HOME/.gemini" 2>/dev/null || true - fi -fi - -# GitHub Copilot CLI Setup -# When MOAT_COPILOT_INIT is set to the staging directory path, copy files -# from the staging area to their final locations (~/.copilot). Runtime context -# stays mounted in the staging directory and is referenced via -# COPILOT_CUSTOM_INSTRUCTIONS_DIRS. -if [ -n "$MOAT_COPILOT_INIT" ] && [ -d "$MOAT_COPILOT_INIT" ]; then - # Determine target home directory - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - TARGET_HOME="/home/moatuser" - else - TARGET_HOME="$HOME" - fi - - # Create ~/.copilot directory - mkdir -p "$TARGET_HOME/.copilot" - - # Copy config/state files if present (preserve permissions) - [ -f "$MOAT_COPILOT_INIT/config.json" ] && \ - cp -p "$MOAT_COPILOT_INIT/config.json" "$TARGET_HOME/.copilot/" - [ -f "$MOAT_COPILOT_INIT/settings.json" ] && \ - cp -p "$MOAT_COPILOT_INIT/settings.json" "$TARGET_HOME/.copilot/" - [ -f "$MOAT_COPILOT_INIT/permissions-config.json" ] && \ - cp -p "$MOAT_COPILOT_INIT/permissions-config.json" "$TARGET_HOME/.copilot/" - - # Ensure moatuser owns all the files if we're running as root - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown -R moatuser:moatuser "$TARGET_HOME/.copilot" 2>/dev/null || true - fi -fi - -# Provider Init Files -# When MOAT_INIT_FILES is set, it contains tab-delimited records (one per line): -# -# This is used by credential providers that need config files written to disk -# (e.g., Graphite CLI config). Using init-time writes instead of bind mounts -# lets tools write to their config directories freely. -if [ -n "$MOAT_INIT_FILES" ]; then - # Determine ownership target - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - INIT_OWNER="moatuser:moatuser" - INIT_HOME="/home/moatuser" - else - INIT_OWNER="" - INIT_HOME="$HOME" - fi - - printf '%s\n' "$MOAT_INIT_FILES" | while IFS="$(printf '\t')" read -r filepath content; do - [ -z "$filepath" ] && continue - dir=$(dirname "$filepath") - mkdir -p "$dir" && chmod 755 "$dir" - printf '%s' "$content" | base64 -d > "$filepath" - chmod 600 "$filepath" - - # Fix ownership if running as root - if [ -n "$INIT_OWNER" ]; then - chown "$INIT_OWNER" "$filepath" 2>/dev/null || true - while [ "$dir" != "/" ] && [ "$dir" != "." ] && [ "$dir" != "$INIT_HOME" ]; do - chown "$INIT_OWNER" "$dir" 2>/dev/null || true - dir=$(dirname "$dir") - done - fi - done - unset MOAT_INIT_FILES -fi - -# MCP Server Setup -# Remote/host-local MCP servers are configured via .claude.json for Claude Code. -# Local process MCP servers (sandbox-local) are configured per-agent: -# - Claude: Written to .claude.json mcpServers (type: stdio) by the claude provider -# - Codex: Written to .mcp.json in workspace by the codex provider -# - Gemini: Written to .mcp.json in workspace by the gemini provider -# -# setup_workspace_mcp_json copies the local-process MCP config (.mcp.json) for -# Codex/Gemini into /workspace. It is a function (not inline) so it can be called -# AFTER populate_workspace_volume: in volume mode populate tar-extracts the -# staging tree over /workspace, so writing .mcp.json earlier would let the user's -# own .mcp.json clobber moat's. Running it last makes moat's config win in both -# modes (in bind mode populate is a no-op, so ordering is unchanged there). -# -# Both Codex and Gemini write the same destination path. This is safe because -# config validation rejects runs that activate both agents simultaneously — at -# most one block executes. A third agent with its own .mcp.json must preserve -# this mutual-exclusion invariant. -setup_workspace_mcp_json() { - if [ -n "$MOAT_CODEX_INIT" ] && [ -f "$MOAT_CODEX_INIT/mcp.json" ]; then - cp -p "$MOAT_CODEX_INIT/mcp.json" /workspace/.mcp.json - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown moatuser:moatuser /workspace/.mcp.json 2>/dev/null || true - fi - fi - if [ -n "$MOAT_GEMINI_INIT" ] && [ -f "$MOAT_GEMINI_INIT/mcp.json" ]; then - cp -p "$MOAT_GEMINI_INIT/mcp.json" /workspace/.mcp.json - if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - chown moatuser:moatuser /workspace/.mcp.json 2>/dev/null || true - fi - fi -} - -# Clipboard Bridging -# When MOAT_CLIPBOARD is set, start a headless X server for clipboard -# operations. The host writes clipboard data to /tmp/.moat-clipboard -# and uses xclip to set the X selection. -if [ "$MOAT_CLIPBOARD" = "1" ]; then - Xvfb :99 -screen 0 1x1x8 >/dev/null 2>&1 & - export DISPLAY=:99 -fi - -# Git Configuration -# 1. Safe directory: The workspace is mounted from the host with different -# ownership than the container user. Git 2.35.2+ rejects operations on -# directories owned by other users unless explicitly marked safe. -# 2. Identity: When the host has git user.name/user.email configured, moat -# passes them via MOAT_GIT_USER_NAME and MOAT_GIT_USER_EMAIL. Set them as -# system-level git config so commits inside the container use the host's -# identity. -if command -v git >/dev/null 2>&1; then - git config --system --add safe.directory /workspace 2>/dev/null || true - if [ -n "$MOAT_GIT_USER_NAME" ]; then - git config --system user.name "$MOAT_GIT_USER_NAME" 2>/dev/null || true - fi - if [ -n "$MOAT_GIT_USER_EMAIL" ]; then - git config --system user.email "$MOAT_GIT_USER_EMAIL" 2>/dev/null || true - fi - # Authenticate to the moat proxy preemptively with Basic. Unlike curl, git - # does not send Proxy-Authorization from the proxy URL and does not retry - # after the proxy's 407 CONNECT challenge, so HTTPS git through the proxy - # fails without this. Harmless when no proxy is configured. See issue #370. - git config --system http.proxyAuthMethod basic 2>/dev/null || true - # When both github and ssh:github.com grants are active, prefer SSH for all - # GitHub HTTPS URLs (git, pip, npm, etc.). HTTPS git to github.com works on - # its own (http.proxyAuthMethod above + the github provider's Basic-auth - # injection), so this is a routing preference, not a workaround: it makes git - # use the forwarded SSH key's identity rather than the token's. Opt out with - # MOAT_GIT_SSH_GITHUB=0 to use the HTTPS path. See issue #370. - if [ "$MOAT_GIT_SSH_GITHUB" = "1" ]; then - git config --system url."git@github.com:".insteadOf "https://github.com/" 2>/dev/null || true - fi -fi - -# Docker Access Setup -# Two mutually exclusive modes: -# 1. MOAT_DOCKER_GID (host mode): Docker socket mounted from host, just need group access -# 2. MOAT_DOCKER_DIND (dind mode): Start dockerd inside the container - -if [ -n "$MOAT_DOCKER_DIND" ] && [ -n "$MOAT_DOCKER_GID" ]; then - echo "Error: MOAT_DOCKER_DIND and MOAT_DOCKER_GID are mutually exclusive" >&2 - echo "Use MOAT_DOCKER_GID when mounting host's docker socket" >&2 - echo "Use MOAT_DOCKER_DIND when running Docker-in-Docker" >&2 - exit 1 -fi - -# Docker-in-Docker Mode -# When MOAT_DOCKER_DIND=1, start dockerd inside the container. -# This requires the container to be run with --privileged or appropriate capabilities. -if [ "$MOAT_DOCKER_DIND" = "1" ] && [ "$(id -u)" = "0" ]; then - echo "Starting Docker daemon (dind mode)..." >&2 - - # Create docker run directory if it doesn't exist - mkdir -p /var/run - - # Start dockerd in the background with vfs storage driver (most compatible for nested containers) - # Use vfs by default as it works without special kernel requirements - # overlay2 may work if the outer container has it available - dockerd --storage-driver=vfs --log-level=warn >/var/log/dockerd.log 2>&1 & - DOCKERD_PID=$! - - # Wait for dockerd to be ready (up to DIND_TIMEOUT_SECONDS) - # Check for socket file AND docker info since socket must exist for non-root users - DIND_WAITED=0 - echo "Waiting for Docker daemon to be ready..." >&2 - while [ "$DIND_WAITED" -lt "$DIND_TIMEOUT_SECONDS" ]; do - # Check both socket exists AND daemon responds - if [ -S /var/run/docker.sock ] && docker info >/dev/null 2>&1; then - echo "Docker daemon is ready (took ${DIND_WAITED}s)" >&2 - break - fi - # Check if dockerd is still running - if ! kill -0 "$DOCKERD_PID" 2>/dev/null; then - echo "Error: Docker daemon failed to start" >&2 - echo "Check /var/log/dockerd.log for details:" >&2 - tail -20 /var/log/dockerd.log 2>/dev/null || true - exit 1 - fi - sleep 1 - DIND_WAITED=$((DIND_WAITED + 1)) - done - - if [ "$DIND_WAITED" -ge "$DIND_TIMEOUT_SECONDS" ]; then - echo "Error: Docker daemon did not become ready within ${DIND_TIMEOUT_SECONDS} seconds" >&2 - echo "Socket exists: $([ -S /var/run/docker.sock ] && echo yes || echo no)" >&2 - echo "Check /var/log/dockerd.log for details:" >&2 - tail -20 /var/log/dockerd.log 2>/dev/null || true - exit 1 - fi - - # Add moatuser to docker group so they can use docker without sudo - if id moatuser >/dev/null 2>&1; then - # Ensure docker group exists (dockerd creates it, but be safe) - if ! getent group docker >/dev/null 2>&1; then - groupadd docker 2>/dev/null || true - fi - usermod -aG docker moatuser 2>/dev/null || true - fi -fi - -# Docker Socket Group (host mode) -# When MOAT_DOCKER_GID is set, the docker socket is mounted and we need to -# give moatuser access. We detect the socket's GID inside the container -# (not from the host) because Docker Desktop on macOS translates ownership. -# Note: Uses GNU stat -c format (Linux-specific, but containers are always Linux). -if [ -n "$MOAT_DOCKER_GID" ] && [ "$(id -u)" = "0" ] && [ -S /var/run/docker.sock ]; then - # Get the actual GID of the socket as seen inside the container - SOCKET_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null) || true - if [ -z "$SOCKET_GID" ]; then - echo "Warning: Failed to detect docker socket GID, docker access may not work" >&2 - elif [ -n "$SOCKET_GID" ]; then - # Check if a group with this GID already exists - if ! getent group "$SOCKET_GID" >/dev/null 2>&1; then - # Create a group with the docker socket GID - groupadd -g "$SOCKET_GID" moat-docker 2>/dev/null || true - fi - # Add moatuser to the group - DOCKER_GROUP=$(getent group "$SOCKET_GID" | cut -d: -f1) - if [ -n "$DOCKER_GROUP" ] && id moatuser >/dev/null 2>&1; then - usermod -aG "$DOCKER_GROUP" moatuser 2>/dev/null || true - fi - fi -fi - -# Workspace Volume Population -# When MOAT_WORKSPACE_VOLUME=1, copy the read-only staging tree into /workspace -# before dropping privileges. The staging mount (MOAT_WORKSPACE_STAGING, default -# /mnt/host-workspace) is a bind-mount of the host workspace directory; /workspace -# is a named Docker volume that starts empty each run. -# -# Excludes (MOAT_WORKSPACE_EXCLUDES) are newline-delimited, "./"-prefixed paths -# relative to the staging root (e.g. "./node_modules"). They are written to a temp -# file so the user-controlled value never expands on the command line. -# -# Symlinks are copied as symlinks (not dereferenced) — this is tar's DEFAULT -# behavior, so out-of-tree symlink targets are never copied into the volume. -# We deliberately pass NO symlink flag: the explicit no-deref long option only -# exists in GNU tar 1.35+, but the container base (debian bookworm) ships GNU tar -# 1.34, which rejects it and aborts the copy. The default preserves the security -# property without any flag. -# -# Must run as root, before the privilege drop and before run_pre_run_hook, so -# that chown -R moatuser:moatuser /workspace succeeds. -populate_workspace_volume() { - [ "${MOAT_WORKSPACE_VOLUME:-}" = "1" ] || return 0 - - # Defensive: this function must run as root (it chowns /workspace). The call - # site is before the privilege drop; this guard makes a future refactor that - # moves it past the drop fail loudly instead of hitting a silent chown EPERM. - if [ "$(id -u)" != "0" ]; then - echo "moat: populate_workspace_volume must run as root" >&2 - exit 1 - fi - - staging="${MOAT_WORKSPACE_STAGING:-/mnt/host-workspace}" - - exclude_file="/tmp/moat-excludes.$$" - : > "$exclude_file" - if [ -n "${MOAT_WORKSPACE_EXCLUDES:-}" ]; then - printf '%s' "$MOAT_WORKSPACE_EXCLUDES" > "$exclude_file" - fi - - # Copy staging -> /workspace. Symlinks are preserved as symlinks (tar default, - # so no out-of-tree target leakage); excludes are read from a file (never - # expand the user-controlled env var into the command line). - # - # Excludes are newline-delimited (one "./"-prefixed pattern per line), NOT - # NUL-delimited: GNU tar 1.34's `--null --exclude-from` only applies the first - # record and silently ignores the rest, which left every exclude after the - # first copied into the volume. Plain (newline) --exclude-from applies all - # patterns. Exclude patterns are validated at config load to a path-safe class - # (no whitespace/newlines), so newline is a safe delimiter. - # - # In POSIX sh, $? after a pipeline reports only the rightmost command's - # status, so a failure in the source `tar -cf -` (read error, bad exclude - # file) would go undetected and leave a silently partial workspace. Capture - # the source tar's status via a temp file and check both ends. - src_rc_file="/tmp/moat-ws-rc.$$" - ( cd "$staging" && tar --exclude-from="$exclude_file" -cf - . ; echo $? > "$src_rc_file" ) \ - | ( cd /workspace && tar -xf - ) - dst_rc=$? - src_rc="$(cat "$src_rc_file" 2>/dev/null || echo 1)" - rm -f "$exclude_file" "$src_rc_file" - if [ "$src_rc" -ne 0 ] || [ "$dst_rc" -ne 0 ]; then - echo "moat: failed to populate workspace volume (src=$src_rc dst=$dst_rc)" >&2 - exit 1 - fi - - # The fresh volume mountpoint is root-owned; hand it to the agent user. - chown -R moatuser:moatuser /workspace -} - -# Pre-run Hook -# When MOAT_PRE_RUN is set, run the command as moatuser in /workspace before -# executing the main command. This runs on every container start (not cached). -# Use for workspace-level setup that needs project files (e.g., "npm install"). -run_pre_run_hook() { - if [ -z "$MOAT_PRE_RUN" ]; then - return - fi - # Capture the hook's exit code with `set -e` temporarily off, so a failing - # hook is reported with context instead of aborting the entrypoint silently — - # which looks like the container itself failed to start, with no hint the - # pre_run hook was the cause. See issue #372. - set +e - if [ "$(id -u)" != "0" ]; then - # Already non-root, run directly. The subshell keeps the hook's `cd` from - # changing the entrypoint's own working directory. - ( cd /workspace && sh -c "$MOAT_PRE_RUN" ) - hook_status=$? - elif id moatuser >/dev/null 2>&1; then - # Drop to moatuser for the hook. gosu spawns a separate process, so its - # `cd` can't leak into the entrypoint — no subshell needed here. - gosu moatuser sh -c "cd /workspace && $MOAT_PRE_RUN" - hook_status=$? - else - hook_status=0 - fi - set -e - - if [ "$hook_status" -ne 0 ]; then - echo "" >&2 - echo "moat: pre_run hook failed (exit code $hook_status)" >&2 - echo "moat: command: $MOAT_PRE_RUN" >&2 - echo "moat: the pre_run hook runs as moatuser in /workspace before your command." >&2 - echo "moat: fix the command above, or remove hooks.pre_run from moat.yaml." >&2 - exit "$hook_status" - fi -} - -# Named volume ownership -# Docker named volumes (moat.yaml `volumes:` with `type: volume`) are created -# root-owned. Chown each mount root to moatuser so the non-root pre_run hook and -# command can write. Non-recursive on purpose: a fresh volume's root is the only -# root-owned node, and its contents are created by moatuser. `chown -R` over a -# multi-GB cache on every start would reintroduce the slowness this feature avoids. -if [ -n "$MOAT_VOLUME_CHOWN" ] && [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then - # Disable pathname expansion so a target containing a glob char ([ ] * ?) is not - # expanded against the filesystem. Word-splitting on spaces stays on — the paths - # are space-separated (matching MOAT_EXTRA_HOSTS). - set -f - for vpath in $MOAT_VOLUME_CHOWN; do - chown moatuser:moatuser "$vpath" 2>/dev/null || true - done - set +f -fi - -# Execute the user's command -# First run the pre_run hook (if set), then exec the main command. -# If we're already running as a non-root user (UID != 0), just exec directly. -# This happens when Docker is started with --user to match host UID on Linux. -# If we're root and moatuser exists, drop privileges with gosu. -# If moatuser doesn't exist, fail - running as root defeats the security model. -populate_workspace_volume -setup_workspace_mcp_json -run_pre_run_hook -if [ "$(id -u)" != "0" ]; then - # Already non-root (e.g., --user was passed to docker run) - exec "$@" -elif id moatuser >/dev/null 2>&1; then - # Running as root, moatuser exists - drop privileges - exec gosu moatuser "$@" -else - # Running as root, no moatuser - fail with clear error - # Running as root defeats the container security model - echo "Error: Container started as root but moatuser does not exist." >&2 - echo "This is a security issue - running as root defeats container isolation." >&2 - echo "" >&2 - echo "If you're using a custom image, ensure it creates a 'moatuser' account:" >&2 - echo " RUN useradd -m -u 5000 -s /bin/bash moatuser" >&2 - echo "" >&2 - echo "Or run the container with a non-root user:" >&2 - echo " docker run --user 1000:1000 ..." >&2 - exit 1 -fi diff --git a/internal/e2e/entrypoint_acceptance_test.go b/internal/e2e/entrypoint_acceptance_test.go new file mode 100644 index 00000000..cd53d7e4 --- /dev/null +++ b/internal/e2e/entrypoint_acceptance_test.go @@ -0,0 +1,343 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/container" + "github.com/majorcontext/moat/internal/initbin" + "github.com/majorcontext/moat/internal/run" + "github.com/majorcontext/moat/internal/storage" +) + +// The moat-init Go entrypoint is the sole container entrypoint. This harness +// runs it in a real container across the feature scenarios and asserts the +// observable post-start state directly (identity, staged files + modes, the +// exec'd environment, long-lived children). It replaced the sh-vs-go +// differential harness when the shell entrypoint was removed; the pure-logic +// parity checks now live in internal/moatinit (unit + live-tool differential +// tests). + +// stateDumperScript emits a machine-parseable manifest between markers: the +// exec'd process identity, its environment (proxy auth tokens redacted, +// per-run values dropped), the system git config, staged file trees with +// modes+ownership, a census of the expected long-lived children, and a +// MOAT_INIT_FILES leak scan. +const stateDumperScript = ` +echo MOAT-MANIFEST-BEGIN +echo "[identity]" +echo "user=$(id -un)" +echo "uid=$(id -u)" +echo "gid=$(id -g)" +echo "[env]" +env | sort \ + | grep -v "^HOSTNAME=" \ + | grep -v "^SHLVL=" \ + | grep -v "^_=" \ + | sed -E "s#^(HTTPS?_PROXY|https?_proxy)=http://moat:[^@]*@#\1=http://moat:REDACTED@#" \ + | sed -E "s#^(MOAT_SSH_TCP_ADDR)=.*#\1=REDACTED#" \ + | sed -E "s#^(SSH_AUTH_SOCK)=.*#\1=REDACTED#" +echo "[gitconfig]" +{ git config --system --list 2>/dev/null || echo none; } | sort +echo "[tree]" +for d in "$HOME/.claude" "$HOME/.codex" "$HOME/.gemini" "$HOME/.copilot" /workspace; do + if [ -e "$d" ]; then + echo "-- $d" + find "$d" -printf "%y %M %u %g %P\n" 2>/dev/null | sort + fi +done +echo "[children]" +for want in socat Xvfb dockerd; do + found=absent + for c in /proc/[0-9]*/comm; do + if [ "$(cat "$c" 2>/dev/null)" = "$want" ]; then found=running; break; fi + done + echo "$want $found" +done +echo "[init-files-leak]" +if env | grep -q "^MOAT_INIT_FILES="; then echo LEAKED; else echo clean; fi +echo MOAT-MANIFEST-END +` + +var dumperCmd = []string{"sh", "-c", stateDumperScript} + +// runEntrypoint starts a run, waits for it, and returns the extracted +// manifest plus the full log text and the wait error. +func runEntrypoint(t *testing.T, name string, opts run.Options) (manifest, allLogs string, waitErr error) { + t.Helper() + if initbin.IsStub(initbin.Binary()) { + // The test binary embeds the same initbin blob writeEntrypoint ships: + // a stub would exec the fail-closed placeholder as PID 1 and fail + // confusingly. `make test-e2e` regenerates the real binary first. + t.Skip("embedded moat-init binary is the committed stub — run 'make generate-init' (or 'make test-e2e', which does) first") + } + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + mgr, err := run.NewManagerWithOptions(run.ManagerOptions{NoSandbox: &[]bool{true}[0]}) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + defer mgr.Close() + + opts.Name = name + r, err := mgr.Create(ctx, opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer mgr.Destroy(context.Background(), r.ID) + + if err := mgr.Start(ctx, r.ID); err != nil { + t.Fatalf("Start: %v", err) + } + waitErr = mgr.Wait(ctx, r.ID) + time.Sleep(200 * time.Millisecond) + + store, err := storage.NewRunStore(storage.DefaultBaseDir(), r.ID) + if err != nil { + t.Fatalf("NewRunStore: %v", err) + } + logs, err := store.ReadLogs(0, 5000) + if err != nil { + t.Fatalf("ReadLogs: %v", err) + } + var b strings.Builder + for _, entry := range logs { + b.WriteString(entry.Line) + b.WriteString("\n") + } + allLogs = b.String() + + begin := strings.Index(allLogs, "MOAT-MANIFEST-BEGIN") + end := strings.Index(allLogs, "MOAT-MANIFEST-END") + if begin >= 0 && end > begin { + manifest = allLogs[begin:end] + } + return manifest, allLogs, waitErr +} + +// section returns the lines of a "[name]" manifest section (up to the next +// "[" header or the end marker). +func section(manifest, name string) []string { + start := strings.Index(manifest, "["+name+"]") + if start < 0 { + return nil + } + rest := manifest[start+len("["+name+"]"):] + var out []string + for _, line := range strings.Split(rest, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") || line == "MOAT-MANIFEST-END" { + break + } + out = append(out, line) + } + return out +} + +func manifestHas(lines []string, want string) bool { + for _, l := range lines { + if l == want { + return true + } + } + return false +} + +// assertBaseline checks the invariants every run must satisfy: the command +// runs as moatuser (uid 5000, privilege drop happened) and no secret env +// leaked. +func assertBaseline(t *testing.T, manifest string) { + t.Helper() + id := section(manifest, "identity") + if !manifestHas(id, "user=moatuser") { + t.Errorf("command did not run as moatuser:\n%v", id) + } + if !manifestHas(id, "uid=5000") { + t.Errorf("privilege drop did not reach uid 5000:\n%v", id) + } + if leak := section(manifest, "init-files-leak"); !manifestHas(leak, "clean") { + t.Errorf("MOAT_INIT_FILES leaked into the exec env:\n%v", leak) + } +} + +// TestEntrypointBaseline runs a plain command on every available runtime and +// asserts the privilege drop and env scrub. +func TestEntrypointBaseline(t *testing.T) { + testOnAllRuntimes(t, func(t *testing.T, rt container.Runtime) { + m, logs, _ := runEntrypoint(t, "acc-baseline", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + }) + if m == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + assertBaseline(t, m) + }) +} + +// TestEntrypointFeatureScenarios exercises the feature phases on Docker and +// asserts each phase's observable result. +func TestEntrypointFeatureScenarios(t *testing.T) { + requireDocker(t) + + t.Run("pre-run-hook", func(t *testing.T) { + m, logs, _ := runEntrypoint(t, "acc-prerun", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + Config: &config.Config{ + Hooks: config.HooksConfig{PreRun: "echo marker > /workspace/.pre-run-marker"}, + }, + }) + if m == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + assertBaseline(t, m) + // The hook ran as moatuser in /workspace and left an owned marker. + if ws := section(m, "tree"); !hasFileOwned(ws, ".pre-run-marker", "moatuser") { + t.Errorf("pre_run marker missing or not owned by moatuser:\n%v", ws) + } + }) + + t.Run("workspace-volume", func(t *testing.T) { + m, logs, _ := runEntrypoint(t, "acc-wsvol", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + WorkspaceMode: config.WorkspaceModeVolume, + }) + if m == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + assertBaseline(t, m) + // The populated /workspace is owned by moatuser (the recursive chown + // after the tar copy). + if ws := section(m, "tree"); !hasAnyOwned(ws, "moatuser") { + t.Errorf("/workspace not owned by moatuser after populate:\n%v", ws) + } + }) + + t.Run("clipboard", func(t *testing.T) { + m, logs, _ := runEntrypoint(t, "acc-clip", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + Clipboard: true, + }) + if m == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + assertBaseline(t, m) + if env := section(m, "env"); !manifestHas(env, "DISPLAY=:99") { + t.Errorf("DISPLAY not exported for clipboard:\n%v", env) + } + // Xvfb is a long-lived child that must survive the exec handoff. + if kids := section(m, "children"); !manifestHas(kids, "Xvfb running") { + t.Errorf("Xvfb did not survive the exec handoff:\n%v", kids) + } + }) + + t.Run("init-files-multi", func(t *testing.T) { + rec := func(path, content string) string { + return path + "\t" + base64.StdEncoding.EncodeToString([]byte(content)) + } + records := rec("/home/moatuser/.config/deep/nested/tool/config.toml", "secret-one") + "\n" + + rec("/home/moatuser/.acc-initrc", "secret-two") + m, logs, _ := runEntrypoint(t, "acc-initfiles", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: dumperCmd, + Env: []string{"MOAT_INIT_FILES=" + records}, + }) + if m == "" { + t.Fatalf("no manifest; logs:\n%s", logs) + } + // The scrub is the load-bearing assertion here. + assertBaseline(t, m) + // Both secret files exist at 0600, owned by moatuser (visible in the + // home tree). + tree := section(m, "tree") + if !hasFileMode(tree, ".acc-initrc", "-rw-------") { + t.Errorf("init file .acc-initrc not at 0600:\n%v", tree) + } + if !hasFileMode(tree, "config.toml", "-rw-------") { + t.Errorf("nested init file not at 0600:\n%v", tree) + } + }) +} + +// TestEntrypointPreRunFailure is the negative probe: a failing pre_run hook +// produces the framed #372 diagnostic and the hook's literal exit code, and +// the user command does not run. +func TestEntrypointPreRunFailure(t *testing.T) { + requireDocker(t) + _, logs, waitErr := runEntrypoint(t, "acc-prerun-fail", run.Options{ + Workspace: createTestWorkspace(t), + Cmd: []string{"sh", "-c", "echo SHOULD-NOT-RUN"}, + Config: &config.Config{ + Hooks: config.HooksConfig{PreRun: "echo doing-setup; exit 7"}, + }, + }) + for _, want := range []string{ + "moat: pre_run hook failed (exit code 7)", + "moat: command: echo doing-setup; exit 7", + } { + if !strings.Contains(logs, want) { + t.Errorf("logs missing %q:\n%s", want, logs) + } + } + if strings.Contains(logs, "SHOULD-NOT-RUN") { + t.Error("user command ran after a failing pre_run hook") + } + // "exit code 7" (not a bare "7", which 17/27/127 would satisfy). + if waitErr == nil || !strings.Contains(waitErr.Error(), "exit code 7") { + t.Errorf("wait error = %v, want the hook's literal exit code 7", waitErr) + } +} + +// hasFileOwned reports whether the tree section has a regular-file line for +// base name `name` owned by `owner`. Tree lines are "%y %M %u %g %P". +func hasFileOwned(tree []string, name, owner string) bool { + for _, l := range tree { + f := strings.Fields(l) + if len(f) >= 5 && f[0] == "f" && f[2] == owner && baseName(f[4]) == name { + return true + } + } + return false +} + +func hasFileMode(tree []string, name, mode string) bool { + for _, l := range tree { + f := strings.Fields(l) + if len(f) >= 5 && f[0] == "f" && f[1] == mode && baseName(f[4]) == name { + return true + } + } + return false +} + +func hasAnyOwned(tree []string, owner string) bool { + for _, l := range tree { + f := strings.Fields(l) + if len(f) >= 3 && f[2] == owner { + return true + } + } + return false +} + +func baseName(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/internal/e2e/entrypoint_parity_test.go b/internal/e2e/entrypoint_parity_test.go deleted file mode 100644 index 999a657e..00000000 --- a/internal/e2e/entrypoint_parity_test.go +++ /dev/null @@ -1,357 +0,0 @@ -//go:build e2e -// +build e2e - -package e2e - -import ( - "context" - "encoding/base64" - "strings" - "testing" - "time" - - "github.com/majorcontext/moat/internal/config" - "github.com/majorcontext/moat/internal/container" - "github.com/majorcontext/moat/internal/initbin" - "github.com/majorcontext/moat/internal/run" - "github.com/majorcontext/moat/internal/storage" -) - -// stateDumperScript is the fixed user command both parity legs run. It -// emits a machine-parseable manifest between markers covering what a naive -// file+id snapshot would miss (plan §7): identity with the supplementary -// set order-normalized, the exec'd environment (proxy auth tokens redacted -// to a fixed placeholder, per-run values dropped), /etc/hosts, the system -// git config, file trees with modes+ownership (mtimes masked by omission; -// the statsig dir compared as a content hash so a copy corruption cannot -// hide behind a mask), a census of the expected long-lived children, and a -// MOAT_INIT_FILES leak scan. -const stateDumperScript = ` -echo MOAT-MANIFEST-BEGIN -echo "[identity]" -id -u -id -g -id -G | tr " " "\n" | sort -n | paste -sd " " - -echo "[env]" -env | sort \ - | grep -v "^HOSTNAME=" \ - | grep -v "^SHLVL=" \ - | grep -v "^_=" \ - | sed -E "s#^(HTTPS?_PROXY|https?_proxy)=http://moat:[^@]*@#\1=http://moat:REDACTED@#" \ - | sed -E "s#^(MOAT_SSH_TCP_ADDR)=.*#\1=REDACTED#" \ - | sed -E "s#^(SSH_AUTH_SOCK)=.*#\1=REDACTED#" -echo "[hosts]" -# Docker writes a unique self-entry " " into every -# container's /etc/hosts; drop the self line (each leg is its own -# container) so the moat-appended entries are what gets compared. -SELF="$(cat /etc/hostname 2>/dev/null)" -if [ -n "$SELF" ]; then - grep -v "$SELF" /etc/hosts 2>/dev/null || echo none -else - cat /etc/hosts 2>/dev/null || echo none -fi -echo "[gitconfig]" -{ git config --system --list 2>/dev/null || echo none; } | sort -echo "[tree]" -for d in "$HOME/.claude" "$HOME/.codex" "$HOME/.gemini" "$HOME/.copilot" /workspace; do - if [ -e "$d" ]; then - echo "-- $d" - find "$d" -path "*/statsig" -prune -o -printf "%y %M %u %g %P\n" 2>/dev/null | sort - fi -done -if [ -f "$HOME/.claude.json" ]; then stat -c "%A %U %G .claude.json" "$HOME/.claude.json"; else echo "no .claude.json"; fi -echo "[statsig]" -if [ -d "$HOME/.claude/statsig" ]; then - (cd "$HOME/.claude/statsig" && find . -type f | sort | xargs cat 2>/dev/null | sha256sum) -else - echo none -fi -echo "[children]" -for want in socat Xvfb dockerd; do - found=absent - for c in /proc/[0-9]*/comm; do - if [ "$(cat "$c" 2>/dev/null)" = "$want" ]; then found=running; break; fi - done - echo "$want $found" -done -echo "[init-files-leak]" -if env | grep -q "^MOAT_INIT_FILES="; then echo LEAKED; else echo clean; fi -echo MOAT-MANIFEST-END -` - -// runEntrypointLeg starts one parity leg with the given entrypoint -// implementation selected via the operator-only host env channel, waits for -// completion, and returns the extracted manifest plus the full log text. -func runEntrypointLeg(t *testing.T, impl, name string, opts run.Options) (manifest, allLogs string, waitErr error) { - t.Helper() - if impl == "go" && initbin.IsStub(initbin.Binary()) { - // The test binary embeds the same initbin blobs writeEntrypoint - // ships: a stub go leg would exec the fail-closed placeholder as - // PID 1 and fail confusingly instead of exercising parity. - t.Skip("embedded moat-init binary is the committed stub — run 'make generate-init' (or 'make test-e2e', which does) first") - } - t.Setenv("MOAT_INIT_IMPL", impl) - - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - - mgr, err := run.NewManagerWithOptions(run.ManagerOptions{NoSandbox: &[]bool{true}[0]}) - if err != nil { - t.Fatalf("NewManager: %v", err) - } - defer mgr.Close() - - opts.Name = name + "-" + impl - r, err := mgr.Create(ctx, opts) - if err != nil { - t.Fatalf("Create(%s): %v", impl, err) - } - defer mgr.Destroy(context.Background(), r.ID) - - if err := mgr.Start(ctx, r.ID); err != nil { - t.Fatalf("Start(%s): %v", impl, err) - } - waitErr = mgr.Wait(ctx, r.ID) - time.Sleep(200 * time.Millisecond) - - store, err := storage.NewRunStore(storage.DefaultBaseDir(), r.ID) - if err != nil { - t.Fatalf("NewRunStore(%s): %v", impl, err) - } - logs, err := store.ReadLogs(0, 5000) - if err != nil { - t.Fatalf("ReadLogs(%s): %v", impl, err) - } - var b strings.Builder - for _, entry := range logs { - b.WriteString(entry.Line) - b.WriteString("\n") - } - allLogs = b.String() - - begin := strings.Index(allLogs, "MOAT-MANIFEST-BEGIN") - end := strings.Index(allLogs, "MOAT-MANIFEST-END") - if begin >= 0 && end > begin { - manifest = allLogs[begin:end] - } - return manifest, allLogs, waitErr -} - -// diffManifests reports the first differing line for readable failures. -func diffManifests(t *testing.T, name, sh, goM string) { - t.Helper() - if sh == goM { - return - } - shLines, goLines := strings.Split(sh, "\n"), strings.Split(goM, "\n") - for i := 0; i < len(shLines) || i < len(goLines); i++ { - var a, b string - if i < len(shLines) { - a = shLines[i] - } - if i < len(goLines) { - b = goLines[i] - } - if a != b { - t.Errorf("%s: manifests diverge at line %d:\n sh: %q\n go: %q", name, i+1, a, b) - break - } - } - t.Errorf("%s: full manifests differ\n===== sh =====\n%s\n===== go =====\n%s", name, sh, goM) -} - -// parityScenario is one config the harness diffs across implementations. -type parityScenario struct { - name string - opts func(t *testing.T) run.Options -} - -func parityScenarios() []parityScenario { - dumperCmd := []string{"sh", "-c", stateDumperScript} - return []parityScenario{ - { - // Plain run: privilege drop, baseline env, no features. - name: "baseline", - opts: func(t *testing.T) run.Options { - return run.Options{Workspace: createTestWorkspace(t), Cmd: dumperCmd} - }, - }, - { - // Gap fixture: pre_run hook (success path) leaves its marker in - // /workspace as moatuser before the dumper runs. - name: "pre-run-hook", - opts: func(t *testing.T) run.Options { - return run.Options{ - Workspace: createTestWorkspace(t), - Cmd: dumperCmd, - Config: &config.Config{ - Hooks: config.HooksConfig{PreRun: "date +%s > /workspace/.pre-run.timestamp && echo marker > /workspace/.pre-run-marker"}, - }, - } - }, - }, - { - // Gap fixture: MOAT_WORKSPACE_VOLUME full populate — tar copy, - // ownership hand-off, and the .mcp.json ordering all covered by - // the [tree] section. - name: "workspace-volume", - opts: func(t *testing.T) run.Options { - return run.Options{ - Workspace: createTestWorkspace(t), - Cmd: dumperCmd, - WorkspaceMode: config.WorkspaceModeVolume, - } - }, - }, - { - // Gap fixture: clipboard — Xvfb child census + DISPLAY in env. - name: "clipboard", - opts: func(t *testing.T) run.Options { - return run.Options{Workspace: createTestWorkspace(t), Cmd: dumperCmd, Clipboard: true} - }, - }, - { - // Gap fixture: multi-record MOAT_INIT_FILES with a deep parent - // chain — 0600 files, 0755 parents, ancestor chown, and the - // INIT-10 scrub all visible in [tree] + [init-files-leak]. - name: "init-files-multi", - opts: func(t *testing.T) run.Options { - rec := func(path, content string) string { - return path + "\t" + base64.StdEncoding.EncodeToString([]byte(content)) - } - records := rec("/home/moatuser/.config/deep/nested/tool/config.toml", "secret-one") + "\n" + - rec("/home/moatuser/.parityrc", "secret-two") - return run.Options{ - Workspace: createTestWorkspace(t), - Cmd: dumperCmd, - Env: []string{"MOAT_INIT_FILES=" + records}, - } - }, - }, - } -} - -// TestEntrypointParityBaseline diffs the full manifest between the sh and -// go entrypoints on every available runtime (Docker and Apple reparent -// children differently — the census must match per-runtime). -func TestEntrypointParityBaseline(t *testing.T) { - testOnAllRuntimes(t, func(t *testing.T, rt container.Runtime) { - sc := parityScenarios()[0] - shM, shLogs, _ := runEntrypointLeg(t, "sh", "parity-base", sc.opts(t)) - goM, goLogs, _ := runEntrypointLeg(t, "go", "parity-base", sc.opts(t)) - if shM == "" { - t.Fatalf("sh leg produced no manifest; logs:\n%s", shLogs) - } - if goM == "" { - t.Fatalf("go leg produced no manifest; logs:\n%s", goLogs) - } - diffManifests(t, sc.name, shM, goM) - }) -} - -// TestEntrypointParityScenarios diffs the remaining scenarios on Docker -// (the full-matrix leg per the plan's runtime matrix). -func TestEntrypointParityScenarios(t *testing.T) { - requireDocker(t) - for _, sc := range parityScenarios()[1:] { - sc := sc - t.Run(sc.name, func(t *testing.T) { - shM, shLogs, _ := runEntrypointLeg(t, "sh", "parity-"+sc.name, sc.opts(t)) - goM, goLogs, _ := runEntrypointLeg(t, "go", "parity-"+sc.name, sc.opts(t)) - if shM == "" { - t.Fatalf("sh leg produced no manifest; logs:\n%s", shLogs) - } - if goM == "" { - t.Fatalf("go leg produced no manifest; logs:\n%s", goLogs) - } - diffManifests(t, sc.name, shM, goM) - // The scrub is load-bearing enough to assert directly, not just - // via cross-leg equality (both legs leaking would still match). - for impl, m := range map[string]string{"sh": shM, "go": goM} { - if strings.Contains(m, "LEAKED") { - t.Errorf("%s leg leaked MOAT_INIT_FILES into the exec env", impl) - } - } - }) - } -} - -// TestEntrypointParityPreRunFailure is the negative probe: a failing -// pre_run hook must produce the framed #372 diagnostic and the hook's -// literal exit code on BOTH implementations, and the user command must not -// run. -func TestEntrypointParityPreRunFailure(t *testing.T) { - requireDocker(t) - for _, impl := range []string{"sh", "go"} { - impl := impl - t.Run(impl, func(t *testing.T) { - opts := run.Options{ - Workspace: createTestWorkspace(t), - Cmd: []string{"sh", "-c", "echo SHOULD-NOT-RUN"}, - Config: &config.Config{ - Hooks: config.HooksConfig{PreRun: "echo doing-setup; exit 7"}, - }, - } - manifest, logs, waitErr := runEntrypointLeg(t, impl, "parity-prerun-fail", opts) - if manifest != "" { - t.Error("manifest produced despite failing hook") - } - for _, want := range []string{ - "moat: pre_run hook failed (exit code 7)", - "moat: command: echo doing-setup; exit 7", - } { - if !strings.Contains(logs, want) { - t.Errorf("logs missing %q:\n%s", want, logs) - } - } - if strings.Contains(logs, "SHOULD-NOT-RUN") { - t.Error("user command ran after a failing pre_run hook") - } - // "exit code 7" (not a bare "7", which 17/27/127 would satisfy). - if waitErr == nil || !strings.Contains(waitErr.Error(), "exit code 7") { - t.Errorf("wait error = %v, want the hook's literal exit code 7", waitErr) - } - }) - } -} - -// TestEntrypointDispatcherClosedEnum is the dispatcher negative probe: an -// unknown MOAT_INIT_IMPL value must fail loudly, never fall back to an -// unintended entrypoint. -func TestEntrypointDispatcherClosedEnum(t *testing.T) { - requireDocker(t) - opts := run.Options{ - Workspace: createTestWorkspace(t), - Cmd: []string{"sh", "-c", "echo SHOULD-NOT-RUN"}, - } - _, logs, waitErr := runEntrypointLeg(t, "bogus", "parity-bad-impl", opts) - if !strings.Contains(logs, "Error: invalid MOAT_INIT_IMPL 'bogus'") { - t.Errorf("logs missing the closed-enum error:\n%s", logs) - } - if strings.Contains(logs, "SHOULD-NOT-RUN") { - t.Error("user command ran under an invalid dispatcher value") - } - if waitErr == nil { - t.Error("run succeeded despite an invalid MOAT_INIT_IMPL") - } -} - -// TestEntrypointGoLongLivedChildren verifies the Go leg's detached children -// survive the exec handoff: the SSH bridge/Xvfb census in the manifest runs -// AFTER the entrypoint has been replaced by the user command, so a -// "running" entry proves the child outlived the exec. -func TestEntrypointGoLongLivedChildren(t *testing.T) { - requireDocker(t) - opts := run.Options{ - Workspace: createTestWorkspace(t), - Cmd: []string{"sh", "-c", stateDumperScript}, - Clipboard: true, - } - manifest, logs, _ := runEntrypointLeg(t, "go", "parity-children", opts) - if manifest == "" { - t.Fatalf("no manifest; logs:\n%s", logs) - } - if !strings.Contains(manifest, "Xvfb running") { - t.Errorf("Xvfb did not survive the exec handoff:\n%s", manifest) - } -} diff --git a/internal/initbin/initbin.go b/internal/initbin/initbin.go index 039a23b6..4d81f961 100644 --- a/internal/initbin/initbin.go +++ b/internal/initbin/initbin.go @@ -63,12 +63,13 @@ func Binary() []byte { // than a real cross-compiled entrypoint. Three layers keep a stub from // serving as PID 1: the release gate (internal/initbin/gate, wired into the // goreleaser before hooks) refuses to release stub bytes and execs the -// regenerated binary's --plan as a positive functional check; the parity -// harness skips its go legs when the test binary embeds a stub; and the -// stub itself fails loudly at runtime — the backstop for channels that -// bypass generation entirely (`go install`, bare `go build`), where the -// dispatcher's sh default keeps runs working and MOAT_INIT_IMPL=go fails -// closed with the stub's message. +// regenerated binary's --plan as a positive functional check; the e2e +// acceptance harness skips when the test binary embeds a stub; and the stub +// itself fails loudly at runtime — the backstop for channels that bypass +// generation entirely (`go install`, bare `go build`). Because the Go binary +// is now the sole entrypoint (no shell fallback), a stub shipped that way +// makes the container fail closed at PID 1 with the stub's rebuild message +// rather than silently skipping the privilege drop. func IsStub(b []byte) bool { return bytes.HasPrefix(b, []byte(stubMarker)) } diff --git a/internal/moatinit/doc.go b/internal/moatinit/doc.go index 50ddb9c0..3054c3e8 100644 --- a/internal/moatinit/doc.go +++ b/internal/moatinit/doc.go @@ -22,8 +22,6 @@ // temp root with no container and no root privileges. // // The compiled binary (cmd/moat-init) is embedded into the moat host binary -// by internal/initbin and shipped into run images by writeEntrypoint. During -// the migration window a dispatcher selects between the shell script and -// this implementation via MOAT_INIT_IMPL (operator-only; see internal/run's -// reserved-key validation). +// by internal/initbin and shipped into run images as /usr/local/bin/moat-init +// (the container ENTRYPOINT) by writeEntrypoint. package moatinit diff --git a/internal/moatinit/phase.go b/internal/moatinit/phase.go index e253bb1e..b8917d1c 100644 --- a/internal/moatinit/phase.go +++ b/internal/moatinit/phase.go @@ -93,6 +93,6 @@ func Run(ctx *Context) int { } } fmt.Fprintln(ctx.Stderr, "FATAL: moat-init reached the end of its phase list without exec'ing the command.") - fmt.Fprintln(ctx.Stderr, "This build of moat-init is incomplete; use MOAT_INIT_IMPL=sh (the default) or rebuild moat.") + fmt.Fprintln(ctx.Stderr, "This build of moat-init is incomplete; rebuild moat via 'make build'.") return 1 } diff --git a/internal/moatinit/shellparity_test.go b/internal/moatinit/shellparity_test.go index 6474e5d0..c9fa23d8 100644 --- a/internal/moatinit/shellparity_test.go +++ b/internal/moatinit/shellparity_test.go @@ -3,512 +3,20 @@ package moatinit import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "io/fs" - "os" "os/exec" - "path/filepath" - "sort" "strings" - "sync" "testing" ) -// This file is a differential pressure harness: it runs the REAL -// internal/deps/scripts/moat-init.sh and the REAL compiled Go entrypoint -// side by side against crafted environments and compares observable -// behavior — exit codes, contract stderr, the child-visible environment, -// and the resulting file trees (paths, types, modes, content). -// -// It covers the non-root branch only (the test process is not root; the -// root/gosu legs are the e2e parity harness's job) and avoids every phase -// that touches shared absolute paths (/etc/hosts writes, /run/moat/ssh, -// /workspace/.mcp.json, populate) except where the phase's own guard makes -// the case side-effect-free (root guards, malformed-entry skips, -// resolve-before-write failures). - -var ( - buildOnce sync.Once - goBinPath string - buildErr error -) - -func builtGoEntrypoint(t *testing.T) string { - t.Helper() - buildOnce.Do(func() { - dir, err := os.MkdirTemp("", "moat-init-parity") - if err != nil { - buildErr = err - return - } - goBinPath = filepath.Join(dir, "moat-init") - cmd := exec.Command("go", "build", "-o", goBinPath, "github.com/majorcontext/moat/cmd/moat-init") - if out, err := cmd.CombinedOutput(); err != nil { - buildErr = fmt.Errorf("building entrypoint: %v\n%s", err, out) - } - }) - if buildErr != nil { - t.Fatal(buildErr) - } - return goBinPath -} - -func scriptPath(t *testing.T) string { - t.Helper() - p, err := filepath.Abs("../deps/scripts/moat-init.sh") - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(p); err != nil { - t.Fatalf("moat-init.sh not found: %v", err) - } - return p -} - -// legResult is one implementation's observable outcome. -type legResult struct { - exit int - stdout string - stderr string - tree string // normalized listing of the leg's HOME -} - -// runLeg executes one implementation with an isolated HOME and the given -// MOAT_* env, returning the observable outcome. baseEnv entries may contain -// the placeholder @HOME@ which is substituted with the leg's home dir. -func runLeg(t *testing.T, argv []string, home string, env map[string]string, cmdArgs []string) legResult { - t.Helper() - full := append(append([]string{}, argv...), cmdArgs...) - cmd := exec.Command(full[0], full[1:]...) - cmd.Dir = home // deterministic cwd for both legs - cmd.Env = []string{ - "PATH=" + os.Getenv("PATH"), - "HOME=" + home, - } - for k, v := range env { - cmd.Env = append(cmd.Env, k+"="+strings.ReplaceAll(v, "@HOME@", home)) - } - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - code := 0 - if err != nil { - var exitErr *exec.ExitError - if ok := errAs(err, &exitErr); ok { - code = exitErr.ExitCode() - } else { - t.Fatalf("running %v: %v", full, err) - } - } - return legResult{exit: code, stdout: stdout.String(), stderr: stderr.String(), tree: treeListing(t, home)} -} - -func errAs(err error, target **exec.ExitError) bool { - e, ok := err.(*exec.ExitError) - if ok { - *target = e - } - return ok -} - -// treeListing renders a home dir as "relpath type mode sha256[:12]" lines, -// mtime-free and root-relative so two legs' trees compare byte-for-byte. -func treeListing(t *testing.T, root string) string { - t.Helper() - var lines []string - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, _ := filepath.Rel(root, p) - if rel == "." { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - switch { - case d.Type()&fs.ModeSymlink != 0: - dest, _ := os.Readlink(p) - lines = append(lines, fmt.Sprintf("%s symlink -> %s", rel, dest)) - case d.IsDir(): - lines = append(lines, fmt.Sprintf("%s dir %o", rel, info.Mode().Perm())) - default: - data, err := os.ReadFile(p) - if err != nil { - return err - } - sum := sha256.Sum256(data) - lines = append(lines, fmt.Sprintf("%s file %o %s", rel, info.Mode().Perm(), hex.EncodeToString(sum[:])[:12])) - } - return nil - }) - if err != nil { - t.Fatalf("walking %s: %v", root, err) - } - sort.Strings(lines) - return strings.Join(lines, "\n") -} - -// normalizeChildEnv filters an `env` dump down to comparable lines: the -// shell leg adds PWD/SHLVL/OLDPWD/_ that the exec'd-direct Go leg does not, -// and HOME differs per leg. -func normalizeChildEnv(out, home string) string { - keep := make([]string, 0, 16) - for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { - switch { - case strings.HasPrefix(line, "PWD="), strings.HasPrefix(line, "OLDPWD="), - strings.HasPrefix(line, "SHLVL="), strings.HasPrefix(line, "_="): - continue - } - keep = append(keep, strings.ReplaceAll(line, home, "@HOME@")) - } - sort.Strings(keep) - return strings.Join(keep, "\n") -} - -// stage writes a staging file with an explicit mode. -func stageParity(t *testing.T, dir, name string, mode os.FileMode, content string) { - t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - p := filepath.Join(dir, name) - if err := os.WriteFile(p, []byte(content), mode); err != nil { - t.Fatal(err) - } - if err := os.Chmod(p, mode); err != nil { - t.Fatal(err) - } -} - -// parityCase is one differential scenario. -type parityCase struct { - name string - // setup stages shared fixtures and returns the MOAT_* env (values may - // use @HOME@) plus the user command. stderrExact compares stderr - // byte-for-byte; envCompare compares the normalized child `env` output - // (the command must then be []string{"env"}). - setup func(t *testing.T, shared string) (env map[string]string, cmd []string) - stderrExact bool - // stderrFramed compares stderr only from the framed "moat:" block on: - // a signal-killed hook makes the SHELL itself print a job-status line - // ("Terminated") before the framed message — incidental shell output, - // not part of the scripted contract. - stderrFramed bool - envCompare bool - wantExit int -} - -func TestShellGoDifferentialParity(t *testing.T) { - if _, err := exec.LookPath("sh"); err != nil { - t.Skip("no sh on PATH") - } - goBin := builtGoEntrypoint(t) - script := scriptPath(t) - xvfbPresent := func() bool { _, err := exec.LookPath("Xvfb"); return err == nil }() - - cases := []parityCase{ - { - name: "baseline env passthrough", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"SOME_USER_VAR": "kept"}, []string{"env"} - }, - envCompare: true, - }, - { - name: "claude staging full allowlist", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - staging := filepath.Join(shared, "claude-init") - stageParity(t, staging, "settings.json", 0o640, `{"s":1}`) - stageParity(t, staging, ".credentials.json", 0o644, `{"token":"x"}`) - stageParity(t, staging, "remote-settings.json", 0o644, `{"r":1}`) - stageParity(t, staging, "stats-cache.json", 0o644, `{}`) - stageParity(t, staging, "CLAUDE.md", 0o644, "ctx") - stageParity(t, staging, ".claude.json", 0o644, `{"ok":true}`) - stageParity(t, filepath.Join(staging, "statsig"), "cache.db", 0o600, "st") - stageParity(t, staging, "stray.txt", 0o644, "must not copy") - stageParity(t, staging, "mcp.json", 0o644, "not on claude allowlist") - return map[string]string{"MOAT_CLAUDE_INIT": staging}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "codex and gemini staging modes", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - codex := filepath.Join(shared, "codex-init") - stageParity(t, codex, "config.toml", 0o644, "cfg") - stageParity(t, codex, "auth.json", 0o644, `{"k":"v"}`) - stageParity(t, codex, "AGENTS.md", 0o644, "agents") - gemini := filepath.Join(shared, "gemini-init") - stageParity(t, gemini, "settings.json", 0o640, `{}`) - stageParity(t, gemini, "oauth_creds.json", 0o644, `{}`) - return map[string]string{"MOAT_CODEX_INIT": codex, "MOAT_GEMINI_INIT": gemini}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "copilot staging", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - st := filepath.Join(shared, "copilot-init") - stageParity(t, st, "config.json", 0o644, `{}`) - stageParity(t, st, "settings.json", 0o600, `{}`) - stageParity(t, st, "permissions-config.json", 0o644, `{}`) - return map[string]string{"MOAT_COPILOT_INIT": st}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "init files multi-record deep chain + env scrub", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - rec := func(path, content string) string { - return path + "\t" + base64.StdEncoding.EncodeToString([]byte(content)) - } - records := rec("@HOME@/.config/deep/nested/tool/config.toml", "secret-one") + "\n" + - rec("@HOME@/.toolrc", "secret-two") + "\n" - return map[string]string{"MOAT_INIT_FILES": records}, []string{"env"} - }, - envCompare: true, - }, - { - name: "clipboard exports DISPLAY", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - if xvfbPresent { - t.Skip("Xvfb installed; skipping to avoid spawning a real X server") - } - return map[string]string{"MOAT_CLIPBOARD": "1"}, []string{"env"} - }, - envCompare: true, - }, - { - name: "git system config identical", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not installed") - } - return map[string]string{ - "GIT_CONFIG_SYSTEM": "@HOME@/system-gitconfig", - "MOAT_GIT_USER_NAME": `Ada "quoted" Lovelace`, - "MOAT_GIT_USER_EMAIL": "ada@example.com", - "MOAT_GIT_SSH_GITHUB": "1", - }, []string{"true"} - }, - stderrExact: true, - }, - { - name: "git insteadOf opt-out", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not installed") - } - return map[string]string{ - "GIT_CONFIG_SYSTEM": "@HOME@/system-gitconfig", - "MOAT_GIT_SSH_GITHUB": "0", - }, []string{"true"} - }, - stderrExact: true, - }, - { - name: "docker mutex fatal", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_DOCKER_DIND": "1", "MOAT_DOCKER_GID": "999"}, []string{"true"} - }, - stderrExact: true, - wantExit: 1, - }, - { - name: "dind silently skipped as non-root", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_DOCKER_DIND": "1"}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "populate root guard fatal", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_WORKSPACE_VOLUME": "1"}, []string{"true"} - }, - stderrExact: true, - wantExit: 1, - }, - { - name: "volume chown skipped as non-root", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_VOLUME_CHOWN": "/nonexistent/vol"}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "pre_run hook success writes marker", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_PRE_RUN": "echo hooked > \"$HOME/.hook-marker\""}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "pre_run hook failure passes literal 42", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_PRE_RUN": "echo doing-setup; exit 42"}, []string{"sh", "-c", "echo SHOULD-NOT-RUN"} - }, - stderrExact: true, - wantExit: 42, - }, - { - name: "pre_run hook signal-killed reports 143", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_PRE_RUN": "kill -TERM $$"}, []string{"true"} - }, - stderrFramed: true, - wantExit: 143, - }, - { - name: "pre_run whitespace-only hook runs", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_PRE_RUN": " "}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "extra hosts malformed entries all skipped", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return map[string]string{"MOAT_EXTRA_HOSTS": "moat-proxy: :1.2.3.4 foo x:x"}, []string{"true"} - }, - stderrExact: true, - }, - { - name: "exec exit code passthrough", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return nil, []string{"sh", "-c", "exit 9"} - }, - stderrExact: true, - wantExit: 9, - }, - { - name: "exec command not found is 127", - setup: func(t *testing.T, shared string) (map[string]string, []string) { - return nil, []string{"definitely-not-a-real-command-xyz"} - }, - wantExit: 127, // stderr wording is tool-generated and differs; codes must match - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - shared := t.TempDir() - env, cmdArgs := tc.setup(t, shared) - - homeSh, homeGo := t.TempDir(), t.TempDir() - sh := runLeg(t, []string{"sh", script}, homeSh, env, cmdArgs) - goL := runLeg(t, []string{goBin}, homeGo, env, cmdArgs) - - if sh.exit != goL.exit { - t.Errorf("exit codes diverge: sh=%d go=%d\nsh stderr:\n%s\ngo stderr:\n%s", - sh.exit, goL.exit, sh.stderr, goL.stderr) - } - if sh.exit != tc.wantExit { - t.Errorf("sh exit = %d, want %d (stderr:\n%s)", sh.exit, tc.wantExit, sh.stderr) - } - if tc.stderrExact { - shErr := strings.ReplaceAll(sh.stderr, homeSh, "@HOME@") - goErr := strings.ReplaceAll(goL.stderr, homeGo, "@HOME@") - if shErr != goErr { - t.Errorf("stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", shErr, goErr) - } - } - if tc.stderrFramed { - frame := func(s string) string { - idx := strings.Index(s, "moat: ") - if idx < 0 { - return s - } - return s[idx:] - } - if frame(sh.stderr) != frame(goL.stderr) { - t.Errorf("framed stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", - frame(sh.stderr), frame(goL.stderr)) - } - } - if tc.envCompare { - shEnv := normalizeChildEnv(sh.stdout, homeSh) - goEnv := normalizeChildEnv(goL.stdout, homeGo) - if shEnv != goEnv { - t.Errorf("child env diverges:\n--- sh ---\n%s\n--- go ---\n%s", shEnv, goEnv) - } - } - if sh.tree != goL.tree { - t.Errorf("home trees diverge:\n--- sh ---\n%s\n--- go ---\n%s", sh.tree, goL.tree) - } - }) - } -} - -// TestShellGoDifferentialResolveFailure is split out (≈10s of real retry -// budget across both legs) and skipped under -short: an unresolvable -// '@'-target must fail closed with the identical three-line error in both -// implementations. -func TestShellGoDifferentialResolveFailure(t *testing.T) { - if testing.Short() { - t.Skip("10s retry budget; skipped with -short") - } - if _, err := exec.LookPath("getent"); err != nil { - t.Skip("getent not installed (script leg needs it)") - } - goBin := builtGoEntrypoint(t) - script := scriptPath(t) - env := map[string]string{"MOAT_EXTRA_HOSTS": "moat-proxy:@nope.invalid"} - - sh := runLeg(t, []string{"sh", script}, t.TempDir(), env, []string{"true"}) - goL := runLeg(t, []string{goBin}, t.TempDir(), env, []string{"true"}) - - if sh.exit != 1 || goL.exit != 1 { - t.Fatalf("exits: sh=%d go=%d, want 1/1", sh.exit, goL.exit) - } - if sh.stderr != goL.stderr { - t.Errorf("stderr diverges:\n--- sh ---\n%q\n--- go ---\n%q", sh.stderr, goL.stderr) - } -} - -// TestShellGoInitFilesInvalidBase64 pins the one sanctioned residue -// divergence on the invalid-payload fatal: both implementations abort -// non-zero before exec, the shell leaves a truncated/empty file behind -// (redirect-then-decode), the Go port decodes to a buffer first and leaves -// nothing. -func TestShellGoInitFilesInvalidBase64(t *testing.T) { - goBin := builtGoEntrypoint(t) - script := scriptPath(t) - env := map[string]string{"MOAT_INIT_FILES": "@HOME@/.sec/cfg\t!!!not-base64!!!"} - - homeSh, homeGo := t.TempDir(), t.TempDir() - sh := runLeg(t, []string{"sh", script}, homeSh, env, []string{"sh", "-c", "echo SHOULD-NOT-RUN"}) - goL := runLeg(t, []string{goBin}, homeGo, env, []string{"sh", "-c", "echo SHOULD-NOT-RUN"}) - - if sh.exit == 0 || goL.exit == 0 { - t.Fatalf("invalid base64 must be fatal: sh=%d go=%d", sh.exit, goL.exit) - } - for name, r := range map[string]legResult{"sh": sh, "go": goL} { - if strings.Contains(r.stdout, "SHOULD-NOT-RUN") { - t.Errorf("%s leg exec'd the command after a fatal init-files record", name) - } - } - // Shell residue: the redirect truncates the file before base64 fails. - if _, err := os.Stat(filepath.Join(homeSh, ".sec/cfg")); err != nil { - t.Errorf("expected the shell leg's partial file (documents the baseline): %v", err) - } - // Go: decode-to-buffer leaves nothing (sanctioned hardening, plan B-P1). - if _, err := os.Stat(filepath.Join(homeGo, ".sec/cfg")); !os.IsNotExist(err) { - t.Error("go leg left a partial secret file behind") - } -} +// These tests pin the two pure-logic parsers that replaced coreutils in the +// Go entrypoint against the real tools they emulate — the last remnants of +// the shell-vs-Go differential harness after the shell entrypoint was +// removed. They do not need the (deleted) entrypoint script: each diffs a +// helper directly against a live `sh`/`base64 -d`. // TestSplitInitRecordMatchesLiveShell differentially checks the record -// splitter against the script's actual `IFS= read -r` loop for a -// corpus of adversarial record shapes. +// splitter against a live `IFS= read -r` loop for a corpus of +// adversarial record shapes. func TestSplitInitRecordMatchesLiveShell(t *testing.T) { if _, err := exec.LookPath("sh"); err != nil { t.Skip("no sh on PATH") diff --git a/internal/run/envguard.go b/internal/run/envguard.go deleted file mode 100644 index a6b6a05b..00000000 --- a/internal/run/envguard.go +++ /dev/null @@ -1,91 +0,0 @@ -package run - -import ( - "fmt" - "strings" - - "github.com/majorcontext/moat/internal/config" -) - -// reservedInitEnvVars are entrypoint-dispatcher controls injected by the moat -// host binary itself: they select which PID-1 implementation runs inside the -// container (see internal/deps/scripts/moat-init-dispatch.sh). A -// user-settable value would be an attack surface — the dispatcher chooses a -// security-critical entrypoint — so these keys are rejected outright from -// moat.yaml env and -e flags. -// -// Unlike isMoatOwnedProxyVar, this rejection is always on: it does not -// depend on whether a proxy is active (a grantless permissive run passes all -// other env through untouched), and it fails the run instead of warning and -// skipping, because silently dropping an explicit entrypoint selection would -// hide from the operator that their switch never applied. -var reservedInitEnvVars = []string{"MOAT_INIT_IMPL", "MOAT_INIT_LEGACY"} - -// isReservedInitVar reports whether name is a reserved entrypoint-dispatcher -// variable. Matching is case-insensitive for consistency with -// isMoatOwnedProxyVar: only the exact-case variable influences the -// dispatcher, but allowing a case-twin through would invite confusion with -// no legitimate use. -func isReservedInitVar(name string) bool { - upper := strings.ToUpper(name) - for _, r := range reservedInitEnvVars { - if upper == r { - return true - } - } - return false -} - -// validateReservedEnv rejects reserved entrypoint-dispatcher variables in -// every user-supplied environment source: moat.yaml env:, moat.yaml -// secrets: (secret KEYS are user-chosen and appended to the container env -// verbatim, so a `secrets: {MOAT_INIT_IMPL: env://X}` entry would otherwise -// smuggle the switch past the env guard), and -e/--env flags. An -e entry -// without '=' is a host-passthrough form and is matched on its full name. -func validateReservedEnv(cfg *config.Config, explicitEnv []string) error { - if cfg != nil { - for k := range cfg.Env { - if isReservedInitVar(k) { - return reservedEnvError(k, "moat.yaml env") - } - } - for k := range cfg.Secrets { - if isReservedInitVar(k) { - return reservedEnvError(k, "moat.yaml secrets") - } - } - } - for _, e := range explicitEnv { - name := e - if idx := strings.IndexByte(e, '='); idx >= 0 { - name = e[:idx] - } - if isReservedInitVar(name) { - return reservedEnvError(name, "-e flag") - } - } - return nil -} - -// operatorInitEnv returns the entrypoint-dispatcher variables to inject -// into the container, read from the moat PROCESS's own environment — the -// operator-only channel. Users cannot set these through moat.yaml or -e -// (validateReservedEnv rejects them); an operator exports them on the host -// to select the entrypoint implementation: the parity harness drives both -// legs this way, and MOAT_INIT_LEGACY=1 is the one-release rollback lever -// after the Go cutover. -func operatorInitEnv(getenv func(string) string) []string { - var env []string - for _, key := range reservedInitEnvVars { - if v := getenv(key); v != "" { - env = append(env, key+"="+v) - } - } - return env -} - -func reservedEnvError(name, source string) error { - return fmt.Errorf("%s is reserved for moat's entrypoint dispatcher and cannot be set via %s.\n"+ - "It selects which container entrypoint implementation runs and is managed by moat itself.\n"+ - "Remove %s from your configuration and re-run", name, source, name) -} diff --git a/internal/run/envguard_test.go b/internal/run/envguard_test.go deleted file mode 100644 index c00a8a22..00000000 --- a/internal/run/envguard_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package run - -import ( - "strings" - "testing" - - "github.com/majorcontext/moat/internal/config" -) - -func TestValidateReservedEnv(t *testing.T) { - tests := []struct { - name string - cfg *config.Config - env []string - wantErr string // substring; "" = no error - }{ - {"nil config, no env", nil, nil, ""}, - {"benign config env", &config.Config{Env: map[string]string{"FOO": "bar", "MOAT_LIKE_BUT_NOT": "x"}}, nil, ""}, - {"benign -e env", nil, []string{"FOO=bar", "BAZ"}, ""}, - {"MOAT_INIT_IMPL in config env", &config.Config{Env: map[string]string{"MOAT_INIT_IMPL": "go"}}, nil, "MOAT_INIT_IMPL is reserved"}, - {"MOAT_INIT_LEGACY in config env", &config.Config{Env: map[string]string{"MOAT_INIT_LEGACY": "1"}}, nil, "MOAT_INIT_LEGACY is reserved"}, - {"MOAT_INIT_IMPL in -e", nil, []string{"MOAT_INIT_IMPL=go"}, "MOAT_INIT_IMPL is reserved"}, - {"MOAT_INIT_LEGACY in -e", nil, []string{"MOAT_INIT_LEGACY=1"}, "MOAT_INIT_LEGACY is reserved"}, - // -e NAME without '=' is the host-passthrough form; it still injects - // the variable, so it must be rejected too. - {"bare -e passthrough", nil, []string{"MOAT_INIT_IMPL"}, "MOAT_INIT_IMPL is reserved"}, - // Case-insensitive, consistent with isMoatOwnedProxyVar. - {"lowercase in -e", nil, []string{"moat_init_impl=go"}, "is reserved"}, - // Companion: an empty value is still an injection attempt. - {"empty value in -e", nil, []string{"MOAT_INIT_IMPL="}, "MOAT_INIT_IMPL is reserved"}, - // Companion: prefix/suffix near-misses are not reserved. - {"near-miss names pass", &config.Config{Env: map[string]string{"MOAT_INIT_IMPL_X": "1", "XMOAT_INIT_IMPL": "1"}}, []string{"MOAT_INIT=1"}, ""}, - // Secret KEYS are user-chosen and land in the container env - // verbatim — the guard must cover them too. - {"MOAT_INIT_IMPL as secret key", &config.Config{Secrets: map[string]string{"MOAT_INIT_IMPL": "env://X"}}, nil, "MOAT_INIT_IMPL is reserved"}, - {"MOAT_INIT_LEGACY as secret key", &config.Config{Secrets: map[string]string{"moat_init_legacy": "env://X"}}, nil, "is reserved"}, - // Companion: benign secret keys pass. - {"benign secret keys pass", &config.Config{Secrets: map[string]string{"API_KEY": "env://X"}}, nil, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateReservedEnv(tt.cfg, tt.env) - if tt.wantErr == "" { - if err != nil { - t.Fatalf("validateReservedEnv() = %v, want nil", err) - } - return - } - if err == nil { - t.Fatalf("validateReservedEnv() = nil, want error containing %q", tt.wantErr) - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) - } - }) - } -} - -// TestOperatorInitEnv covers the operator-only injection channel: host -// process env in, container env entries out — and the companion, nothing -// injected when the host env is clean. -func TestOperatorInitEnv(t *testing.T) { - host := map[string]string{"MOAT_INIT_IMPL": "go", "MOAT_INIT_LEGACY": "", "PATH": "/bin"} - got := operatorInitEnv(func(k string) string { return host[k] }) - if len(got) != 1 || got[0] != "MOAT_INIT_IMPL=go" { - t.Errorf("operatorInitEnv = %v, want [MOAT_INIT_IMPL=go]", got) - } - if got := operatorInitEnv(func(string) string { return "" }); len(got) != 0 { - t.Errorf("clean host env injected %v", got) - } - if got := operatorInitEnv(func(k string) string { - if k == "MOAT_INIT_LEGACY" { - return "1" - } - return "" - }); len(got) != 1 || got[0] != "MOAT_INIT_LEGACY=1" { - t.Errorf("operatorInitEnv = %v, want [MOAT_INIT_LEGACY=1]", got) - } -} - -// TestReservedInitVarsUnfiltered pins the division of labor: the reserved -// dispatcher vars are NOT part of the proxy-var filter (which only runs when -// a proxy is active and warns-and-skips). They must be rejected by -// validateReservedEnv regardless of proxy state, so adding them to -// isMoatOwnedProxyVar would silently weaken the guard. -func TestReservedInitVarsUnfiltered(t *testing.T) { - for _, name := range reservedInitEnvVars { - if isMoatOwnedProxyVar(name) { - t.Errorf("%s is in isMoatOwnedProxyVar; it must stay under the always-on validateReservedEnv guard instead", name) - } - if !isReservedInitVar(name) { - t.Errorf("isReservedInitVar(%s) = false", name) - } - } -} diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index ceef58bd..cbff94e0 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -107,13 +107,6 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } } - // Reject reserved entrypoint-dispatcher variables before any resources are - // staged. Always on — unlike the isMoatOwnedProxyVar filter below, this - // must hold for grantless/proxyless runs too. - if err := validateReservedEnv(opts.Config, opts.Env); err != nil { - return nil, err - } - opts.Grants = normalizeCopilotGrantNames(opts.Grants) if opts.Config != nil { opts.Config.Grants = normalizeCopilotGrantNames(opts.Config.Grants) @@ -909,10 +902,6 @@ region = %s proxyEnv = append(proxyEnv, "MOAT_CLIPBOARD=1", "DISPLAY=:99") } - // Forward the operator-only entrypoint dispatcher controls from the moat - // process's own environment (user-supplied sources are rejected above). - proxyEnv = append(proxyEnv, operatorInitEnv(os.Getenv)...) - // Add explicit env vars (highest priority - can override config), // but filter proxy-related vars when proxy is active. for _, e := range opts.Env { From eb16916bdb8d97d214653e3e1b9e1b198a91fb8c Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Fri, 17 Jul 2026 04:55:12 +0000 Subject: [PATCH 16/17] build: make build-cli self-clean the regenerated init blobs + guard against committing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the footgun where 'make build-cli' (which runs go generate to cross-compile the real ~2.5 MB moat-init binaries over the committed stubs) left those artifacts as tracked-file modifications, one 'git add' away from being committed. - build, build-cli, and test-e2e now regenerate the binaries, run their work, then restore the committed stubs — even if the build/test fails (the stubs are restored regardless, and the failure exit code still surfaces). The real binaries are baked into the built artifact at compile time, so reverting the source blobs afterward does not change what was built. generate-init / restore-init-stubs remain for the manual 'go test -tags=e2e' path. - TestCommittedBlobsAreStubs is a commit guard: it fails if the tracked embed blobs are real binaries rather than stubs. This closes the gap TestEmbeddedChecksums leaves — that test passes when a real binary is committed alongside its regenerated checksum (exactly the state the footgun produced); the guard fails on it. A clean checkout (CI, any commit) always carries stubs, so the guard only fires on the mistake. Verified: build-cli produces ./moat and leaves embed/ clean; a failing build still restores the stubs; the guard fails on real binaries and passes on stubs. --- Makefile | 32 +++++++++++++++++++++++--------- internal/initbin/initbin_test.go | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 3cd425db..ca4c61d0 100644 --- a/Makefile +++ b/Makefile @@ -15,25 +15,39 @@ help: ## Show this help message @echo " make test-unit ARGS='-run TestName' # Run specific unit test" @echo " make test-unit ARGS='-run TestName ./internal/proxy'" # Run test in specific package" -build: generate-init ## Build the project (regenerates the embedded moat-init binaries) - go build ./... +# The committed moat-init entrypoint blobs (tracked as fail-closed stubs so a +# fresh clone compiles). `go generate` overwrites them with the real +# cross-compiled binaries; the build/test targets below restore the stubs +# afterward so the real ~2.5 MB artifacts never linger as tracked-file +# modifications waiting to be committed by accident. Restoring the source +# stubs is safe because the binaries are baked into the built artifact at +# compile time — reverting the embed files does not change what was built. +INIT_STUBS := internal/initbin/embed internal/initbin/checksums.txt -build-cli: generate-init ## Build the CLI binary ./moat - go build -ldflags "-s -w -X github.com/majorcontext/moat/cmd/moat/cli.version=dev -X github.com/majorcontext/moat/cmd/moat/cli.commit=$$(git rev-parse --short HEAD) -X github.com/majorcontext/moat/cmd/moat/cli.date=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o moat ./cmd/moat +build: ## Build the project (regenerates the embedded moat-init binaries, then restores the committed stubs) + @go generate ./internal/initbin && go build ./...; rc=$$?; \ + git checkout -- $(INIT_STUBS); exit $$rc -generate-init: ## Cross-compile cmd/moat-init into internal/initbin/embed (over the committed stubs) +build-cli: ## Build the CLI binary ./moat (regenerates the embedded moat-init binaries, then restores the committed stubs) + @go generate ./internal/initbin && \ + go build -ldflags "-s -w -X github.com/majorcontext/moat/cmd/moat/cli.version=dev -X github.com/majorcontext/moat/cmd/moat/cli.commit=$$(git rev-parse --short HEAD) -X github.com/majorcontext/moat/cmd/moat/cli.date=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o moat ./cmd/moat; rc=$$?; \ + git checkout -- $(INIT_STUBS); exit $$rc + +generate-init: ## Cross-compile cmd/moat-init into internal/initbin/embed (over the committed stubs; run 'make restore-init-stubs' before committing) go generate ./internal/initbin -restore-init-stubs: ## Restore the committed moat-init stub blobs after a local build - git checkout -- internal/initbin/embed internal/initbin/checksums.txt +restore-init-stubs: ## Restore the committed moat-init stub blobs after a manual generate-init + git checkout -- $(INIT_STUBS) test: test-unit test-e2e test-bats ## Run all tests (unit + E2E + hooks) test-unit: ## Run unit tests with race detector (use ARGS for filtering, e.g., ARGS='-run TestName') go test -race $(ARGS) ./... -test-e2e: generate-init ## Run E2E tests (use ARGS for filtering, e.g., ARGS='-run TestName') - go test -tags=e2e -timeout=30m $(ARGS) ./internal/e2e/ +test-e2e: ## Run E2E tests (regenerates the embedded moat-init binaries, then restores the committed stubs) + @go generate ./internal/initbin && \ + go test -tags=e2e -timeout=30m $(ARGS) ./internal/e2e/; rc=$$?; \ + git checkout -- $(INIT_STUBS); exit $$rc test-bats: ## Run bats tests for Claude Code hooks @which bats > /dev/null || (echo "bats not installed. Install from https://github.com/bats-core/bats-core" && exit 1) diff --git a/internal/initbin/initbin_test.go b/internal/initbin/initbin_test.go index 92934b23..10011387 100644 --- a/internal/initbin/initbin_test.go +++ b/internal/initbin/initbin_test.go @@ -66,6 +66,28 @@ func TestIsStub(t *testing.T) { } } +// TestCommittedBlobsAreStubs is the commit guard: the embed blobs tracked in +// git must be the fail-closed stubs, never the real cross-compiled binaries +// (which are ~2.5 MB build artifacts, not source). `go generate` overwrites +// them during a build; the build/test Makefile targets restore them +// afterward, so a clean checkout — and therefore CI and any commit — always +// carries stubs. +// +// This closes the gap TestEmbeddedChecksums leaves: that test only checks the +// blobs match checksums.txt, so a real binary committed together with its +// regenerated checksum passes it. This test fails on that state. +// +// If this fails locally, you have generated real binaries in your tree (e.g. +// via `make generate-init` or a bare `go generate`): run `make +// restore-init-stubs` before committing. +func TestCommittedBlobsAreStubs(t *testing.T) { + for _, arch := range []string{"amd64", "arm64"} { + if !IsStub(BinaryFor(arch)) { + t.Errorf("embed/moat-init-linux-%s is a real binary, not the committed stub — run 'make restore-init-stubs' before committing (the real binaries are build artifacts, baked into ./moat at compile time, and must never be committed)", arch) + } + } +} + // TestStubFailsClosed pins the fail-closed contract of the committed stub: // it must be a shell script that prints a FATAL message and exits 1, never // a silent no-op that would start the user command as root. From 73f8b9900ff9ba7f4e1e0fb4cfacb1686c007d8a Mon Sep 17 00:00:00 2001 From: Dan Pupius Date: Fri, 17 Jul 2026 21:00:26 +0000 Subject: [PATCH 17/17] docs(changelog): fill the moat-init entrypoint PR link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6d7bb9..c7ebb7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added -- **Go container entrypoint** — the `moat-init` container entrypoint has been rewritten from a 611-line shell script to a Go binary (`internal/moatinit`, embedded and shipped as `/usr/local/bin/moat-init`), with the shell behavior as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. The rewrite lifts the entrypoint's logic (env parsing, branch/mode selection, ordering, error classification, exclude computation, privilege-drop selection) into unit-testable Go while still delegating the mechanical, security-sensitive steps to the audited tools already in the image (`gosu` for the privilege drop, `socat` for the SSH bridge, `tar` for the workspace copy). It adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init --plan`). Image cache keys are re-salted, so cached run images rebuild once; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +- **Go container entrypoint** — the `moat-init` container entrypoint has been rewritten from a 611-line shell script to a Go binary (`internal/moatinit`, embedded and shipped as `/usr/local/bin/moat-init`), with the shell behavior as the contract: same phase ordering, same fail-closed vs best-effort classification, verbatim error wording. The rewrite lifts the entrypoint's logic (env parsing, branch/mode selection, ordering, error classification, exclude computation, privilege-drop selection) into unit-testable Go while still delegating the mechanical, security-sensitive steps to the audited tools already in the image (`gosu` for the privilege drop, `socat` for the SSH bridge, `tar` for the workspace copy). It adds `--plan`, a side-effect-free dry-run that prints the ordered actions the entrypoint would take for the current environment (`moat exec -- /usr/local/bin/moat-init --plan`). Image cache keys are re-salted, so cached run images rebuild once; workflows pinning a concrete `moat/run:` tag must re-tag/rebuild. See [Sandboxing](https://majorcontext.com/moat/concepts/sandboxing). ([#441](https://github.com/majorcontext/moat/pull/441)) - **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's Copilot settings file (`$COPILOT_HOME/settings.json` when set, otherwise `~/.copilot/settings.json`; contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). Legacy `colorMode` values are written as the current `theme` setting. An optional `~/.moat/copilot/settings.json` provides moat-specific overrides that win over host settings. Settings that execute commands (`statusLine`) are only allowed from the moat override file. CLI flags and `moat.yaml` fields take precedence over settings.json values. ([#438](https://github.com/majorcontext/moat/pull/438)) - **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434))