Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
71bad07
docs(plans): add moat-init shell→Go rewrite design & plan
dpup Jul 6, 2026
6b4b159
feat(moatinit): scaffold Go entrypoint, dual-ship dispatcher, embedde…
dpup Jul 16, 2026
37140d1
feat(moatinit): pure-logic phase decisions with unit tests
dpup Jul 16, 2026
39d455f
feat(moatinit): filesystem phases — hosts, agent staging, init files,…
dpup Jul 16, 2026
527aae3
feat(moatinit): workspace-volume populate — Go-owned tar pipe logic +…
dpup Jul 16, 2026
c2e26a5
feat(moatinit): long-lived children (socat/Xvfb/dockerd) + pre-run hook
dpup Jul 16, 2026
711e086
feat(moatinit): exec dispatch — branch selection + gosu privilege dro…
dpup Jul 16, 2026
15304d6
feat(moatinit): --plan dry-run + golden fatal-error contract
dpup Jul 16, 2026
7886e07
feat(e2e): entrypoint parity harness, gap fixtures, and dispatcher pr…
dpup Jul 16, 2026
f8e7b58
docs: document the moat-init Go entrypoint, dispatcher, and --plan dr…
dpup Jul 16, 2026
bb89582
fix(moatinit): address adversarial-review findings
dpup Jul 16, 2026
56f40b6
refactor(deps): rename the shipped Go entrypoint binary to moat-commit
dpup Jul 16, 2026
ba33427
test(moatinit): differential shell-vs-Go pressure harness + edge-case…
dpup Jul 16, 2026
f2d76ed
refactor(deps): name the shipped Go entrypoint binary moat-init
dpup Jul 16, 2026
aedef65
refactor(moatinit): remove the shell entrypoint and dispatcher; Go bi…
dpup Jul 17, 2026
eb16916
build: make build-cli self-clean the regenerated init blobs + guard a…
dpup Jul 17, 2026
114f0e2
Merge remote-tracking branch 'origin/main' into feat/moat-init-go-rew…
dpup Jul 17, 2026
73f8b99
docs(changelog): fill the moat-init entrypoint PR link
dpup Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +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 <run> -- /usr/local/bin/moat-init --plan`). Image cache keys are re-salted, so cached run images rebuild once; workflows pinning a concrete `moat/run:<hash>` 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))
Expand Down
34 changes: 27 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,19 +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: ## Build the project
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: ## 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

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 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: ## 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)
Expand Down
45 changes: 45 additions & 0 deletions cmd/moat-init/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 (
"fmt"
"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:],
Stdin: os.Stdin,
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{"<command>"}
}
for _, line := range moatinit.Plan(ctx) {
fmt.Println(line)
}
return
}

os.Exit(moatinit.Run(ctx))
}
10 changes: 10 additions & 0 deletions docs/content/concepts/01-sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ 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 (`/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.

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 <agent> -- /usr/local/bin/moat-init --plan
```

## Limitations

Container isolation is not a security boundary against a determined attacker. It provides:
Expand Down
Loading
Loading