From a0c5698ab4cb21adad24456fd8832d94b1d6137a Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Thu, 17 Sep 2026 18:29:23 -0400 Subject: [PATCH 1/5] linux plan --- PLAN-LINUX.md | 443 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 11 +- 2 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 PLAN-LINUX.md diff --git a/PLAN-LINUX.md b/PLAN-LINUX.md new file mode 100644 index 0000000..a8aed4f --- /dev/null +++ b/PLAN-LINUX.md @@ -0,0 +1,443 @@ +# ABox Linux Support — libkrun/KVM Host Port + +## Context + +ABox runs only on Apple Silicon today. `cmd/abox-vmm/start_darwin_arm64.go` is +tagged `darwin && arm64`, `start_stub.go` refuses every other platform, and +`internal/runtime/runtime.go:930` calls `unix.Clonefile`, which exists only in +`zsyscall_darwin_{amd64,arm64}.go` — so `internal/runtime` does not compile on +Linux at all. `go build ./cmd/abox` and `make test` fail before reaching any +VMM concern. + +Every isolation primitive ABox depends on is, however, *more* native on Linux +than on macOS: libkrun's primary backend is KVM, and Hypervisor.framework is the +newer target. Verification against the real `libkrun 1.19.4` x86_64 package +(Sept 2026) shows **all fifteen libkrun symbols `start_darwin_arm64.go` calls +exist in the Linux build with identical semantics**, including +`krun_add_vsock(ctx, 0)`, whose header documents "Use 0 to add vsock without any +TSI hijacking." The device plan — no NIC, no TSI inet, no host-path virtio-fs, +two raw disks, one vsock port — transfers verbatim. + +This is therefore a port of build plumbing and host-side syscalls, **not** of the +isolation design. The agent loop, protocol, brokers, credential resolution, +session layout, and guest image contents are unchanged. + +**Goal:** `abox` runs natively on Arch and Fedora (x86_64) with the security +posture preserved, and `make image` builds the guest disk with no Docker, no +root, and no privileged container. + +### User decisions (Sept 2026) + +- **Distros:** Arch and Fedora supported and tested. Debian/Ubuntu documented as + a source build with exact commands, explicitly marked untested — neither ships + prebuilt libkrun/libkrunfw packages. +- **Image builder:** rootless native on Linux; macOS keeps its Docker packer. +- **CPU arch:** x86_64 first, parameterized by `GOARCH` so linux/arm64 + (Fedora Asahi) is a configuration change rather than a rewrite. +- **Local keystore:** Secret Service via libsecret (`secret-tool`), covering + GNOME Keyring, KWallet (`org.kde.secretservicecompat`) and KeePassXC. +- **Config naming:** new portable `keystore` source name, with `keychain` and + `secretservice` accepted as aliases so existing configs keep working. +- **Headless policy:** plaintext fallback is preserved so nothing breaks, but it + now warns rather than degrading silently. + +### Credential parity (in scope) + +Of the five credential sources, **four already work on Linux unchanged**. +`env`, `vault`, `azure`, and `aws` contain no `GOOS`/darwin/macOS-path +references — a direct consequence of PLAN-CRED.md global decision 2 (stdlib +HTTP or CLI subprocess only, no SDKs, no cgo). Only `keychain` +(internal/credsource/keychain.go:23, hardcoded `/usr/bin/security`) is +macOS-only. + +The gap is therefore the *local, no-infrastructure* keystore — exactly ABox's +laptop-local thesis. Standing up Vault to hold one API key is absurd for that +user, yet on Linux today `SavePreferred` (internal/credsource/save.go:41) sees +`KeychainEnabled() == false` and writes every `/provider` and `/mcp` key to +plaintext `credentials.env`. Closing that is in scope. + +Credential sources are siblings, not a stack: `Resolver.Resolve` performs a +single `r.sources[ref.Source]` lookup with no chaining, so each model or MCP +server names exactly one source and different entries may name different ones. +Adding a local keystore does not alter the cloud sources in any way. + +**Deferred:** `systemd-creds` (TPM2-sealed, `--user` scoped; systemd 261 and +/dev/tpm0 confirmed on the dev host) and `pass`. Cloud sources are the +documented headless answer instead. + +## Global decisions + +1. **No protocol change.** `protocol.Version` stays 4. Host/guest framing, the + `vmmconfig.Config` stdin contract, `session.WritePaddedConfig`, and the guest + image contents are platform-independent and untouched. +2. **cgo flags split from cgo logic.** cgo accumulates `#cgo` directives + package-wide across files in a package, so the ~90-line `startVM` body is + shared and only the preamble files are platform-tagged. No duplicated binding. +3. **Linux links `-lkrun` only, via pkg-config.** libkrun `dlopen`s + `libkrunfw.so.5` at runtime on Linux (confirmed: no undefined `krunfw_*` + symbols in `libkrun.so.1.19.4`), so `-lkrunfw` is omitted. `#cgo pkg-config: + libkrun` resolves correctly under pacman, dnf, and a `make install` into + `/usr/local`; macOS keeps its explicit Homebrew paths and `-lkrunfw`. +4. **`cloneFile` becomes platform-split, and gains a Linux fast path.** + `unix.IoctlFileClone` (FICLONE reflink; btrfs, XFS `reflink=1`, bcachefs) then + `unix.CopyFileRange` (in-kernel copy, works on ext4) then the existing + `io.Copy` fallback. Fedora defaults to btrfs, so the 768 MiB per-session + golden-image clone is near-instant there. +5. **The golden image filename carries its architecture.** An arm64 rootfs booted + under an x86_64 kernel is an unexplained guest panic; a silent failure mode is + unacceptable. `config.GuestImageName` (internal/config/config.go:17) becomes a + function of `runtime.GOARCH`, with the bare `abox-guest.raw` kept as a legacy + fallback in `ImageDir()`, mirroring the existing `~/Library/Caches` lookup. +6. **`/dev/vhost-vsock` is not a requirement.** libkrun implements virtio-vsock + in userspace and maps guest ports onto host Unix sockets via + `krun_add_vsock_port` — exactly how `sess.RPCSocket()` already works. There + are zero `vhost` or `/dev/vsock` references in the library. No vsock kernel + module, no `CONFIG_VHOST_VSOCK`, no CID allocation. +7. **Linux isolation claims start at Planned, independently of macOS.** Per + `AGENTS.md`, claims stay Planned until the hardware suite passes. macOS + evidence must not implicitly cover a KVM backend. +8. **The OS keystore is selected at runtime by `runtime.GOOS`, not by build + tag**, matching the existing `securityToolAvailable(goos, mode)` pattern + (internal/credsource/keychain.go:42) so unit tests stay platform-independent + and table-driven. +9. **Secrets never appear in argv.** `secret-tool store` takes the value on + stdin, written with **no trailing newline** — the man page warns that a piped + newline is stored as part of the password. Attributes reuse the existing + `KeychainService = "abox"` constant: `service abox account `, + mirroring the macOS `-s`/`-a` pair exactly. +10. **`ErrNotFound` vs `ErrLocked` needs a separate availability probe.** + `secret-tool` returns non-zero for both "no such item" and "no service", so + exit code alone cannot distinguish them the way macOS exit code 44 does. + Availability = `secret-tool` on PATH plus a reachable session bus. This + preserves the distinction `SavePreferred` depends on for its fallback. + +## Verified findings (Sept 2026) + +Checked against `extra/libkrun 1.19.4-1` and `extra/libkrunfw 5.5.0-1`, and +against `golang.org/x/sys@v0.47.0` as pinned in `go.mod`. + +| Claim | Evidence | +| --- | --- | +| All 15 libkrun symbols ABox calls exist on Linux x86_64 | `libkrun.h` + `nm -D` on `libkrun.so.1.19.4` | +| `KRUN_FEATURE_BLK` is compiled in | `RegisterBlockDevice` / `OpenBlockDevice` / `AttachBlockDevice` present in the `.so` | +| `-lkrunfw` unnecessary on Linux | libkrun `dlopen`s `libkrunfw.so.5`; no undefined `krunfw_*` | +| vsock is userspace; `/dev/vhost-vsock` unused | No `vhost` / `/dev/vsock` strings | +| KVM is the backend | 3 `/dev/kvm` refs; reads `/sys/module/kvm_*/parameters/nested` | +| `mke2fs -d` builds ext4 unprivileged | Ran as uid 1000, exit 0, no loop mount, no Docker | +| `fakeroot` gives correct `0:0` ownership | `debugfs -R "ls -l"` shows `0 0`, versus `1000 1000` without it | +| `unix.Clonefile` is darwin-only | Defined only in `zsyscall_darwin_{amd64,arm64}.go` | +| Linux reflink helpers available | `IoctlFileClone` ioctl_linux.go:184; `CopyFileRange` zsyscall_linux.go:635 | +| `vault` / `azure` / `aws` are platform-clean | No `GOOS`/darwin/macOS-path references in those three files | +| `azure` CLI fallback is portable | `exec.LookPath("az")`, not a hardcoded macOS path | +| `secret-tool store` takes the secret on stdin | `man secret-tool` STORE; warns a piped newline becomes part of the secret | +| Missing key exits 1 with empty stdout | Probed `secret-tool lookup` directly on the dev host | +| Exit codes cannot separate not-found from unavailable | `man secret-tool` EXIT STATUS: "0 on success, a non-zero failure code otherwise" | +| Secret Service is live on the dev host | `gnome-keyring-daemon --components=pkcs11,secrets` owns `org.freedesktop.secrets` | +| KWallet / KeePassXC use the same API | `org.kde.secretservicecompat` activatable on the session bus | + +## Platform surface + +Six Go touchpoints, two scripts, the Makefile, and CI. Already portable and +untouched: `protocol/`, all brokers, `internal/agent`, `internal/session`, +`internal/vmmconfig`, and `config.Dir()` (`~/.abox`, with `ABOX_HOME` override — +the `~/Library/...` lookups are legacy read-only fallbacks that no-op on Linux). + +| Location | Issue | +| --- | --- | +| `internal/runtime/runtime.go:930` | `unix.Clonefile` — **compile break**, darwin-only | +| `cmd/abox-vmm/start_darwin_arm64.go` | Body portable; only `#cgo` paths are Homebrew-specific | +| `cmd/abox-vmm/start_stub.go:1` | Tag `!darwin \|\| !arm64` swallows Linux | +| `internal/runtime/runtime.go:288` | `DYLD_LIBRARY_PATH` in the fixed `cmd.Env` | +| `internal/credsource/keychain.go:23,43` | Hardcoded `/usr/bin/security` — the only credential-source gap on Linux | +| `cmd/abox/creds.go:35` | macOS-only error copy | +| `images/build-guest.sh`, `images/update-guest-bin.sh` | Hardcoded `-linux-arm64`; `--privileged` loop mount | +| `Makefile` | `codesign` unconditional; guest pinned to `GOARCH=arm64` | +| `.github/workflows/test.yml` | `macos-latest` only | + +--- + +## Phase 0 — Spike: boot a VM before changing anything + +Throwaway code. Nothing downstream matters if this fails. + +```bash +sudo pacman -S libkrun libkrunfw fakeroot # Arch +sudo dnf install libkrun libkrunfw fakeroot # Fedora +``` + +1. Copy `start_darwin_arm64.go` to `start_linux.go`, tag `//go:build linux`, + replace the preamble with `#cgo pkg-config: libkrun`. +2. Temporarily reduce `cloneFile` to its `io.Copy` path so `internal/runtime` + compiles. Phase 1 does this properly. +3. Build an amd64 guest, hand-pack a rootfs with the Phase 3 recipe, run + `abox --probe-vm`. + +**Exit criterion:** `--probe-vm` prints the guest file listing. + +Watch for: `krun_has_feature(KRUN_FEATURE_BLK) == 1`; `krun_add_vsock_port` +connecting to the host's listening Unix socket in the same direction as macOS; +`krun_set_root_disk_remount` behaving identically. + +--- + +## Phase 1 — Compile on Linux, and a portable libkrun binding + +**1a. Fix the `cloneFile` compile break** (internal/runtime/runtime.go:928). +Split by platform, keeping the existing `io.Copy` tail shared: + +- `internal/runtime/clone_darwin.go` — `unix.Clonefile` fast path +- `internal/runtime/clone_linux.go` — `unix.IoctlFileClone`, then + `unix.CopyFileRange`, then fallback +- `internal/runtime/clone_other.go` — fallback only + +`Prepare` (internal/runtime/runtime.go:224) keeps its current signature and +semantics; only the copy mechanism changes. + +**1b. Portable cgo binding** in `cmd/abox-vmm/`: + +- `cgoflags_darwin_arm64.go` — `//go:build darwin && arm64`; preamble only, + carrying today's Homebrew include/lib paths, `-lkrun -lkrunfw`, and rpath +- `cgoflags_linux.go` — `//go:build linux && (amd64 || arm64)`; preamble only: + `#cgo pkg-config: libkrun` +- `start_libkrun.go` — the shared `startVM`, tagged for both platforms +- `start_stub.go` — retagged to exclude both; message rewritten to name the + actual per-OS requirement rather than asserting macOS + +**1c.** Make the fixed `cmd.Env` at internal/runtime/runtime.go:288 +platform-conditional; drop `DYLD_LIBRARY_PATH` on Linux, where `/usr/lib` is +already on the default search path. + +**1d.** Reword cmd/abox/creds.go:35 so the message names the platform's actual +keystore situation instead of asserting macOS. + +--- + +## Phase 2 — Multi-arch guest and image identity + +- `Makefile`: derive `GUEST_ARCH` from `go env GOARCH` (overridable); build + `bin/abox-guest-linux-$(GUEST_ARCH)`. +- `Makefile`: guard the `vmm` target's `$(MAKE) sign` with + `$(filter darwin,...)` so `codesign` is a no-op off darwin. +- Arch-tag the golden image per Global decision 5. +- `runtime.Prepare`: refuse an architecture mismatch with an explicit error + rather than booting into a kernel panic. This is the natural insertion point + for the existing roadmap item *"Image manifest + SHA-256 verify"* — the + manifest should carry the architecture. + +--- + +## Phase 3 — Rootless native image builder (Linux) + +Replaces Docker, `--privileged`, and `mount -o loop` entirely on Linux. Verified +working. `images/build-guest.sh` selects on `uname`; the macOS path is unchanged, +because nothing else can build a Linux ARM64 tree there. + +```sh +fakeroot sh -c ' + apk.static --root "$ROOTFS" --initdb --keys-dir "$KEYS" add alpine-base git patch + install -Dm0755 bin/abox-guest-linux-$ARCH "$ROOTFS/usr/local/bin/abox-guest" + mkdir -p "$ROOTFS/work/repo" "$ROOTFS/abox-config" "$ROOTFS/tmp" + printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions ndots:1\n" > "$ROOTFS/etc/resolv.conf" + mke2fs -q -F -t ext4 -d "$ROOTFS" -b 4096 "$OUT" 768M +' +``` + +`apk.static` is a static binary that runs on any distribution; fetch it from the +Alpine CDN pinned by SHA-256. `fakeroot` is required — without it `mke2fs -d` +stamps the invoking uid, producing a rootfs owned by `1000:1000`. Rootfs contents +must stay byte-identical in spirit to the Docker path: alpine-base, git, patch, +`/usr/local/bin/abox-guest`, `/work/repo`, `/abox-config`, resolv.conf. + +`images/update-guest-bin.sh`: on Linux, replace the binary in place with +`debugfs -w -R "rm /usr/local/bin/abox-guest"` followed by `-R "write ..."` +(unprivileged), or simply rebuild — it is fast. + +This advances the existing README note that replacing the packer is follow-up +work, and removes `--privileged` from the flow of a project whose thesis is +isolation. Worth stating plainly in the README. + +--- + +## Phase 4 — Credential parity: OS keystore on Linux + +Goal: `/provider` and `/mcp` stop writing plaintext on a Linux desktop. Scope is +the local keystore only — `vault`, `azure`, and `aws` already work untouched. + +**4a. Generalize the keystore abstraction.** `internal/credsource/keychain.go` +becomes an OS-keystore dispatch selected at runtime by GOOS (Global decision 8). +The `Source` interface and `Resolver` wiring are unchanged: + +- an `osKeystore` interface — `Available() bool`, `Get`/`Set`/`Delete(ctx, name)` +- `keychainStore` — today's `security(1)` implementation, moved intact +- `secretServiceStore` — new, `secret-tool` subprocess +- package-level `KeychainAvailable`/`SetKeychain`/`DeleteKeychain` become + `OSKeystoreAvailable`/`SetOSKeystore`/`DeleteOSKeystore`; update the call sites + at save.go:14,26,36 and cmd/abox/creds.go:17-18 + +**4b. Secret Service implementation.** Reuses `KeychainService = "abox"`: + +- resolve — `secret-tool lookup service abox account `; capture stdout raw + and **do not** `TrimSpace`, since the stored value is byte-exact and no tty + newline is appended when piped (this differs from the macOS path, where + `security -w` does append one) +- set — `secret-tool store --label "abox: " service abox account ` + with the value on stdin and no trailing newline +- delete — `secret-tool clear service abox account ` +- validate with `config.ValidEnvName(ref.Name)` before shelling out, exactly as + `keychainSource.Resolve` (keychain.go:47) already does — this is what makes + attribute injection impossible +- map errors per Global decision 10; never include a secret value in an error, + per the `Source` contract + +**4c. Config surface.** `credentialSources` (internal/config/config.go:304) gains +`keystore` and `secretservice`; `CredentialRef.validate` (config.go:312) +normalizes all three spellings to one canonical source. `NewResolver` +(internal/credsource/credsource.go) registers the OS keystore under each accepted +alias so `source: keychain` keeps resolving on both platforms. + +**4d. Fallback warning.** `SaveResult.Note` already carries a human-readable +string (save.go:34,40,46). Add an explicit warning when falling back to plaintext +so a Linux user learns the key did not reach a keystore, surfaced in the TUI and +by `abox creds migrate`. + +**4e. Migration.** `abox creds migrate` needs no logic change once the keystore +dispatches; reword the macOS-only error at cmd/abox/creds.go:35. + +**4f. Wording.** save.go:40 hardcodes "key saved to macOS keychain (service +abox)". Make it platform-accurate, along with `credStatusLabel` +(internal/tui/tui.go:154) and the `/provider`, `/credential`, `/mcp` copy. + +--- + +## Phase 5 — Preflight and diagnostics + +Expected to be the dominant support burden. Fail with actionable text, never a +raw libkrun return code. In `startVM` (Linux) or `runtime.Start`: + +- `/dev/kvm` missing — load `kvm_intel`/`kvm_amd`, or enable virtualization in + firmware. +- `/dev/kvm` `EACCES` — `sudo usermod -aG kvm $USER`, then log out and back in. + Arch ships a udev rule granting 0666; Fedora and Debian use `root:kvm 0660`, + so this will be the most common first-run failure. +- Host is itself a VM without nested virt — surface `krun_check_nested_virt()`. +- `libkrunfw.so.5` not found — name the package for the detected distribution. + +--- + +## Phase 6 — CI and release + +- `.github/workflows/test.yml`: matrix `[macos-latest, ubuntu-latest]`. GitHub + runners have no `/dev/kvm`, so the Linux job stays unit tests, `go vet`, and + compile — the same limitation macOS already has. Either install libkrun from + source for the cgo build or skip the `abox-vmm` build on that job. +- Matrix the guest cross-build over `amd64` and `arm64`. +- `release.yml`: add Linux artifacts. + +--- + +## Phase 7 — Documentation and claim hygiene + +- README: per-platform prerequisites; Linux quickstart; state that `make image` + on Linux needs no Docker, no root, and no privileged container. +- Per-distro install: Arch (`pacman`), Fedora (`dnf`), Debian/Ubuntu source build + (`apt install python3-pyelftools build-essential flex bison libelf-dev`, then + `make && sudo make install`; libkrunfw compiles a Linux kernel, so it is slow), + marked **untested**. +- `docs/troubleshooting.md`: `/dev/kvm` permissions, missing libkrunfw, + architecture mismatch, nested virtualization, and no Secret Service provider. +- `docs/credentials.md` and the README credentials table: rename the row to + `keystore`, note it resolves to macOS Keychain or Secret Service per platform, + and record `keychain`/`secretservice` as accepted aliases. State plainly that + `vault`, `azure`, and `aws` are cross-platform and are the supported headless + answer on Linux. +- **`PLAN.md` §22**: the acceptance matrix is written against + Hypervisor.framework. Give it a per-platform column so KVM enforcement is + tracked separately, per Global decision 7. + +--- + +## Prerequisites (Linux hosts) + +**Runtime:** Intel VT-x or AMD-V enabled in firmware; `kvm_intel`/`kvm_amd` +loaded so `/dev/kvm` exists; read/write access to `/dev/kvm`; libkrun and +libkrunfw on the library path. Nested virtualization only if the host is itself a +VM. Default budget is 1 vCPU / 768 MiB per session +(`vmmconfig.DefaultVCPU`, `DefaultRAMMiB`). + +**Credentials (optional):** libsecret (`secret-tool`) plus a Secret Service +provider — GNOME Keyring, KWallet, or KeePassXC — for the `keystore` source. +Without one, ABox falls back to `credentials.env` at 0600 with a warning, or use +`vault`/`azure`/`aws`, which need nothing platform-specific. + +**Build:** Go 1.25+, gcc, pkg-config, libkrun headers. Image builds additionally +need e2fsprogs ≥ 1.43 (for `mke2fs -d`) and fakeroot. No codesign, no Docker. + +Note: Arch's libkrun pulls `libvirglrenderer` and `libpipewire` because GPU and +sound are compiled in. ABox uses neither; this is dependency weight only. + +## Risks / notes + +1. Architecture-mismatched image produces an opaque guest panic. Mitigated by + arch-tagged filenames plus the `Prepare` check (Phase 2). Highest-severity + item in this plan. +2. `/dev/kvm` permissions differ across distributions. Mitigated by Phase 4 + preflight. +3. Host running inside a VM without nested virtualization. Surfaced via + `krun_check_nested_virt()`. +4. On ext4 there is no reflink, so each session clone is a real 768 MiB copy. + `CopyFileRange` keeps it in-kernel; btrfs and XFS get true reflink. +5. libkrun version skew across distributions. `krun_has_feature` already guards + at runtime; add a version gate if a symbol gap appears. +6. `apk.static` is fetched from the Alpine CDN at image-build time. Pin by + SHA-256. +7. Debian/Ubuntu source builds of libkrunfw compile a kernel. Documented, not + supported, not tested. +8. `secret-tool` cannot distinguish "not found" from "no service" by exit code. + Mitigated by the availability probe (Global decision 10); a wrong mapping + would either suppress the plaintext fallback or mask a real lookup failure. +9. A trailing newline piped into `secret-tool store` silently becomes part of the + secret, producing a credential that fails authentication with no visible + cause. Covered by a round-trip test in Verification. +10. Headless Linux with no session bus still lands keys in plaintext. Accepted + and now warned; `vault`/`azure`/`aws` are the documented alternative. + +## Verification + +1. **Compile and unit:** `make test` and `go build ./cmd/abox ./cmd/abox-vmm` + succeed on Linux. They currently do **not**, because of `unix.Clonefile`; this + is the first regression to clear. +2. **VM liveness:** `abox --probe-vm` lists guest files. +3. **Isolation spot-checks** (not the full §22 suite, which requires the hardware + matrix): + - `abox exec --prompt "run: ip addr"` shows loopback only — no NIC. + - A host canary file outside the snapshot directory is unreachable from the + guest. + - `~/.abox/sessions//` contains only `root.raw` and `config.raw`; + `config.raw` is mode 0400 and carries no credential material. +4. **Agent loop:** set a provider key via `/provider`, run a prompt requiring + `read_file` and `run_command`; confirm the approval prompt appears and + defaults to deny. +5. **Resume:** `abox --resume ` reloads transcript and conversation. +6. **Image builder:** `make image` completes as a non-root user with the Docker + daemon stopped; `debugfs -R "ls -l /usr/local/bin" ` reports `0 0`. +7. **Credential round-trip:** `/provider` stores a key; `secret-tool lookup + service abox account ANTHROPIC_API_KEY` returns it **byte-identical** (no + trailing newline); `~/.abox/credentials.env` does not contain it; a turn + authenticates against the provider. +8. **Fallback path:** with `DBUS_SESSION_BUS_ADDRESS` unset, the same flow warns, + writes to `credentials.env` at 0600, and still completes a turn. +9. **Alias compatibility:** existing `source: keychain` config resolves on Linux; + `keystore` and `secretservice` resolve identically; an unknown source is still + rejected by `config.Validate`. +10. **Cloud sources unaffected:** a model pinned to `source: vault` and another to + `source: keystore` both resolve in the same session. +11. **Cross-platform regression:** the same suite still passes on Apple Silicon, + including `security(1)` keychain storage. + +## Status + +Not started. No code in this plan has been implemented; the findings table +records read-only verification against installed-ready packages, the pinned +`x/sys` module, and the live Secret Service on the dev host. No secret was +written to any keystore during planning. Linux isolation claims remain +**Planned** per Global decision 7. diff --git a/README.md b/README.md index ac3bbc9..a5d4f86 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,20 @@ libkrun microVM on Apple Silicon; host TUI/SDK, LLM/MCP brokers, and ## Prerequisites -- Apple Silicon Mac +- Apple Silicon Mac or Linux - Go 1.24+ - Docker (today: pack the guest **root filesystem** image only; not on the session path) - libkrun and libkrunfw (VMM + **guest Linux kernel**; the kernel is not inside the `.raw` disk) +### Mac ```bash brew tap libkrun/krun brew trust libkrun/krun brew install libkrun libkrunfw ``` +### Linux + ## Quickstart From this directory or any other directory. ABox snapshots that exact directory @@ -50,8 +53,8 @@ creates its own private baseline for change tracking. Git ignore rules are not consulted. Every regular file beneath the selected directory is copied, including dotfiles, except `.git` metadata. Start ABox from a directory containing only files the guest is allowed to read. - ![](img/abox-quickstart.gif) +### Mac `make image`: uses Docker once (today) to pack a raw ext4 root filesystem (`~/.abox/images/abox-guest.raw`): Alpine userspace, git, patch, and @@ -85,6 +88,7 @@ abox - `ctrl+c` quits - The agent runs only inside the guest (MicroVM) +### Linux ## microVM > Docker @@ -171,8 +175,6 @@ Example: Currently, Grok, OpenAI, and Anthropic are supported. -![](img/prov1.png) -![](img/prov2.png) ## Test MicroVM Connectivity @@ -241,7 +243,6 @@ guest ready; files: If you try to use ABox without a guest/microVM, you will see the following: -![](img/novm.png) Headless agent loop (needs a VM and a provider key; the prompt is sent to the guest agent): From 986f89cda93d7fdf1832e6a1f38e2e87050e706f Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Thu, 17 Sep 2026 18:37:28 -0400 Subject: [PATCH 2/5] linux plan --- PLAN-LINUX.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/PLAN-LINUX.md b/PLAN-LINUX.md index a8aed4f..f5d9f92 100644 --- a/PLAN-LINUX.md +++ b/PLAN-LINUX.md @@ -434,10 +434,3 @@ sound are compiled in. ABox uses neither; this is dependency weight only. 11. **Cross-platform regression:** the same suite still passes on Apple Silicon, including `security(1)` keychain storage. -## Status - -Not started. No code in this plan has been implemented; the findings table -records read-only verification against installed-ready packages, the pinned -`x/sys` module, and the live Secret Service on the dev host. No secret was -written to any keystore during planning. Linux isolation claims remain -**Planned** per Global decision 7. From 5691a419069ae447196b8d8d20ef030ac7317bab Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Sun, 20 Sep 2026 08:32:03 -0400 Subject: [PATCH 3/5] linux support --- .github/CODEOWNERS | 5 + .github/acceptance/linux-kvm-v1.json | 30 + .github/actionlint.yaml | 11 + .github/workflows/linux-hardware-gate.yml | 187 +++++ .github/workflows/pages.yml | 10 +- .github/workflows/release.yml | 400 +++++++--- .github/workflows/test.yml | 136 +++- Makefile | 56 +- PLAN-ABOX-SDK.md | 14 +- PLAN-CRED.md | 65 +- PLAN-LINUX.md | 718 ++++++++++++++---- PLAN.md | 304 ++++---- README.md | 150 ++-- cmd/abox-guest/main.go | 10 +- cmd/abox-vmm/build_tags_test.go | 32 + cmd/abox-vmm/cgoflags_darwin_arm64.go | 9 + cmd/abox-vmm/cgoflags_linux.go | 8 + cmd/abox-vmm/main.go | 19 + cmd/abox-vmm/main_test.go | 28 + cmd/abox-vmm/preflight_darwin.go | 5 + cmd/abox-vmm/preflight_linux.go | 97 +++ ...start_darwin_arm64.go => start_libkrun.go} | 59 +- cmd/abox-vmm/start_stub.go | 7 +- cmd/abox/creds.go | 30 +- cmd/abox/creds_test.go | 22 +- cmd/abox/main.go | 130 +++- cmd/abox/signals_linux.go | 12 + cmd/abox/signals_linux_test.go | 22 + cmd/abox/signals_other.go | 9 + docs/api.md | 5 +- docs/approvals.md | 5 + docs/cli.md | 15 +- docs/concepts.md | 41 +- docs/credentials.md | 28 +- docs/examples.md | 5 +- docs/examples/cancel.md | 3 + docs/examples/custom-vm.md | 4 +- docs/examples/mcp-tokens.md | 2 +- docs/index.md | 24 +- docs/platforms.md | 156 ++++ docs/quickstart.md | 34 +- docs/sessions.md | 21 +- docs/troubleshooting.md | 165 +++- images/build-guest-darwin.sh | 100 +++ images/build-guest-linux.sh | 316 ++++++++ images/build-guest.sh | 54 +- images/update-guest-bin.sh | 28 +- internal/config/config.go | 109 ++- internal/config/config_test.go | 74 +- internal/credsource/azure.go | 42 +- internal/credsource/credsource.go | 5 +- internal/credsource/credsource_test.go | 410 ++++++---- internal/credsource/keychain.go | 401 ++++++++-- internal/credsource/save.go | 22 +- internal/guest/tools/command_guest_linux.go | 37 + .../guest/tools/command_guest_linux_test.go | 52 ++ internal/guest/tools/command_stub.go | 9 + internal/guest/tools/freeze_linux.go | 2 +- internal/guest/tools/freeze_stub.go | 11 +- internal/guest/tools/freeze_stub_test.go | 17 + internal/guest/tools/tools.go | 89 ++- internal/guest/tools/tools_test.go | 28 + internal/guestimage/lock_other.go | 18 + internal/guestimage/lock_unix.go | 49 ++ internal/guestimage/manifest.go | 114 +++ internal/guestimage/manifest_test.go | 54 ++ internal/mcpauth/oauth.go | 112 ++- internal/mcpauth/oauth_test.go | 111 ++- internal/repository/repository.go | 322 ++++++-- internal/repository/repository_test.go | 212 ++++-- internal/runtime/clone.go | 47 ++ internal/runtime/clone_darwin.go | 9 + internal/runtime/clone_linux.go | 62 ++ internal/runtime/clone_linux_test.go | 125 +++ internal/runtime/clone_other.go | 9 + internal/runtime/environment_darwin.go | 13 + internal/runtime/environment_other.go | 12 + internal/runtime/lifecycle_linux_test.go | 172 +++++ internal/runtime/runtime.go | 488 ++++++++++-- internal/runtime/runtime_test.go | 224 +++++- internal/runtime/runtime_turn_test.go | 44 +- internal/runtime/stale_linux.go | 145 ++++ internal/runtime/stale_linux_test.go | 81 ++ internal/runtime/stale_other.go | 9 + internal/runtime/stop_linux.go | 12 + internal/runtime/stop_other.go | 9 + internal/session/lock_other.go | 11 + internal/session/lock_unix.go | 46 ++ internal/session/session.go | 34 +- internal/session/session_test.go | 53 ++ internal/tui/commands.go | 6 +- internal/tui/commands_test.go | 4 +- internal/tui/tui.go | 9 +- packaging/libkrun-required-symbols.txt | 12 + pkg/abox/abox.go | 32 +- pkg/abox/doc.go | 6 +- protocol/protocol.go | 3 + .../validate-hardware-report.cpython-314.pyc | Bin 0 -> 3564 bytes .../verify-kvm-attestation.cpython-314.pyc | Bin 0 -> 6148 bytes scripts/check-libkrun.sh | 82 ++ scripts/generate-libkrun-symbols.sh | 18 + scripts/install-arch-libkrun.sh | 39 + scripts/install-fedora-libkrun.sh | 33 + scripts/package-linux-release.sh | 74 ++ scripts/run-linux-hardware-gate.sh | 144 ++++ scripts/test-rootless-image-build.sh | 76 ++ scripts/validate-hardware-report.py | 67 ++ scripts/verify-kvm-attestation.py | 97 +++ scripts/verify-linux-release.sh | 133 ++++ 109 files changed, 7125 insertions(+), 1206 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/acceptance/linux-kvm-v1.json create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/linux-hardware-gate.yml create mode 100644 cmd/abox-vmm/build_tags_test.go create mode 100644 cmd/abox-vmm/cgoflags_darwin_arm64.go create mode 100644 cmd/abox-vmm/cgoflags_linux.go create mode 100644 cmd/abox-vmm/main_test.go create mode 100644 cmd/abox-vmm/preflight_darwin.go create mode 100644 cmd/abox-vmm/preflight_linux.go rename cmd/abox-vmm/{start_darwin_arm64.go => start_libkrun.go} (55%) create mode 100644 cmd/abox/signals_linux.go create mode 100644 cmd/abox/signals_linux_test.go create mode 100644 cmd/abox/signals_other.go create mode 100644 docs/platforms.md create mode 100755 images/build-guest-darwin.sh create mode 100755 images/build-guest-linux.sh create mode 100644 internal/guest/tools/command_guest_linux.go create mode 100644 internal/guest/tools/command_guest_linux_test.go create mode 100644 internal/guest/tools/command_stub.go create mode 100644 internal/guest/tools/freeze_stub_test.go create mode 100644 internal/guestimage/lock_other.go create mode 100644 internal/guestimage/lock_unix.go create mode 100644 internal/guestimage/manifest.go create mode 100644 internal/guestimage/manifest_test.go create mode 100644 internal/runtime/clone.go create mode 100644 internal/runtime/clone_darwin.go create mode 100644 internal/runtime/clone_linux.go create mode 100644 internal/runtime/clone_linux_test.go create mode 100644 internal/runtime/clone_other.go create mode 100644 internal/runtime/environment_darwin.go create mode 100644 internal/runtime/environment_other.go create mode 100644 internal/runtime/lifecycle_linux_test.go create mode 100644 internal/runtime/stale_linux.go create mode 100644 internal/runtime/stale_linux_test.go create mode 100644 internal/runtime/stale_other.go create mode 100644 internal/runtime/stop_linux.go create mode 100644 internal/runtime/stop_other.go create mode 100644 internal/session/lock_other.go create mode 100644 internal/session/lock_unix.go create mode 100644 packaging/libkrun-required-symbols.txt create mode 100644 scripts/__pycache__/validate-hardware-report.cpython-314.pyc create mode 100644 scripts/__pycache__/verify-kvm-attestation.cpython-314.pyc create mode 100644 scripts/check-libkrun.sh create mode 100644 scripts/generate-libkrun-symbols.sh create mode 100644 scripts/install-arch-libkrun.sh create mode 100644 scripts/install-fedora-libkrun.sh create mode 100644 scripts/package-linux-release.sh create mode 100644 scripts/run-linux-hardware-gate.sh create mode 100644 scripts/test-rootless-image-build.sh create mode 100644 scripts/validate-hardware-report.py create mode 100644 scripts/verify-kvm-attestation.py create mode 100644 scripts/verify-linux-release.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..51195d4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +/.github/workflows/ @AdminTurnedDevOps +/.github/acceptance/ @AdminTurnedDevOps +/scripts/run-linux-hardware-gate.sh @AdminTurnedDevOps +/scripts/validate-hardware-report.py @AdminTurnedDevOps +/scripts/verify-kvm-attestation.py @AdminTurnedDevOps diff --git a/.github/acceptance/linux-kvm-v1.json b/.github/acceptance/linux-kvm-v1.json new file mode 100644 index 0000000..eaa96eb --- /dev/null +++ b/.github/acceptance/linux-kvm-v1.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "predicate_type": "https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1", + "source": [ + "PLAN.md#214-hardware-security-tests", + "PLAN.md#22-security-acceptance-matrix", + "PLAN-LINUX.md#verification" + ], + "tests": [ + "packaged-artifact-boot", + "device-plan-no-nic-gpu-sound-host-fs", + "host-home-canary-unreachable", + "ssh-canary-unreachable", + "cloud-credential-canary-unreachable", + "docker-socket-canary-unreachable", + "repository-private-disk-only", + "guest-loopback-and-unix-sockets-work", + "host-lan-external-ipv4-ipv6-unreachable", + "tsi-inet-and-unix-hijack-disabled", + "destructive-guest-command-host-unchanged", + "run-command-unprivileged-no-background", + "direct-provider-mode", + "gateway-device-plan-identical", + "mcp-no-host-shell", + "host-fetch-policy-enforced", + "repository-instructions-cannot-relax-policy", + "lifecycle-cleanup-and-supervisor-death", + "default-resource-budget" + ] +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..5c9d525 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,11 @@ +self-hosted-runner: + labels: + - abox-kvm-arch + - abox-kvm-fedora + +config-variables: + - ABOX_KVM_ACCEPTANCE_SHA256 + - ABOX_KVM_ARCH_HARNESS_SHA256 + - ABOX_KVM_ARCH_RUNNER_NAME + - ABOX_KVM_FEDORA_HARNESS_SHA256 + - ABOX_KVM_FEDORA_RUNNER_NAME diff --git a/.github/workflows/linux-hardware-gate.yml b/.github/workflows/linux-hardware-gate.yml new file mode 100644 index 0000000..e8519e4 --- /dev/null +++ b/.github/workflows/linux-hardware-gate.yml @@ -0,0 +1,187 @@ +name: Linux KVM hardware gate + +on: + workflow_call: + inputs: + candidate_artifact: + type: string + required: true + candidate_file: + type: string + required: true + candidate_sha256: + type: string + required: true + commit: + type: string + required: true + tag: + type: string + required: true + +jobs: + arch-kvm: + name: Protected Arch x86_64 exact-artifact acceptance + environment: linux-kvm-release + runs-on: + group: abox-kvm-release + labels: abox-kvm-arch + permissions: + actions: read + attestations: write + contents: read + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ inputs.commit }} + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: ${{ inputs.candidate_artifact }} + path: candidate + - name: Run trusted Arch hardware suite + id: gate + env: + ABOX_EVIDENCE_DIR: ${{ runner.temp }}/abox-evidence-arch + EXPECTED_RUNNER: ${{ vars.ABOX_KVM_ARCH_RUNNER_NAME }} + HARNESS_SHA256: ${{ vars.ABOX_KVM_ARCH_HARNESS_SHA256 }} + POLICY_SHA256: ${{ vars.ABOX_KVM_ACCEPTANCE_SHA256 }} + CANDIDATE_FILE: ${{ inputs.candidate_file }} + CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} + COMMIT: ${{ inputs.commit }} + run: | + set -euo pipefail + manifest_sha=$(sh scripts/run-linux-hardware-gate.sh \ + arch "candidate/$CANDIDATE_FILE" "$CANDIDATE_SHA256" \ + "$COMMIT" "$EXPECTED_RUNNER" \ + /opt/abox-kvm-gate/v1/run "$HARNESS_SHA256" | tail -n 1) + test -n "$POLICY_SHA256" + test "$manifest_sha" = "$POLICY_SHA256" + echo "manifest-sha256=$manifest_sha" >> "$GITHUB_OUTPUT" + - name: Build Arch attestation predicate + env: + EVIDENCE: ${{ runner.temp }}/abox-evidence-arch + CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} + COMMIT: ${{ inputs.commit }} + MANIFEST_SHA256: ${{ steps.gate.outputs.manifest-sha256 }} + HARNESS_SHA256: ${{ vars.ABOX_KVM_ARCH_HARNESS_SHA256 }} + run: | + python3 - <<'PY' + import json + import os + import pathlib + + evidence = pathlib.Path(os.environ["EVIDENCE"]) + report = json.loads((evidence / "report.json").read_text(encoding="utf-8")) + predicate = dict(report) + predicate["baseline"] = "arch-2026-09-17-x86_64-libkrun-1.19.4-1-libkrunfw-5.5.0-1" + predicate["harness_sha256"] = os.environ["HARNESS_SHA256"] + if predicate["candidate_sha256"] != os.environ["CANDIDATE_SHA256"]: + raise SystemExit("report candidate digest changed before attestation") + if predicate["commit"] != os.environ["COMMIT"]: + raise SystemExit("report commit changed before attestation") + if predicate["acceptance_manifest_sha256"] != os.environ["MANIFEST_SHA256"]: + raise SystemExit("report acceptance manifest changed before attestation") + (evidence / "predicate.json").write_text( + json.dumps(predicate, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + PY + - name: Sign Arch KVM evidence with GitHub OIDC + id: attest + uses: actions/attest@ce27ba3b4a9a139d9a20a4a07d69fabb52f1e5bc # v2 + with: + subject-path: candidate/${{ inputs.candidate_file }} + predicate-type: https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1 + predicate-path: ${{ runner.temp }}/abox-evidence-arch/predicate.json + - name: Collect signed Arch evidence + run: cp '${{ steps.attest.outputs.bundle-path }}' '${{ runner.temp }}/abox-evidence-arch/attestation.json' + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: kvm-evidence-${{ inputs.tag }}-arch + path: ${{ runner.temp }}/abox-evidence-arch/ + if-no-files-found: error + retention-days: 30 + + fedora-kvm: + name: Protected Fedora 44 x86_64 exact-artifact acceptance + environment: linux-kvm-release + runs-on: + group: abox-kvm-release + labels: abox-kvm-fedora + permissions: + actions: read + attestations: write + contents: read + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ inputs.commit }} + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: ${{ inputs.candidate_artifact }} + path: candidate + - name: Run trusted Fedora hardware suite + id: gate + env: + ABOX_EVIDENCE_DIR: ${{ runner.temp }}/abox-evidence-fedora + EXPECTED_RUNNER: ${{ vars.ABOX_KVM_FEDORA_RUNNER_NAME }} + HARNESS_SHA256: ${{ vars.ABOX_KVM_FEDORA_HARNESS_SHA256 }} + POLICY_SHA256: ${{ vars.ABOX_KVM_ACCEPTANCE_SHA256 }} + CANDIDATE_FILE: ${{ inputs.candidate_file }} + CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} + COMMIT: ${{ inputs.commit }} + run: | + set -euo pipefail + manifest_sha=$(sh scripts/run-linux-hardware-gate.sh \ + fedora "candidate/$CANDIDATE_FILE" "$CANDIDATE_SHA256" \ + "$COMMIT" "$EXPECTED_RUNNER" \ + /opt/abox-kvm-gate/v1/run "$HARNESS_SHA256" | tail -n 1) + test -n "$POLICY_SHA256" + test "$manifest_sha" = "$POLICY_SHA256" + echo "manifest-sha256=$manifest_sha" >> "$GITHUB_OUTPUT" + - name: Build Fedora attestation predicate + env: + EVIDENCE: ${{ runner.temp }}/abox-evidence-fedora + CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} + COMMIT: ${{ inputs.commit }} + MANIFEST_SHA256: ${{ steps.gate.outputs.manifest-sha256 }} + HARNESS_SHA256: ${{ vars.ABOX_KVM_FEDORA_HARNESS_SHA256 }} + run: | + python3 - <<'PY' + import json + import os + import pathlib + + evidence = pathlib.Path(os.environ["EVIDENCE"]) + report = json.loads((evidence / "report.json").read_text(encoding="utf-8")) + predicate = dict(report) + predicate["baseline"] = "fedora-44-x86_64-libkrun-1.19.0-1.fc44-libkrunfw-5.5.0-1.fc44-selinux-enforcing" + predicate["harness_sha256"] = os.environ["HARNESS_SHA256"] + if predicate["candidate_sha256"] != os.environ["CANDIDATE_SHA256"]: + raise SystemExit("report candidate digest changed before attestation") + if predicate["commit"] != os.environ["COMMIT"]: + raise SystemExit("report commit changed before attestation") + if predicate["acceptance_manifest_sha256"] != os.environ["MANIFEST_SHA256"]: + raise SystemExit("report acceptance manifest changed before attestation") + (evidence / "predicate.json").write_text( + json.dumps(predicate, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + PY + - name: Sign Fedora KVM evidence with GitHub OIDC + id: attest + uses: actions/attest@ce27ba3b4a9a139d9a20a4a07d69fabb52f1e5bc # v2 + with: + subject-path: candidate/${{ inputs.candidate_file }} + predicate-type: https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1 + predicate-path: ${{ runner.temp }}/abox-evidence-fedora/predicate.json + - name: Collect signed Fedora evidence + run: cp '${{ steps.attest.outputs.bundle-path }}' '${{ runner.temp }}/abox-evidence-fedora/attestation.json' + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: kvm-evidence-${{ inputs.tag }}-fedora + path: ${{ runner.temp }}/abox-evidence-fedora/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 13672ed..cfe069e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,7 +22,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Sync example sources run: | set -euo pipefail @@ -30,14 +30,14 @@ jobs: for d in examples/sdk-*; do cp "$d/main.go" "docs/_includes/examples/$(basename "$d").go" done - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 with: enablement: true - - uses: actions/jekyll-build-pages@v1 + - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1 with: source: ./docs destination: ./_site - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 deploy: needs: build @@ -47,4 +47,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d939658..ab514eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,171 +6,353 @@ on: bump: description: Bump this component of the latest tag (ignored when Version is set, or when there are no tags yet) type: choice - options: - - patch - - minor - - major + options: [patch, minor, major] default: patch version: - description: Explicit version with no v prefix (for example 1.0.0). Leave empty to auto-bump. With no tags, the first release is 1.0.0. + description: Explicit version with no v prefix (for example 1.0.0). Leave empty to auto-bump. required: false default: "" permissions: - contents: write + contents: read concurrency: group: release cancel-in-progress: false jobs: - release: - runs-on: macos-15 + version: + name: Resolve candidate version + runs-on: ubuntu-latest + outputs: + previous: ${{ steps.version.outputs.previous }} + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} steps: - - name: Require Apple Silicon - run: | - set -euo pipefail - if [ "$(uname -m)" != "arm64" ]; then - echo "abox-vmm must build on Apple Silicon; runner is $(uname -m)" >&2 - exit 1 - fi - - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 fetch-tags: true - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - + persist-credentials: false - name: Resolve next version - id: ver + id: version env: - OVERRIDE: ${{ inputs.version }} BUMP: ${{ inputs.bump }} + OVERRIDE: ${{ inputs.version }} run: | set -euo pipefail - git fetch --tags --force - OVERRIDE="$(printf '%s' "$OVERRIDE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/^v//')" + if [ "$GITHUB_REF" != refs/heads/main ]; then + echo "releases must be dispatched from refs/heads/main, not $GITHUB_REF" >&2 + exit 1 + fi + override=$(printf '%s' "$OVERRIDE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/^v//') + latest=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1 || true) normalize() { - # 1 -> 1.0.0; 1.0 -> 1.0.0; 1.0.0 -> 1.0.0 - local v="$1" - local a b c rest - a="${v%%.*}" - rest="${v#"$a"}" - rest="${rest#.}" - if [ -z "$rest" ]; then - echo "${a}.0.0" - return - fi - b="${rest%%.*}" - c="${rest#"$b"}" - c="${c#.}" - if [ -z "$c" ]; then - c=0 - fi - echo "${a}.${b}.${c}" + local value=$1 major minor patch rest + major=${value%%.*} + rest=${value#"$major"} + rest=${rest#.} + if [ -z "$rest" ]; then printf '%s.0.0\n' "$major"; return; fi + minor=${rest%%.*} + patch=${rest#"$minor"} + patch=${patch#.} + [ -n "$patch" ] || patch=0 + printf '%s.%s.%s\n' "$major" "$minor" "$patch" } - LATEST="$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1 || true)" - - if [ -n "$OVERRIDE" ]; then - VER="$(normalize "$OVERRIDE")" + if [ -n "$override" ]; then + version=$(normalize "$override") + elif [ -z "$latest" ]; then + version=1.0.0 else - if [ -z "$LATEST" ]; then - VER="1.0.0" - else - CUR="$(normalize "${LATEST#v}")" - MA="${CUR%%.*}" - REST="${CUR#*.}" - MI="${REST%%.*}" - PA="${REST#*.}" - case "$BUMP" in - major) - MA=$((MA + 1)) - MI=0 - PA=0 - ;; - minor) - MI=$((MI + 1)) - PA=0 - ;; - patch | *) - PA=$((PA + 1)) - ;; - esac - VER="${MA}.${MI}.${PA}" - fi + current=$(normalize "${latest#v}") + major=${current%%.*} + rest=${current#*.} + minor=${rest%%.*} + patch=${rest#*.} + case "$BUMP" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) echo "invalid bump: $BUMP" >&2; exit 1 ;; + esac + version=$major.$minor.$patch fi - - TAG="v${VER}" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "tag ${TAG} already exists" >&2 + if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "invalid release version: $version" >&2 exit 1 fi - echo "version=${VER}" >> "$GITHUB_OUTPUT" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "previous=${LATEST}" >> "$GITHUB_OUTPUT" - echo "next release ${TAG} (bump=${BUMP} previous=${LATEST:-none})" + tag=v$version + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "tag already exists: $tag" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "previous=$latest" >> "$GITHUB_OUTPUT" + macos-candidate: + name: Build macOS arm64 candidate + needs: version + runs-on: macos-15 + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: Require Apple Silicon + run: test "$(uname -m)" = arm64 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod - name: Test run: make test - - name: Install libkrun - env: - HOMEBREW_NO_REQUIRE_TAP_TRUST: "1" run: | set -euo pipefail brew tap libkrun/krun - brew trust libkrun/krun || true - # The tap's libkrunfw Sequoia bottle URL 404s (brew asks for - # libkrunfw-64.*.bottle.1.tgz; the asset is libkrunfw-5.5.0.*). - # Formula "source" is already a prebuilt aarch64 tarball. brew install --build-from-source libkrunfw brew install libkrun - - - name: Build - run: make build - - - name: Package + test "$(brew list --versions libkrun)" = 'libkrun 1.19.4' + test "$(brew list --versions libkrunfw)" = 'libkrunfw 5.5.0' + - name: Build candidate artifacts env: - TAG: ${{ steps.ver.outputs.tag }} + TAG: ${{ needs.version.outputs.tag }} run: | set -euo pipefail + make build IMAGE_ID="$TAG" mkdir -p dist tar -C bin -czf "dist/abox_${TAG}_darwin_arm64.tar.gz" abox abox-vmm - cp bin/abox-guest-linux-arm64 "dist/abox-guest_${TAG}_linux_arm64" - chmod 0755 "dist/abox-guest_${TAG}_linux_arm64" - (cd dist && shasum -a 256 * > SHA256SUMS) + install -m 0755 bin/abox-guest-linux-arm64 "dist/abox-guest_${TAG}_linux_arm64" + (cd dist && shasum -a 256 "abox_${TAG}_darwin_arm64.tar.gz" \ + "abox-guest_${TAG}_linux_arm64" > SHA256SUMS.darwin) + - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: | + dist/abox_${{ needs.version.outputs.tag }}_darwin_arm64.tar.gz + dist/abox-guest_${{ needs.version.outputs.tag }}_linux_arm64 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: macos-candidate-${{ needs.version.outputs.tag }} + path: dist/ + if-no-files-found: error + retention-days: 7 + + linux-candidate: + name: Build Linux amd64 candidate on pinned Arch snapshot + needs: version + runs-on: ubuntu-latest + container: + image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b + permissions: + attestations: write + contents: read + id-token: write + outputs: + artifact: ${{ steps.package.outputs.artifact }} + file: ${{ steps.package.outputs.file }} + sha256: ${{ steps.package.outputs.sha256 }} + steps: + - name: Install pinned build dependencies + run: | + set -euo pipefail + printf '%s\n' 'Server = https://archive.archlinux.org/repos/2026/09/17/$repo/os/$arch' > /etc/pacman.d/mirrorlist + pacman -Syyu --noconfirm + pacman -S --needed --noconfirm git curl ca-certificates pkgconf \ + fakeroot e2fsprogs \ + zstd python shadow util-linux binutils file + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - name: Install checksum/signature-pinned libkrun pair + run: sh scripts/install-arch-libkrun.sh + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + - name: Build and package as an unprivileged user + env: + TAG: ${{ needs.version.outputs.tag }} + run: | + set -euo pipefail + useradd --create-home --shell /bin/bash abox-build + chown -R abox-build:abox-build "$GITHUB_WORKSPACE" + epoch=$(git show -s --format=%ct "$GITHUB_SHA") + runuser -u abox-build -- env HOME=/home/abox-build PATH="$PATH" \ + GITHUB_WORKSPACE="$GITHUB_WORKSPACE" TAG="$TAG" SOURCE_DATE_EPOCH="$epoch" \ + sh -c ' + set -eu + cd "$GITHUB_WORKSPACE" + sh scripts/check-libkrun.sh 1.19.4 libkrunfw.so.5 + CGO_ENABLED=1 make test + CGO_ENABLED=1 make abox vmm guest GUEST_ARCH=amd64 IMAGE_ID="$TAG" + make image GUEST_ARCH=amd64 IMAGE_ID="$TAG" IMAGE="$GITHUB_WORKSPACE/dist/abox-guest-linux-amd64.raw" + sh scripts/package-linux-release.sh "$TAG" \ + "$GITHUB_WORKSPACE/dist/abox-guest-linux-amd64.raw" \ + "$GITHUB_WORKSPACE/dist/release" + ' + - name: Verify and expose immutable candidate digest + id: package + env: + TAG: ${{ needs.version.outputs.tag }} + run: | + set -euo pipefail + file="abox_${TAG}_linux_amd64.tar.gz" + sha=$(sha256sum "dist/release/$file") + sha=${sha%% *} + expected=$(cut -d' ' -f1 "dist/release/$file.sha256") + test "$sha" = "$expected" + sh scripts/verify-linux-release.sh "dist/release/$file" "$sha" 1.19.4 + echo "artifact=linux-candidate-$TAG" >> "$GITHUB_OUTPUT" + echo "file=$file" >> "$GITHUB_OUTPUT" + echo "sha256=$sha" >> "$GITHUB_OUTPUT" + - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: dist/release/${{ steps.package.outputs.file }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.package.outputs.artifact }} + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + linux-hardware-gates: + name: Protected Arch and Fedora KVM gates + needs: [version, macos-candidate, linux-candidate] + permissions: + actions: read + attestations: write + contents: read + id-token: write + uses: ./.github/workflows/linux-hardware-gate.yml + with: + candidate_artifact: ${{ needs.linux-candidate.outputs.artifact }} + candidate_file: ${{ needs.linux-candidate.outputs.file }} + candidate_sha256: ${{ needs.linux-candidate.outputs.sha256 }} + commit: ${{ github.sha }} + tag: ${{ needs.version.outputs.tag }} + secrets: inherit + publish: + name: Verify evidence and publish + needs: [version, macos-candidate, linux-candidate, linux-hardware-gates] + runs-on: ubuntu-latest + environment: release + permissions: + attestations: read + contents: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: macos-candidate-${{ needs.version.outputs.tag }} + path: dist/macos + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: ${{ needs.linux-candidate.outputs.artifact }} + path: dist/linux + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: kvm-evidence-${{ needs.version.outputs.tag }}-arch + path: evidence/arch + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: kvm-evidence-${{ needs.version.outputs.tag }}-fedora + path: evidence/fedora + - name: Verify candidate provenance and both signed KVM predicates + env: + CANDIDATE_FILE: ${{ needs.linux-candidate.outputs.file }} + CANDIDATE_SHA256: ${{ needs.linux-candidate.outputs.sha256 }} + ARCH_RUNNER: ${{ vars.ABOX_KVM_ARCH_RUNNER_NAME }} + ARCH_HARNESS_SHA256: ${{ vars.ABOX_KVM_ARCH_HARNESS_SHA256 }} + FEDORA_RUNNER: ${{ vars.ABOX_KVM_FEDORA_RUNNER_NAME }} + FEDORA_HARNESS_SHA256: ${{ vars.ABOX_KVM_FEDORA_HARNESS_SHA256 }} + TAG: ${{ needs.version.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + candidate="dist/linux/$CANDIDATE_FILE" + actual=$(sha256sum "$candidate") + actual=${actual%% *} + test "$actual" = "$CANDIDATE_SHA256" + gh attestation verify "$candidate" --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-digest "$GITHUB_SHA" + for distro in arch fedora; do + if [ "$distro" = arch ]; then + expected_runner=$ARCH_RUNNER + expected_harness=$ARCH_HARNESS_SHA256 + else + expected_runner=$FEDORA_RUNNER + expected_harness=$FEDORA_HARNESS_SHA256 + fi + gh attestation verify "$candidate" \ + --bundle "evidence/$distro/attestation.json" \ + --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1 \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/linux-hardware-gate.yml" \ + --source-digest "$GITHUB_SHA" \ + --format json > "evidence/$distro/verification.json" + python3 scripts/verify-kvm-attestation.py \ + "evidence/$distro/verification.json" "evidence/$distro/predicate.json" \ + "evidence/$distro/report.json" "$candidate" "$distro" "$GITHUB_SHA" \ + .github/acceptance/linux-kvm-v1.json "$expected_runner" "$expected_harness" + done + (cd dist/macos && shasum -a 256 -c SHA256SUMS.darwin) + for subject in \ + "dist/macos/abox_${TAG}_darwin_arm64.tar.gz" \ + "dist/macos/abox-guest_${TAG}_linux_arm64"; do + gh attestation verify "$subject" --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-digest "$GITHUB_SHA" + done + - name: Prepare release assets + env: + TAG: ${{ needs.version.outputs.tag }} + run: | + set -euo pipefail + mkdir -p release-assets + cp dist/macos/abox_*_darwin_arm64.tar.gz release-assets/ + cp dist/macos/abox-guest_*_linux_arm64 release-assets/ + cp dist/linux/abox_*_linux_amd64.tar.gz release-assets/ + for distro in arch fedora; do + cp "evidence/$distro/attestation.json" "release-assets/kvm-${distro}-${TAG}.attestation.json" + cp "evidence/$distro/report.json" "release-assets/kvm-${distro}-${TAG}.report.json" + done + (cd release-assets && sha256sum * > SHA256SUMS) - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.ver.outputs.tag }} - PREV: ${{ steps.ver.outputs.previous }} + PREVIOUS: ${{ needs.version.outputs.previous }} + TAG: ${{ needs.version.outputs.tag }} run: | set -euo pipefail - # GitHub --generate-notes lists merged PRs only. This repo - # ships from main commits, so notes are git log since PREV. { echo "## What's Changed" echo - if [ -n "${PREV}" ]; then - git log --no-merges --pretty=format:'- %s (%h)' "${PREV}..${GITHUB_SHA}" + if [ -n "$PREVIOUS" ]; then + git log --no-merges --pretty=format:'- %s (%h)' "$PREVIOUS..$GITHUB_SHA" echo echo - echo "**Full Changelog**: https://github.com/${GITHUB_REPOSITORY}/compare/${PREV}...${TAG}" + echo "**Full Changelog**: https://github.com/$GITHUB_REPOSITORY/compare/$PREVIOUS...$TAG" else - git log --no-merges --pretty=format:'- %s (%h)' "${GITHUB_SHA}" + git log --no-merges --pretty=format:'- %s (%h)' "$GITHUB_SHA" echo fi - } > /tmp/release-notes.md + } > "$RUNNER_TEMP/release-notes.md" gh release create "$TAG" \ + --draft \ --title "ABox $TAG" \ - --notes-file /tmp/release-notes.md \ - --target "${GITHUB_SHA}" \ - dist/abox_"${TAG}"_darwin_arm64.tar.gz \ - dist/abox-guest_"${TAG}"_linux_arm64 \ - dist/SHA256SUMS + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --target "$GITHUB_SHA" \ + release-assets/* + gh release edit "$TAG" --draft=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a7c206..33c038a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,21 +14,137 @@ concurrency: cancel-in-progress: true jobs: - test: + macos: + name: macOS unit and guest build runs-on: macos-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod cache: true + - run: make test + - run: CGO_ENABLED=0 go test ./cmd/... + - run: go vet ./... + - run: go build -o /tmp/abox ./cmd/abox + - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags abox_guest -o /tmp/abox-guest ./cmd/abox-guest - - name: Test - run: make test + ubuntu-build-only: + name: Ubuntu cgo-disabled unit/vet (no KVM evidence) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: true + - name: Unit tests and vet without cgo + env: + CGO_ENABLED: "0" + run: | + set -euo pipefail + make test + go test ./... + go vet ./... + go build -o /tmp/abox ./cmd/abox + go build -o /tmp/abox-vmm-stub ./cmd/abox-vmm + - name: Cross-build both guest architectures + run: | + set -euo pipefail + for arch in amd64 arm64; do + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -tags abox_guest \ + -o "/tmp/abox-guest-linux-$arch" ./cmd/abox-guest + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build \ + -o "/tmp/abox-vmm-linux-$arch-stub" ./cmd/abox-vmm + done + - name: Validate workflows + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + actionlint -shellcheck= -config-file .github/actionlint.yaml + - name: Record build-only scope + run: | + echo '### Ubuntu build-only result' >> "$GITHUB_STEP_SUMMARY" + echo 'This job has no KVM/libkrun and is not Linux runtime or isolation evidence.' >> "$GITHUB_STEP_SUMMARY" - - name: Build abox - run: go build -o /tmp/abox ./cmd/abox + arch-build-only: + name: Arch 2026-09-17 cgo/image build (no KVM evidence) + runs-on: ubuntu-latest + container: + image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b + steps: + - name: Install pinned Arch snapshot dependencies + run: | + set -euo pipefail + printf '%s\n' 'Server = https://archive.archlinux.org/repos/2026/09/17/$repo/os/$arch' > /etc/pacman.d/mirrorlist + pacman -Syyu --noconfirm + pacman -S --needed --noconfirm git curl ca-certificates pkgconf \ + fakeroot e2fsprogs \ + zstd python shadow util-linux binutils file + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Install checksum/signature-pinned libkrun pair + run: sh scripts/install-arch-libkrun.sh + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: true + - name: Run real pkg-config/cgo compatibility checks + run: | + set -euo pipefail + sh scripts/check-libkrun.sh 1.19.4 libkrunfw.so.5 + CGO_ENABLED=1 go test ./... + CGO_ENABLED=0 go test -tags abox_guest -run TestGuestCommand ./internal/guest/tools + CGO_ENABLED=1 make vmm + ldd bin/abox-vmm | grep -Eq 'libkrun\.so\.1[[:space:]]+=>' + CGO_ENABLED=0 go build -o /tmp/abox-vmm-stub ./cmd/abox-vmm + - name: Run rootless image builder + run: | + set -euo pipefail + useradd --create-home --shell /bin/bash abox-ci + chown -R abox-ci:abox-ci "$GITHUB_WORKSPACE" + runuser -u abox-ci -- env HOME=/home/abox-ci GITHUB_WORKSPACE="$GITHUB_WORKSPACE" \ + sh -c 'cd "$GITHUB_WORKSPACE" && sh scripts/test-rootless-image-build.sh amd64' + - name: Record build-only scope + run: | + echo '### Arch build-only result' >> "$GITHUB_STEP_SUMMARY" + echo 'This unprivileged container has no KVM and is not runtime or isolation evidence.' >> "$GITHUB_STEP_SUMMARY" - - name: Build guest - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /tmp/abox-guest ./cmd/abox-guest + fedora-build-only: + name: Fedora 44 cgo/image build (no KVM evidence) + runs-on: ubuntu-latest + container: + image: fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80 + steps: + - name: Install exact Fedora 44 dependencies + run: | + set -euo pipefail + dnf -y --releasever=44 install git curl ca-certificates gcc gcc-c++ make \ + pkgconf-pkg-config \ + fakeroot e2fsprogs zstd python3 shadow-utils util-linux binutils file \ + tar gzip findutils diffutils + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Install checksum-pinned libkrun pair + run: sh scripts/install-fedora-libkrun.sh + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: true + - name: Run real pkg-config/cgo compatibility checks + run: | + set -euo pipefail + sh scripts/check-libkrun.sh 1.19.0 libkrunfw.so.5 + CGO_ENABLED=1 go test ./... + CGO_ENABLED=0 go test -tags abox_guest -run TestGuestCommand ./internal/guest/tools + CGO_ENABLED=1 make vmm + ldd bin/abox-vmm | grep -Eq 'libkrun\.so\.1[[:space:]]+=>' + CGO_ENABLED=0 go build -o /tmp/abox-vmm-stub ./cmd/abox-vmm + - name: Run rootless image builder + run: | + set -euo pipefail + useradd --create-home --shell /bin/bash abox-ci + chown -R abox-ci:abox-ci "$GITHUB_WORKSPACE" + runuser -u abox-ci -- env HOME=/home/abox-ci GITHUB_WORKSPACE="$GITHUB_WORKSPACE" \ + sh -c 'cd "$GITHUB_WORKSPACE" && sh scripts/test-rootless-image-build.sh amd64' + - name: Record build-only scope + run: | + echo '### Fedora build-only result' >> "$GITHUB_STEP_SUMMARY" + echo 'This unprivileged container has no KVM and is not runtime or isolation evidence.' >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index 130c895..49972e3 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,15 @@ MODULE := github.com/AdminTurnedDevOps/ABox BIN := bin ENTITLEMENTS := assets/entitlements.plist -IMAGE ?= $(HOME)/.abox/images/abox-guest.raw +PLATFORM ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) +GUEST_ARCH ?= $(ARCH) +IMAGE_ID ?= abox-guest-dev +CGO_ENABLED ?= $(shell go env CGO_ENABLED) +GUEST_BIN := $(BIN)/abox-guest-linux-$(GUEST_ARCH) +IMAGE ?= $(HOME)/.abox/images/abox-guest-linux-$(GUEST_ARCH).raw -.PHONY: all build guest vmm abox image test fmt tidy sign +.PHONY: all build guest vmm vmm-preflight abox image image-update test test-freeze-tags fmt tidy sign all: build @@ -15,25 +21,57 @@ abox: vmm: mkdir -p $(BIN) - go build -o $(BIN)/abox-vmm ./cmd/abox-vmm - $(MAKE) sign + $(MAKE) vmm-preflight + CGO_ENABLED=$(CGO_ENABLED) go build -o $(BIN)/abox-vmm ./cmd/abox-vmm + $(if $(filter darwin,$(PLATFORM)),$(MAKE) sign,) + +vmm-preflight: + @if [ "$(PLATFORM)" = linux ] && [ "$(CGO_ENABLED)" = 0 ]; then \ + echo "CGO_ENABLED=0: building the diagnostic abox-vmm stub; it cannot start a VM" >&2; \ + elif [ "$(PLATFORM)" = linux ]; then \ + if ! command -v pkg-config >/dev/null 2>&1 || \ + ! pkg-config --atleast-version=1.19.0 libkrun >/dev/null 2>&1 || \ + ! pkg-config --max-version=1.19.99 libkrun >/dev/null 2>&1; then \ + echo "Linux VMM builds require libkrun 1.19.x and its pkg-config metadata." >&2; \ + echo "Install pkgconf + libkrun (Arch), pkgconf-pkg-config + libkrun-devel (Fedora)," >&2; \ + echo "or set PKG_CONFIG_PATH for a pinned source install (often /usr/local/lib64/pkgconfig)." >&2; \ + exit 1; \ + fi; \ + fi guest: + @if [ "$(GUEST_ARCH)" != amd64 ] && [ "$(GUEST_ARCH)" != arm64 ]; then \ + echo "unsupported GUEST_ARCH=$(GUEST_ARCH); expected amd64 or arm64" >&2; \ + exit 1; \ + fi + @case "$(IMAGE_ID)" in ''|*[!A-Za-z0-9._-]*) echo "invalid IMAGE_ID=$(IMAGE_ID)" >&2; exit 1;; esac mkdir -p $(BIN) - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o $(BIN)/abox-guest-linux-arm64 ./cmd/abox-guest + CGO_ENABLED=0 GOOS=linux GOARCH=$(GUEST_ARCH) go build -tags abox_guest -ldflags "-X main.imageID=$(IMAGE_ID)" -o $(GUEST_BIN) ./cmd/abox-guest sign: - codesign --entitlements $(ENTITLEMENTS) --force -s - $(BIN)/abox-vmm + @if [ "$(PLATFORM)" = darwin ]; then \ + codesign --entitlements $(ENTITLEMENTS) --force -s - $(BIN)/abox-vmm; \ + else \ + echo "codesign skipped on $(PLATFORM)"; \ + fi image: guest - ABOX_IMAGE=$(IMAGE) sh images/build-guest.sh + ABOX_GUEST_ARCH=$(GUEST_ARCH) ABOX_IMAGE=$(IMAGE) ABOX_IMAGE_ID=$(IMAGE_ID) sh images/build-guest.sh image-update: guest - ABOX_IMAGE=$(IMAGE) sh images/update-guest-bin.sh + ABOX_GUEST_ARCH=$(GUEST_ARCH) ABOX_IMAGE=$(IMAGE) ABOX_IMAGE_ID=$(IMAGE_ID) sh images/update-guest-bin.sh -test: +test: test-freeze-tags go test ./protocol ./internal/... ./pkg/... +test-freeze-tags: + @host_files=$$(go list -f '{{join .GoFiles " "}}' ./internal/guest/tools); \ + case " $$host_files " in *" freeze_stub.go "*) ;; *) echo "host tools build did not select freeze_stub.go" >&2; exit 1;; esac; \ + case " $$host_files " in *" freeze_linux.go "*) echo "host tools build selected freeze_linux.go" >&2; exit 1;; esac + @guest_files=$$(CGO_ENABLED=0 GOOS=linux GOARCH=$(GUEST_ARCH) go list -tags abox_guest -f '{{join .GoFiles " "}}' ./internal/guest/tools); \ + case " $$guest_files " in *" freeze_linux.go "*) ;; *) echo "tagged guest build did not select freeze_linux.go" >&2; exit 1;; esac; \ + case " $$guest_files " in *" freeze_stub.go "*) echo "tagged guest build selected freeze_stub.go" >&2; exit 1;; esac + fmt: gofmt -w ./cmd ./internal ./protocol diff --git a/PLAN-ABOX-SDK.md b/PLAN-ABOX-SDK.md index 178e2c7..91edf38 100644 --- a/PLAN-ABOX-SDK.md +++ b/PLAN-ABOX-SDK.md @@ -2,6 +2,16 @@ ## Context +**Current-platform note (Sept 2026):** This is the historical v0.2 SDK plan. +The current SDK requires protocol 4 and architecture-tagged images with +adjacent manifests. The host runtime is implemented for macOS/arm64 and for +Linux amd64/arm64, but Linux VMM execution, release support, and KVM isolation +remain **Planned** pending separate Phase 0.5 and Phase 18 evidence on the +pinned Arch/Fedora x86_64 baselines. Linux builds use libkrun 1.19.x through +pkg-config and the rootless native image builder; WSL2 and containerized VMM +execution are unsupported. The original macOS-only runtime assumption is +superseded, while macOS remains the currently runnable release path. + ABox's host orchestration is already SDK-shaped: `cmd/abox/main.go` composes configuration, session creation or ID-based loading, source-directory snapshotting, VM startup, and `Sandbox.UserTurn`. The goal is a public Go SDK @@ -114,7 +124,9 @@ Core problem (both sides currently block): - `examples/sdk-basic/main.go`: open session, run a turn with live event printing, cancel on SIGINT, print usage/cost fields, export patch, close. - README: new "Go SDK" section (import path, minimal snippet, guest-version caveat re: resume + `make image-update`). -- `pkg/abox/doc.go` package docs. Note the Apple Silicon + libkrun + golden image runtime requirements up front. +- `pkg/abox/doc.go` package docs. Note the qualified host runtime, + libkrun/libkrunfw, and architecture-tagged golden image plus manifest up + front; Linux runtime support remains gated. - Makefile `test` target already globs `./internal/...` — extend to `./protocol ./internal/... ./pkg/...`. (Single-line additive change; call out to user.) The docs should be built in GitHub Pages diff --git a/PLAN-CRED.md b/PLAN-CRED.md index 6d704d2..cd40c86 100644 --- a/PLAN-CRED.md +++ b/PLAN-CRED.md @@ -2,6 +2,18 @@ ## Context +**Current-platform note (Sept 2026):** This document preserves the historical +credential-overhaul sequence. Its macOS-only `keychain` naming is superseded by +the implemented canonical `keystore` source: macOS dispatches to Keychain and +Linux dispatches to Secret Service through `secret-tool` plus bounded `gdbus` +probes. `keychain` and `secretservice` remain accepted aliases; saved config +uses `keystore`. Linux warns and falls back to `credentials.env` at mode 0600 +when Secret Service is absent, locked, loses its provider, or times out. +`vault`, `azure`, and `aws` remain cross-platform and are the preferred +headless Linux sources. Protocol 4 now host-brokers both LLM and remote MCP +traffic, so later historical statements that MCP tokens still enter the guest +are also superseded. + At the start of this overhaul, ABox violated the intended host-only LLM credential boundary and stored credential values in session configuration: - All secrets lived plaintext in `~/.abox/credentials.env` (internal/credentials/credentials.go). @@ -11,15 +23,24 @@ At the start of this overhaul, ABox violated the intended host-only LLM credenti User research (Sept 2026) recommends: host-side credential-source abstraction, resolve only the selected model's credential, never persist resolved values, remove secrets from guest config, and move provider transport behind a host broker. **User approved all three phases**, keychain via `security(1)` subprocess (no cgo — `abox` stays plain `go build`), Vault via `VAULT_ADDR`/`VAULT_TOKEN` KV v2. -**Approved source set (user decision, Sept 2026):** `env`, `keychain` (macOS), `vault` (HashiCorp Vault KV v2), `azure` (Azure Key Vault), `aws` (AWS Secrets Manager). All cloud stores via stdlib HTTP or a CLI subprocess — no HashiCorp/Azure/AWS SDKs, no cgo. +**Current source set:** `env`, `keystore` (macOS Keychain or Linux Secret +Service), `vault` (HashiCorp Vault KV v2), `azure` (Azure Key Vault), and `aws` +(AWS Secrets Manager). `keychain` and `secretservice` are accepted aliases. All +cloud stores use stdlib HTTP or a CLI subprocess; there are no +HashiCorp/Azure/AWS SDKs and no cgo in `abox`. -**Deferred (documented, not built):** Kubernetes sources, workload identity federation (Azure managed identity, AWS IAM roles — this milestone uses static SP/env credentials only), MCP traffic brokering (guest MCP client keeps its TSI path this milestone; PLAN.md §14.4 is the follow-up that removes MCP tokens from the guest), agentgateway LLM routing (stays "direct base_url" exactly as today — flagged, never claimed enforced, per PLAN.md §14.3). +**Still deferred:** Kubernetes sources, workload identity federation (Azure +managed identity and AWS IAM roles; this milestone uses static SP/env +credentials), and agentgateway LLM routing. The historical protocol-3 plan also +deferred MCP traffic brokering, but protocol 4 has since implemented the host +MCP broker and removed MCP tokens from the guest. ## Global decisions 1. New host-only package `internal/credsource`; `internal/credentials` stays as the credentials.env file store (env-source backend + fallback writer). Import direction: `credsource` may import `config`; `config` never imports `credsource`. 2. Cloud secret stores via stdlib HTTP or CLI subprocess only — no HashiCorp/Azure/AWS SDK dependency, no cgo: Vault = one `GET /v1//data/` with `X-Vault-Token`; Azure Key Vault = stdlib OAuth2 client-credentials token POST plus `GET {vault}/secrets/{name}` Data Plane REST; AWS Secrets Manager = in-package SigV4 over `GetSecretValue` REST with static env credentials. -3. `_REFRESH` write: **delete it** (oauth.go:83). Future work note: persist client_id + refresh token in keychain, implement the refresh grant. +3. `_REFRESH` write: **delete it** (oauth.go:83). Future work note: persist + client ID + refresh token in the OS keystore and implement the refresh grant. 4. One protocol bump, `protocol.Version` 2 → 3, at Phase 3. Phase 2 needs no protocol change: v2 guests already implement `set_model`/`set_mcp_tokens` (cmd/abox-guest/main.go:238-259) and tolerate secretless boot config. Protocol-1 guests cannot run agent sessions from the rewritten secretless config; resume is rejected explicitly rather than reporting a misleading ready state. 5. Phase 3 is **version-gated, not a config mode**: proto ≥ 3 guest binaries have no direct provider transport (broker is the only LLM path); proto == 2 guests get the legacy post-hello secret push + stderr deprecation warning. No `model_transport` knob. 6. Phase 3 prerequisite: before the reader-goroutine demux, the host could not receive guest-initiated frames because `Sandbox.Call` read the connection inline and dropped frames outside its awaited ID. Task 3.1 supplied that demux before broker methods were enabled. @@ -37,7 +58,7 @@ type Value struct { Bytes []byte; Version string; ExpiresAt time.Time; LeaseID s func (v *Value) Zero() // best-effort overwrite func (v Value) String() string // "credsource.Value(redacted)" — defeats accidental %v logging type Source interface { Resolve(context.Context, Reference) (Value, error); Close() error } -type Resolver struct{ ... } // registers env, keychain (darwin), vault +type Resolver struct{ ... } // registers env, portable keystore, cloud sources var ErrNotFound, ErrLocked error ``` Errors mention only Source/Name, never values. @@ -76,19 +97,23 @@ models: provider: anthropic model: claude-sonnet-4-20250514 credential: - source: keychain # env | keychain | vault | azure | aws - name: ANTHROPIC_API_KEY # env: var; keychain: account; vault: KV-v2 path; azure: secret URI; aws: secret ID + source: keystore # env | keystore | vault | azure | aws + name: ANTHROPIC_API_KEY # env: var; keystore: account; vault: KV-v2 path; azure: secret URI; aws: secret ID field: api_key # vault/aws only (vault default "value"; aws unset = whole SecretString) version: "4" # vault/azure only (optional) # credential_env: X # DEPRECATED alias == {source: env, name: X} mcp_servers: - name: github url: https://... - credential: {source: keychain, name: ABOX_MCP_GITHUB_TOKEN} + credential: {source: keystore, name: ABOX_MCP_GITHUB_TOKEN} ``` - `CredentialRef` struct in `config`; `Model.Credential *CredentialRef`, `MCPServer.Credential *CredentialRef`. - `Model.CredentialReference()`: explicit ref, else `{env, CredentialEnv}`, else `{env, EnvName()}`. New `Model.EnvName()`: CredentialEnv, else canonical provider env from `DefaultProviders()`, else `ABOX_MODEL__KEY` — fills `protocol.GuestModel.CredentialEnv` (ToGuest, config.go:250) so Phase-1 wire format is unchanged. `MCPServer.CredentialReference()` reuses `TokenEnv` (config.go:376). -- Validate: reject both `credential` and `credential_env` set; source ∈ {env, keychain, vault, azure, aws}; env names pass `ValidEnvName`; `field` vault/aws only; `version` vault/azure only; azure `name` must be an `https://…vault.azure.net/secrets/…` (or other region suffix) URI. +- Validate: reject both `credential` and `credential_env` set; canonical source + is one of `env`, `keystore`, `vault`, `azure`, or `aws`; accepted local-store + aliases canonicalize to `keystore`; env names pass `ValidEnvName`; `field` is + vault/aws only; `version` is vault/azure only; Azure `name` must be an + `https://...vault.azure.net/secrets/...` (or other region suffix) URI. - **Delete `SecretsFromEnv`** (config.go:281-300) + its test. Replace `Model.CredentialPresent` (config.go:396; sole caller tui.go:444) with resolver-backed presence check so keychain/vault keys don't render "missing". Presence is resolved **once when the picker opens** (cached per session), never in the render path — a `security` subprocess or Vault HTTP call per frame would freeze the TUI. - `credsource.FromConfig(config.CredentialRef) Reference` glue. @@ -104,9 +129,18 @@ Uses `cfg.ResolvedMCPServers()` (config.go:307) — offline resolves no MCP toke ### 1.7 TUI keychain-by-default, migration, mcpauth Modify: internal/tui/commands.go (:47, :56), tui.go (:335-389, :444), internal/mcpauth/oauth.go (:57-86), cmd/abox/main.go. -- `applyProviderKey`: try `SetKeychain`; on success upsert the model's `credential: {keychain, ...}` ref and `cfg.Save()` (config.go:354); status "key saved to macOS keychain (service abox)". On ErrLocked/unavailable, fall back to `credentials.Save` and replace any stale explicit cloud/keychain reference with `credential: {source: env, ...}`. `applyMCPKey` mirrors this selected-source update. -- `mcpauth.LoginNamed`: same keychain-preferred writer; **delete the `_REFRESH` write**. -- New CLI `abox creds migrate` (dispatched like `mcp`, main.go:35): move credentials.env entries to keychain (including MCP OAuth tokens; re-login is the fallback for expired ones), update matching config refs, drop `*_REFRESH` keys, rewrite credentials.env to a comment (kept, 0600). No silent startup migration — env source keeps working indefinitely. +- Current `applyProviderKey`/`applyMCPKey`: try the OS keystore; on success + persist canonical `credential: {source: keystore, ...}`. On + `ErrLocked`/unavailable/timeout, warn and fall back to `credentials.Save` at + mode 0600 with `source: env`. +- `mcpauth.LoginNamed`: use the same OS-keystore-preferred writer; **delete the + `_REFRESH` write**. +- New CLI `abox creds migrate` (dispatched like `mcp`, main.go:35): move + `credentials.env` entries to the available OS keystore (including MCP OAuth + tokens; re-login is the fallback for expired ones), update matching config + refs to canonical `keystore`, drop `*_REFRESH` keys, and rewrite + `credentials.env` to a mode-0600 comment. There is no silent startup + migration; the env source keeps working indefinitely. ### Phase 1 verification `go build ./...` && `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/abox-guest` && `go test ./protocol ./internal/... ./pkg/...` && `golangci-lint run ./...`. @@ -199,7 +233,9 @@ Gates as before + `make build && make image` smoke (`abox --probe-vm`, then a re ## Risks / notes 1. Demux refactor (3.1) touches every RPC path incl. cancel edge cases — own PR, existing suite green before broker methods. -2. Keychain headless/SSH (`abox exec`, locked keychain) → ErrLocked with guidance; use env-source refs in CI. Document. +2. OS keystore in headless/SSH use may be unavailable or locked. Linux warns + before its 0600 plaintext fallback; use `vault`, `azure`, or `aws` when that + fallback is unacceptable. 3. MCP tokens still enter the guest this milestone — accepted; §14.4 brokering is the follow-up. 4. Zeroing is best-effort (Go GC copies); stated in package docs. 5. Old `abox` binary resuming a scrubbed session re-writes secrets into config.raw (old Prepare); next new-binary start re-scrubs. Mixed-binary users only. @@ -211,8 +247,11 @@ Gates as before + `make build && make image` smoke (`abox --probe-vm`, then a re - Protocol 3 provider HTTPS and LLM authentication are host-brokered. LLM credential values are absent from the guest and session config; compatibility model metadata, including `base_url` and the credential environment-variable name, remains on the guest config disk but is not trusted for protocol-3 routing. - Startup credential resolution is partial: successfully resolved MCP tokens are pushed even if the selected model credential is missing. Missing optional MCP tokens are skipped; other source failures are reported after the partial push. Interactive CLI may continue after reporting the error; headless CLI and SDK startup return it. - Protocol-1 resume is rejected after the host rewrites `config.raw` without secrets. Protocol 2 remains the legacy secret-push path; protocol 3 keeps LLM credentials host-side. -- MCP tokens still enter guest memory through `set_mcp_tokens`; MCP traffic and credential brokering remain follow-up work. +- Protocol 4 host-brokers remote MCP and keeps MCP tokens out of the guest. The + earlier protocol-3 `set_mcp_tokens` status is historical. - Session scrubbing reports aggregate per-session errors and aborts CLI/SDK startup if any legacy session could not be scrubbed. - Azure and AWS credential-source authentication is limited to static host credentials (or an existing Azure CLI login); managed/workload identity is deferred. - `llmbroker.Broker` still has no config-update API. The SDK and TUI therefore install a newly constructed broker after an idle model change so subsequent streams use current aliases, base URLs, and credential references. A broker-owned atomic `UpdateConfig` API would remove direct handler replacement and better define concurrent SDK `SetModel` behavior. - Isolation remains **Planned** until the named hardware tests pass. +- Linux/KVM requires its own Phase 0.5 and Phase 18 evidence on both pinned + Arch and Fedora baselines; macOS evidence does not cover it. diff --git a/PLAN-LINUX.md b/PLAN-LINUX.md index f5d9f92..5ffb489 100644 --- a/PLAN-LINUX.md +++ b/PLAN-LINUX.md @@ -2,25 +2,42 @@ ## Context -ABox runs only on Apple Silicon today. `cmd/abox-vmm/start_darwin_arm64.go` is -tagged `darwin && arm64`, `start_stub.go` refuses every other platform, and -`internal/runtime/runtime.go:930` calls `unix.Clonefile`, which exists only in -`zsyscall_darwin_{amd64,arm64}.go` — so `internal/runtime` does not compile on -Linux at all. `go build ./cmd/abox` and `make test` fail before reaching any -VMM concern. - -Every isolation primitive ABox depends on is, however, *more* native on Linux -than on macOS: libkrun's primary backend is KVM, and Hypervisor.framework is the -newer target. Verification against the real `libkrun 1.19.4` x86_64 package -(Sept 2026) shows **all fifteen libkrun symbols `start_darwin_arm64.go` calls -exist in the Linux build with identical semantics**, including -`krun_add_vsock(ctx, 0)`, whose header documents "Use 0 to add vsock without any -TSI hijacking." The device plan — no NIC, no TSI inet, no host-path virtio-fs, -two raw disks, one vsock port — transfers verbatim. - -This is therefore a port of build plumbing and host-side syscalls, **not** of the -isolation design. The agent loop, protocol, brokers, credential resolution, -session layout, and guest image contents are unchanged. +This plan began when ABox ran only on Apple Silicon: the VMM binding was tagged +`darwin && arm64`, the stub refused every other platform, and the runtime used +the Darwin-only `unix.Clonefile`. Those porting blockers have now been removed. +The shared libkrun binding, Linux clone path, architecture manifests, rootless +image builder, Linux lifecycle signals, and Secret Service keystore are +implemented in the working tree. + +Implementation is not support evidence. Linux VMM execution, Linux release +support, and every Linux/KVM isolation claim remain **Planned** until separate +Phase 0.5 and Phase 18 records pass on both pinned x86_64 baselines against the +exact artifacts under test. macOS/HVF evidence cannot cover KVM, and Arch +evidence cannot cover Fedora. + +Current phase status: + +| Area | Status | +| --- | --- | +| Portable compile/binding, lifecycle, image identity/builder, keystore | Implemented; unit/build verification required | +| Linux preflight and mapped KVM/loader/SELinux diagnostics | Implemented; real KVM/SELinux behavior remains part of the hardware gate | +| Arch/Fedora build-only CI and gated Linux release workflow | Implemented plumbing; no checked-in passing hardware evidence and no support claim | +| Linux Phase 0.5 and Phase 18 hardware evidence | Planned; runtime/isolation support gate is closed | + +libkrun's primary Linux backend is KVM, while macOS uses Hypervisor.framework. +Inspection of the Arch `libkrun 1.19.4` x86_64 package (Sept 2026) shows that all +libkrun symbols currently used by the shared `start_libkrun.go` binding exist, including +`krun_add_vsock(ctx, 0)`, whose header specifies no TSI hijacking. Symbol and +header compatibility establish that a spike is possible; they do **not** prove +identical KVM behavior. Phase 0 must verify that the intended device plan — no +NIC, no TSI inet/Unix, no host-path virtio-fs, two raw disks, one vsock port — +actually has those semantics on each pinned Linux baseline. + +The isolation design and agent protocol remain unchanged, but this is more than +a cgo/syscall port: image identity/building, session compatibility metadata, +credential persistence, lifecycle handling, CI/release plumbing, and platform +documentation all change. The agent loop, host LLM/MCP brokers, typed RPC, and +guest rootfs package/tool set remain common across hosts. **Goal:** `abox` runs natively on Arch and Fedora (x86_64) with the security posture preserved, and `make image` builds the guest disk with no Docker, no @@ -28,9 +45,10 @@ root, and no privileged container. ### User decisions (Sept 2026) -- **Distros:** Arch and Fedora supported and tested. Debian/Ubuntu documented as - a source build with exact commands, explicitly marked untested — neither ships - prebuilt libkrun/libkrunfw packages. +- **Distros:** Arch and Fedora are the pinned x86_64 build and future runtime + baselines. Runtime support remains Planned until both hardware gates pass. + Debian/Ubuntu is a source build explicitly marked untested; neither ships the + pinned prebuilt libkrun/libkrunfw package pair. - **Image builder:** rootless native on Linux; macOS keeps its Docker packer. - **CPU arch:** x86_64 first, parameterized by `GOARCH` so linux/arm64 (Fedora Asahi) is a configuration change rather than a rewrite. @@ -43,18 +61,17 @@ root, and no privileged container. ### Credential parity (in scope) -Of the five credential sources, **four already work on Linux unchanged**. +At the start of the port, four of the five credential sources already worked on +Linux unchanged. `env`, `vault`, `azure`, and `aws` contain no `GOOS`/darwin/macOS-path references — a direct consequence of PLAN-CRED.md global decision 2 (stdlib -HTTP or CLI subprocess only, no SDKs, no cgo). Only `keychain` -(internal/credsource/keychain.go:23, hardcoded `/usr/bin/security`) is -macOS-only. +HTTP or CLI subprocess only, no SDKs, no cgo). Only the local `keychain` +implementation was macOS-only. It is now the portable `keystore` source, +dispatched to macOS Keychain or Linux Secret Service. -The gap is therefore the *local, no-infrastructure* keystore — exactly ABox's -laptop-local thesis. Standing up Vault to hold one API key is absurd for that -user, yet on Linux today `SavePreferred` (internal/credsource/save.go:41) sees -`KeychainEnabled() == false` and writes every `/provider` and `/mcp` key to -plaintext `credentials.env`. Closing that is in scope. +The gap was therefore the *local, no-infrastructure* keystore. The implemented +Linux path probes Secret Service and uses a warned 0600 `credentials.env` +fallback when it is absent, locked, loses its provider, or times out. Credential sources are siblings, not a stack: `Resolver.Resolve` performs a single `r.sources[ref.Source]` lookup with no chaining, so each model or MCP @@ -73,21 +90,32 @@ documented headless answer instead. 2. **cgo flags split from cgo logic.** cgo accumulates `#cgo` directives package-wide across files in a package, so the ~90-line `startVM` body is shared and only the preamble files are platform-tagged. No duplicated binding. -3. **Linux links `-lkrun` only, via pkg-config.** libkrun `dlopen`s - `libkrunfw.so.5` at runtime on Linux (confirmed: no undefined `krunfw_*` - symbols in `libkrun.so.1.19.4`), so `-lkrunfw` is omitted. `#cgo pkg-config: - libkrun` resolves correctly under pacman, dnf, and a `make install` into - `/usr/local`; macOS keeps its explicit Homebrew paths and `-lkrunfw`. +3. **Linux links `-lkrun` only, via pkg-config.** The verified Arch libkrun + 1.19.4 package `dlopen`s `libkrunfw.so.5` at runtime (and has no undefined + `krunfw_*` symbols), so `-lkrunfw` is omitted. The exact firmware SONAME is a + property of the pinned distro libkrun package, not an ABox-wide constant; + Fedora support must record and test its compatible pair. `#cgo pkg-config: + libkrun` resolves at link time under pacman, dnf, and a source install when + `PKG_CONFIG_PATH` includes `/usr/local/lib64/pkgconfig`. Runtime discovery is + separate and covered by Phase 5; macOS keeps its explicit Homebrew paths and + `-lkrunfw`. 4. **`cloneFile` becomes platform-split, and gains a Linux fast path.** `unix.IoctlFileClone` (FICLONE reflink; btrfs, XFS `reflink=1`, bcachefs) then `unix.CopyFileRange` (in-kernel copy, works on ext4) then the existing `io.Copy` fallback. Fedora defaults to btrfs, so the 768 MiB per-session golden-image clone is near-instant there. -5. **The golden image filename carries its architecture.** An arm64 rootfs booted - under an x86_64 kernel is an unexplained guest panic; a silent failure mode is - unacceptable. `config.GuestImageName` (internal/config/config.go:17) becomes a - function of `runtime.GOARCH`, with the bare `abox-guest.raw` kept as a legacy - fallback in `ImageDir()`, mirroring the existing `~/Library/Caches` lookup. +5. **The golden image path and manifest carry its architecture.** An arm64 + rootfs booted under an x86_64 kernel is an unexplained guest panic; a silent + failure mode is unacceptable. `config.GuestImageName` + (internal/config/config.go:17) becomes a function of `runtime.GOARCH`. Every + image, including a configured custom path, has an adjacent manifest with a + schema version, guest architecture, image ID, guest protocol, and SHA-256. + Published names and the local current-generation pointer are architecture + tagged. The bare `abox-guest.raw` is discovery-only compatibility data, not + trusted architecture evidence. On darwin/arm64, where all previously runnable + images were arm64, a successful one-time validation may write its manifest. + Linux refuses a metadata-free legacy image with a rebuild/migration + instruction; it never infers architecture from the host. 6. **`/dev/vhost-vsock` is not a requirement.** libkrun implements virtio-vsock in userspace and maps guest ports onto host Unix sockets via `krun_add_vsock_port` — exactly how `sess.RPCSocket()` already works. There @@ -108,17 +136,54 @@ documented headless answer instead. 10. **`ErrNotFound` vs `ErrLocked` needs a separate availability probe.** `secret-tool` returns non-zero for both "no such item" and "no service", so exit code alone cannot distinguish them the way macOS exit code 44 does. - Availability = `secret-tool` on PATH plus a reachable session bus. This - preserves the distinction `SavePreferred` depends on for its fallback. + Availability = trusted `/usr/bin/secret-tool` plus a reachable session bus and a + provider that owns or can activate `org.freedesktop.secrets`. Merely finding + `DBUS_SESSION_BUS_ADDRESS` is not sufficient. Use a bounded, non-mutating + D-Bus probe before lookup/store so a bus with no provider is classified as + unavailable. This preserves the distinction `SavePreferred` depends on for + its fallback. +11. **Linux release support has a hardware gate.** Generic GitHub-hosted CI proves + compile and unit behavior only. Publishing a supported Linux artifact or + changing a KVM isolation claim from Planned requires every applicable + §21.4/§22 test on named x86_64 KVM hosts for both Arch and Fedora, against the + exact commit and artifacts being released. A boot probe or symbol inspection + is not a substitute. +12. **`keystore` is the canonical persisted credential source.** `keychain` and + `secretservice` remain accepted input aliases. Config load canonicalizes all + three in memory, config save writes `keystore`, and resolver, save, migration, + MCP OAuth, and TUI paths all use one shared canonicalization helper. Validation + itself does not pretend to mutate a value receiver. +13. **Support names exact host baselines.** The initial baselines are Arch's + 2026-09-17 x86_64 snapshot with libkrun 1.19.4-1/libkrunfw 5.5.0-1, and + Fedora 44 x86_64 updates with libkrun/libkrun-devel 1.19.0-1.fc44 and + libkrunfw 5.5.0-1.fc44 (`libkrun.so.1`, `libkrunfw.so.5`). Each baseline + records the required exported symbols and pkg-config metadata. Evidence from + one baseline is not evidence for the other. +14. **WSL2 and containerized ABox runtimes are unsupported initially.** Containers + remain valid compile/image-build environments, but running the VMM inside a + container or WSL2 adds device, seccomp, namespace, and lifecycle surfaces not + covered by this port. Detect those environments and fail clearly even when + `/dev/kvm` happens to exist. A conventional VM with explicitly enabled nested + KVM remains supported only after the same hardware suite passes there. +15. **Approved guest commands do not inherit guest-supervisor privilege.** The + root-owned guest supervisor drops shell and Git subprocesses to UID/GID 1000, + owns `/work/repo` with that identity, strips setuid/setgid bits from the + image, and kills the command process group on completion or cancellation. + This keeps `allow_once` from replacing the guest agent or leaving a daemon + that bypasses a later approval. ## Verified findings (Sept 2026) Checked against `extra/libkrun 1.19.4-1` and `extra/libkrunfw 5.5.0-1`, and against `golang.org/x/sys@v0.47.0` as pinned in `go.mod`. +These package and SONAME findings are Arch-only. They justify the initial spike, +not a Fedora behavior claim. Fedora package metadata confirms the pinned versions +and SONAMEs in Global decision 13; Phase 0 still must prove their runtime behavior. + | Claim | Evidence | | --- | --- | -| All 15 libkrun symbols ABox calls exist on Linux x86_64 | `libkrun.h` + `nm -D` on `libkrun.so.1.19.4` | +| All libkrun symbols currently called by ABox exist on Arch Linux x86_64 | `libkrun.h` + `nm -D` on `libkrun.so.1.19.4` | | `KRUN_FEATURE_BLK` is compiled in | `RegisterBlockDevice` / `OpenBlockDevice` / `AttachBlockDevice` present in the `.so` | | `-lkrunfw` unnecessary on Linux | libkrun `dlopen`s `libkrunfw.so.5`; no undefined `krunfw_*` | | vsock is userspace; `/dev/vhost-vsock` unused | No `vhost` / `/dev/vsock` strings | @@ -128,19 +193,24 @@ against `golang.org/x/sys@v0.47.0` as pinned in `go.mod`. | `unix.Clonefile` is darwin-only | Defined only in `zsyscall_darwin_{amd64,arm64}.go` | | Linux reflink helpers available | `IoctlFileClone` ioctl_linux.go:184; `CopyFileRange` zsyscall_linux.go:635 | | `vault` / `azure` / `aws` are platform-clean | No `GOOS`/darwin/macOS-path references in those three files | -| `azure` CLI fallback is portable | `exec.LookPath("az")`, not a hardcoded macOS path | +| `azure` CLI fallback avoids repository PATH shadowing | Resolves only fixed system/Homebrew locations and uses a minimal environment | | `secret-tool store` takes the secret on stdin | `man secret-tool` STORE; warns a piped newline becomes part of the secret | | Missing key exits 1 with empty stdout | Probed `secret-tool lookup` directly on the dev host | | Exit codes cannot separate not-found from unavailable | `man secret-tool` EXIT STATUS: "0 on success, a non-zero failure code otherwise" | | Secret Service is live on the dev host | `gnome-keyring-daemon --components=pkcs11,secrets` owns `org.freedesktop.secrets` | | KWallet / KeePassXC use the same API | `org.kde.secretservicecompat` activatable on the session bus | +| Fedora 44 libkrun baseline | Fedora package metadata: `libkrun-1.19.0-1.fc44`, provides `libkrun.so.1` | +| Fedora 44 firmware baseline | Fedora package metadata: `libkrunfw-5.5.0-1.fc44`, provides `libkrunfw.so.5` | -## Platform surface +## Original platform surface -Six Go touchpoints, two scripts, the Makefile, and CI. Already portable and -untouched: `protocol/`, all brokers, `internal/agent`, `internal/session`, -`internal/vmmconfig`, and `config.Dir()` (`~/.abox`, with `ABOX_HOME` override — -the `~/Library/...` lookups are legacy read-only fallbacks that no-op on Linux). +At the start of this plan, the host runtime, VMM binding, session metadata, +credential path, two image scripts, Makefile, and CI all needed changes. This +table is retained as the historical implementation inventory; the status table +above is authoritative now. Already portable and untouched were: +`protocol/`, the LLM/MCP brokers, `internal/agent`, `internal/vmmconfig`, and +`config.Dir()` (`~/.abox`, with `ABOX_HOME` override — the `~/Library/...` +lookups are legacy read-only fallbacks that no-op on Linux). | Location | Issue | | --- | --- | @@ -148,8 +218,14 @@ the `~/Library/...` lookups are legacy read-only fallbacks that no-op on Linux). | `cmd/abox-vmm/start_darwin_arm64.go` | Body portable; only `#cgo` paths are Homebrew-specific | | `cmd/abox-vmm/start_stub.go:1` | Tag `!darwin \|\| !arm64` swallows Linux | | `internal/runtime/runtime.go:288` | `DYLD_LIBRARY_PATH` in the fixed `cmd.Env` | +| `internal/runtime/runtime.go:301-329,902-925` | Failure/stop paths must reap the helper and remove stale sockets | +| `internal/session/session.go:16-22` | Resume metadata has no guest architecture, image identity, or backend | | `internal/credsource/keychain.go:23,43` | Hardcoded `/usr/bin/security` — the only credential-source gap on Linux | +| `internal/mcpauth/oauth.go:122-125` | OAuth persistence rejects a canonical `keystore` source | +| `internal/mcpauth/oauth.go:666-668` | Browser launch is hardcoded to macOS `open` | | `cmd/abox/creds.go:35` | macOS-only error copy | +| `cmd/abox/main.go:30-37,127-199,270-283` | Signal ownership is not above boot, TUI, and exec; exec alone watches `os.Interrupt` | +| `internal/guest/tools/freeze_linux.go:1` | Linux host tests compile the guest-only FIFREEZE/FITHAW implementation | | `images/build-guest.sh`, `images/update-guest-bin.sh` | Hardcoded `-linux-arm64`; `--privileged` loop mount | | `Makefile` | `codesign` unconditional; guest pinned to `GOARCH=arm64` | | `.github/workflows/test.yml` | `macos-latest` only | @@ -158,25 +234,62 @@ the `~/Library/...` lookups are legacy read-only fallbacks that no-op on Linux). ## Phase 0 — Spike: boot a VM before changing anything -Throwaway code. Nothing downstream matters if this fails. +Throwaway code. Nothing downstream matters if this fails. Run the complete spike +independently on both pinned baselines: Arch libkrun 1.19.4-1/libkrunfw 5.5.0-1 +and Fedora 44 libkrun 1.19.0-1.fc44/libkrunfw 5.5.0-1.fc44. Do not treat an Arch +success or Fedora package metadata as Fedora runtime evidence. ```bash -sudo pacman -S libkrun libkrunfw fakeroot # Arch -sudo dnf install libkrun libkrunfw fakeroot # Fedora +# Arch uses the 2026/09/17 Arch Linux Archive repository snapshot. +sudo pacman -S --needed base-devel pkgconf libkrun libkrunfw fakeroot e2fsprogs curl +# Fedora runs on Fedora 44 and installs the exact NEVRAs from Global decision 13. +sudo dnf install gcc make pkgconf-pkg-config libkrun-1.19.0-1.fc44 \ + libkrun-devel-1.19.0-1.fc44 libkrunfw-5.5.0-1.fc44 fakeroot e2fsprogs curl ``` -1. Copy `start_darwin_arm64.go` to `start_linux.go`, tag `//go:build linux`, - replace the preamble with `#cgo pkg-config: libkrun`. +The workflow configures the archived Arch repository URL and checksum-pinned +Fedora RPM/repository metadata; a moving mirror is not a baseline. + +1. Copy `start_darwin_arm64.go` to `start_linux.go`, tag the initial x86_64 spike + `//go:build cgo && linux && amd64`, replace the preamble with + `#cgo pkg-config: libkrun`, and temporarily retag `start_stub.go` so exactly + one `startVM` is selected. Alternatively keep the spike outside the product + package. Do not combine the Linux implementation with the current broad stub; + both would define `startVM`. 2. Temporarily reduce `cloneFile` to its `io.Copy` path so `internal/runtime` compiles. Phase 1 does this properly. -3. Build an amd64 guest, hand-pack a rootfs with the Phase 3 recipe, run - `abox --probe-vm`. - -**Exit criterion:** `--probe-vm` prints the guest file listing. - -Watch for: `krun_has_feature(KRUN_FEATURE_BLK) == 1`; `krun_add_vsock_port` -connecting to the host's listening Unix socket in the same direction as macOS; -`krun_set_root_disk_remount` behaving identically. +3. Define and check in the required-symbol manifest that the shared cgo binding is + allowed to use, derived from the copied Phase 0 body plus the planned Phase 1 + cleanup. The current implementation calls eleven unique libkrun functions and + Phase 1 adds `krun_free_ctx`, so do not preserve a stale hard-coded symbol + count; Phase 1 and CI update/check the manifest whenever the binding changes. + On **both** baselines, compare the installed header and `nm -D` output against + every manifest entry, explicitly including + `krun_disable_implicit_vsock`, `krun_add_vsock`, `krun_add_disk3`, + `krun_set_root_disk_remount`, and `krun_start_enter`. Stop before shared-binding + implementation if either package is missing an entry. +4. Build an amd64 guest, hand-pack a rootfs with the Phase 3 recipe, and run a + deterministic probe guest. Do not use an LLM/model-authored command as security + evidence. +5. Record distro/kernel/CPU, KVM modules, libkrun/libkrunfw package versions and + SONAMEs, `krun_has_feature(KRUN_FEATURE_BLK) == 1`, the exact libkrun call + sequence, root remount result, and the working vsock listen direction. +6. Inspect `/sys/class/net`, `/sys/bus/virtio/devices`, mounts, `/dev/dri`, and + `/dev/snd`: loopback exists, but no NIC, GPU, sound, or host-path filesystem is + attached. GPU/sound may be compiled into a distro libkrun without appearing in + the device plan. +7. Prove guest-local loopback and Unix sockets work, then prove deterministic + guest probes cannot reach host TCP/UDP canary listeners, host Unix-socket + canaries, LAN, or external IPv4/IPv6. This is the early proof that + `krun_disable_implicit_vsock` plus `krun_add_vsock(ctx, 0)` disabled both TSI + inet and Unix hijacking on KVM. + +**Exit criterion:** on **both** Arch 1.19.4-1 and Fedora 1.19.0-1.fc44, the +header/`nm -D` required-symbol check passes and `--probe-vm` lists guest files; +the recorded device/network probe proves BLK is enabled, vsock RPC works, no +unintended device is attached, and TSI inet/Unix reachability fails. Keep separate +evidence records for each baseline. This is still spike evidence, not a substitute +for the complete release hardware matrix. --- @@ -191,17 +304,28 @@ Split by platform, keeping the existing `io.Copy` tail shared: - `internal/runtime/clone_other.go` — fallback only `Prepare` (internal/runtime/runtime.go:224) keeps its current signature and -semantics; only the copy mechanism changes. +semantics; only the copy mechanism changes. Each failed fast path must leave the +destination at offset zero and truncated before the next path starts. A failed +clone removes the partial destination. Unit tests cover unsupported reflink, +cross-filesystem errors, a partial `CopyFileRange`, source read failure, cleanup, +and final mode 0600. **1b. Portable cgo binding** in `cmd/abox-vmm/`: -- `cgoflags_darwin_arm64.go` — `//go:build darwin && arm64`; preamble only, +- `cgoflags_darwin_arm64.go` — `//go:build cgo && darwin && arm64`; preamble only, carrying today's Homebrew include/lib paths, `-lkrun -lkrunfw`, and rpath -- `cgoflags_linux.go` — `//go:build linux && (amd64 || arm64)`; preamble only: +- `cgoflags_linux.go` — `//go:build cgo && linux && (amd64 || arm64)`; preamble only: `#cgo pkg-config: libkrun` -- `start_libkrun.go` — the shared `startVM`, tagged for both platforms -- `start_stub.go` — retagged to exclude both; message rewritten to name the - actual per-OS requirement rather than asserting macOS +- `start_libkrun.go` — the shared `startVM`, tagged + `cgo && ((darwin && arm64) || (linux && (amd64 || arm64)))`; it keeps its own + `#include ` and `import "C"`, because C names are file-local even + though `#cgo` flags accumulate package-wide +- `start_stub.go` — the exact complement, including `!cgo`; message rewritten to + name the actual per-OS requirement rather than asserting macOS + +Test both `CGO_ENABLED=1` and `CGO_ENABLED=0` on supported architectures. The +second must compile the explicit diagnostic stub rather than fail with an +undefined `startVM`. **1c.** Make the fixed `cmd.Env` at internal/runtime/runtime.go:288 platform-conditional; drop `DYLD_LIBRARY_PATH` on Linux, where `/usr/lib` is @@ -210,6 +334,58 @@ already on the default search path. **1d.** Reword cmd/abox/creds.go:35 so the message names the platform's actual keystore situation instead of asserting macOS. +**1e. Close and reap every VMM resource path.** Confirm `krun_free_ctx` in the +pinned ABI and release the configuration context on every setup error before +ownership is transferred to `krun_start_enter`. Mark it consumed before that +call; `krun_start_enter` consumes the context, so do not free it if the call +returns. Check the currently ignored console-output return. In `runtime.Start`, +every post-`cmd.Start` failure must kill and `Wait` for the helper, close the +listener, and unlink the socket. `Sandbox.Stop` keeps graceful guest shutdown +followed by a platform-specific helper signal and bounded kill, but always waits +for the final process state and removes the socket. Linux tests observe SIGTERM +(never `os.Interrupt`), then forced SIGKILL when required; tests also cover failed +boot, repeated stop, and no zombie or stale socket. + +`krun_get_shutdown_eventfd` is available only in the libkrun EFI variant and is +not part of the generic Linux path. Linux orderly stop has one exact sequence: +guest shutdown RPC with a deadline, SIGTERM to `abox-vmm`, a bounded wait, then +SIGKILL and `Wait` if it has not exited. Do not use `os.Interrupt`/SIGINT as the +Linux helper fallback. Supervisor death closes the liveness pipe and the helper +exits immediately because no supervisor remains to coordinate guest RPC. + +**1f. Supervisor liveness is an inherited capability.** Implement the liveness +pipe required by `PLAN.md` §4.2: the supervisor retains the write end, passes +only the read end to `abox-vmm` as a fixed inherited descriptor, and the helper +terminates the VM when it observes EOF. No unrelated child inherits the write +end. Record a helper PID only for validated stale cleanup; verify uid, executable, +session ownership, and process start identity before signaling it. Test abrupt +supervisor death without `Sandbox.Stop` and prove no helper or VM remains. + +The helper's inherited descriptor contract is exactly stdin for the validated +config, stdout/stderr for diagnostics, and liveness read fd 3. Go `os/exec` +normally closes descriptors not listed in `ExtraFiles`; retain that behavior and +test it with sentinel parent descriptors so D-Bus, systemd activation, terminal, +and unrelated sockets cannot become ambient helper capabilities. Any descriptors +opened later by libkrun must arise from the validated device plan. + +**1g. Linux signal handling.** `main` creates one signal context before calling +`run(ctx)`; it watches `os.Interrupt`, SIGTERM, and SIGHUP on Linux. Thread that +context through VM preparation/start, exec turns, and a context-aware `tui.Run` +so both TUI and headless paths return through the existing deferred +`Sandbox.Stop`. Remove the inner signal owner from `runExec`; it consumes the +top-level context instead. A second termination signal may force exit. Tests send +all three signals while booting and while each TUI/exec path is ready; +SIGTERM/SIGHUP do not skip cleanup merely because the liveness pipe would +eventually kill the helper. SDK examples may keep their own `os.Interrupt` +contexts; SDK lifecycle remains the caller's `Session.Close` contract. + +**1h. Keep guest filesystem freeze out of host tests.** The Linux build currently +selects `internal/guest/tools/freeze_linux.go`, whose `Freeze()` targets `/`, for +ordinary host `go test`. Put FIFREEZE/FITHAW behind an explicit guest-only build +tag (for example `linux && abox_guest`), compile `abox-guest` with that tag, and +select the non-freezing stub for Linux host unit tests. Add a build/test assertion +that an untagged host test can never issue the root-filesystem ioctls. + --- ## Phase 2 — Multi-arch guest and image identity @@ -218,11 +394,36 @@ keystore situation instead of asserting macOS. `bin/abox-guest-linux-$(GUEST_ARCH)`. - `Makefile`: guard the `vmm` target's `$(MAKE) sign` with `$(filter darwin,...)` so `codesign` is a no-op off darwin. -- Arch-tag the golden image per Global decision 5. -- `runtime.Prepare`: refuse an architecture mismatch with an explicit error - rather than booting into a kernel panic. This is the natural insertion point - for the existing roadmap item *"Image manifest + SHA-256 verify"* — the - manifest should carry the architecture. +- Arch-tag the golden image and create/verify its manifest per Global decision 5. +- `runtime.Prepare`: for a new session, verify the manifest SHA-256 and refuse an + architecture or protocol mismatch before cloning. Resolve the current-generation + pointer once, retain a shared generation lock through manifest read and clone, + and hash the resulting session `root.raw` before accepting it. The builder and + garbage collector take the exclusive side of that lock, so a pointer swap or GC + cannot change/remove the selected generation mid-prepare. Custom images are + opened once and the cloned destination is hashed against their manifest, which + detects concurrent source mutation. A failed check removes the session disk. + Persist manifest schema, guest arch, image ID/digest, guest protocol, and VMM + backend (`hvf` or `kvm`) in `session.json`. +- Resume validates the stored guest architecture against the running host before + starting `abox-vmm` and requires the recorded guest protocol to be in the host's + supported range. It does not compare the mutable `root.raw` with the current + golden image. Existing sessions are concrete compatibility data: darwin/arm64 + may backfill `guest_arch=arm64` because it was the only old runnable backend, + then records protocol only after a successful compatible hello; Linux rejects + metadata-free sessions with an actionable migration error rather than attempting + an unknown disk. +- Normal Open/Resume requires manifest/session guest protocol to equal the current + supported protocol range (initially protocol 4), rejects a future protocol + rather than silently capping it, and confirms the hello value matches recorded + metadata. `--probe-vm` is the one diagnostic exception: it may boot an older + protocol image to test liveness, but must not resolve/push credentials, install + host brokers, resume a real session, or make isolation claims from that result. +- Add session round-trip, cross-architecture rejection, custom-image manifest, + digest mismatch, pointer-swap/GC race, concurrent custom-image mutation, + protocol mismatch/future protocol, credential-free probe, legacy-image, and + legacy-session tests. This means `internal/session` is a Phase 2 touchpoint, + not an untouched package. --- @@ -230,11 +431,16 @@ keystore situation instead of asserting macOS. Replaces Docker, `--privileged`, and `mount -o loop` entirely on Linux. Verified working. `images/build-guest.sh` selects on `uname`; the macOS path is unchanged, -because nothing else can build a Linux ARM64 tree there. +because nothing else can build a Linux ARM64 tree there. Linux writes an immutable +generation directory containing the image and manifest, validates the complete +pair, then atomically swaps one architecture-tagged `current` symlink to that +generation. `GuestImagePath` resolves through the pointer. A crash before the +single pointer rename leaves the prior generation selected; old generations can +be garbage-collected only when no session/build references them. ```sh fakeroot sh -c ' - apk.static --root "$ROOTFS" --initdb --keys-dir "$KEYS" add alpine-base git patch + apk.static --arch "$APK_ARCH" --root "$ROOTFS" --initdb --keys-dir "$KEYS" add alpine-base git patch install -Dm0755 bin/abox-guest-linux-$ARCH "$ROOTFS/usr/local/bin/abox-guest" mkdir -p "$ROOTFS/work/repo" "$ROOTFS/abox-config" "$ROOTFS/tmp" printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions ndots:1\n" > "$ROOTFS/etc/resolv.conf" @@ -242,15 +448,27 @@ fakeroot sh -c ' ' ``` -`apk.static` is a static binary that runs on any distribution; fetch it from the -Alpine CDN pinned by SHA-256. `fakeroot` is required — without it `mke2fs -d` -stamps the invoking uid, producing a rootfs owned by `1000:1000`. Rootfs contents -must stay byte-identical in spirit to the Docker path: alpine-base, git, patch, +`apk.static` is a static binary that runs on any distribution; fetch the host +architecture binary from a versioned Alpine URL pinned by SHA-256, while +`--arch` selects the target rootfs architecture (`amd64` maps to `x86_64`, +`arm64` to `aarch64`). Pin the Alpine release, repositories, keys, and package +versions used for release images; do not resolve an unversioned moving repository +during a release. `fakeroot` is required — without it `mke2fs -d` stamps the +invoking uid, producing a rootfs owned by `1000:1000`. Rootfs contents must stay +byte-identical in spirit to the Docker path: alpine-base, git, patch, `/usr/local/bin/abox-guest`, `/work/repo`, `/abox-config`, resolv.conf. -`images/update-guest-bin.sh`: on Linux, replace the binary in place with -`debugfs -w -R "rm /usr/local/bin/abox-guest"` followed by `-R "write ..."` -(unprivileged), or simply rebuild — it is fast. +Before publishing, run `e2fsck -fn`, use `debugfs` to verify `/` and the guest +binary are owned by `0:0`, verify mode 0755, and extract/read the guest ELF header +to prove it matches the manifest architecture. Check free space before allocating +the 768 MiB image and clean every temporary file on failure. + +`images/update-guest-bin.sh`: prefer a full rebuild on Linux. If the debugfs fast +path is retained, create a new immutable generation, explicitly set the new inode +to uid 0, gid 0, and mode 0755, run `e2fsck -fn`, verify the ELF architecture, +write the new manifest, then atomically swap the generation pointer. A raw +`debugfs write` of a user-owned host binary or two independent renames is not +sufficient. This advances the existing README note that replacing the packer is follow-up work, and removes `--privileged` from the flow of a project whose thesis is @@ -288,12 +506,35 @@ The `Source` interface and `Resolver` wiring are unchanged: attribute injection impossible - map errors per Global decision 10; never include a secret value in an error, per the `Source` contract +- availability invokes `gdbus` with a context timeout to call + `org.freedesktop.DBus.StartServiceByName` for `org.freedesktop.secrets`, then + `NameHasOwner`; this is a non-mutating provider activation/ownership probe and + keeps the implementation subprocess-only +- map a missing bus/provider and provider loss during an operation to `ErrLocked` + so `SavePreferred` follows the warned plaintext fallback; map only a confirmed + missing item to `ErrNotFound` +- confirm item existence through the Secret Service + `org.freedesktop.Secret.Service.SearchItems` D-Bus method using the fixed + `service=abox` and validated `account=` attributes. No returned locked or + unlocked object paths means `ErrNotFound`; an existing locked item, prompt + denial/timeout, transport failure, or `secret-tool` failure is not not-found and + maps to `ErrLocked` or a typed operational error as appropriate +- `secret-tool store` and the provider activation probe run under the existing + ten-second save context. Resolve, store, delete, item search, and provider + activation all use bounded contexts. A timeout, including a blocked graphical + unlock prompt, kills/reaps the subprocess and maps to `ErrLocked`, producing the + explicit plaintext-fallback warning rather than a generic failure +- test missing `gdbus`, a missing bus, a reachable bus without a provider, a + locked collection, a missing item, blocked unlock timeout, subprocess cleanup, + and a provider disappearing during save **4c. Config surface.** `credentialSources` (internal/config/config.go:304) gains -`keystore` and `secretservice`; `CredentialRef.validate` (config.go:312) -normalizes all three spellings to one canonical source. `NewResolver` -(internal/credsource/credsource.go) registers the OS keystore under each accepted -alias so `source: keychain` keeps resolving on both platforms. +`keystore` and `secretservice`. Add a pure `CanonicalCredentialSource` helper; +config load uses it to canonicalize all three spellings to `keystore`, validation +uses the canonical value without relying on mutation, and config save emits the +canonical spelling. `NewResolver` (internal/credsource/credsource.go) resolves +accepted aliases through the same helper so `source: keychain` keeps working on +both platforms. **4d. Fallback warning.** `SaveResult.Note` already carries a human-readable string (save.go:34,40,46). Add an explicit warning when falling back to plaintext @@ -303,10 +544,24 @@ by `abox creds migrate`. **4e. Migration.** `abox creds migrate` needs no logic change once the keystore dispatches; reword the macOS-only error at cmd/abox/creds.go:35. -**4f. Wording.** save.go:40 hardcodes "key saved to macOS keychain (service +**4f. Shared save contract.** +`SavePreferred` returns canonical source `keystore`, and +`internal/mcpauth/oauth.go:persistCredentialReference` must accept and persist it. +Rename keychain-specific result fields where needed so `/provider` and `abox mcp +login` share exactly the same save contract. Add tests for loading each alias, +canonical save output, MCP OAuth persistence, and mixed cloud/keystore configs. + +**4g. Wording.** save.go:40 hardcodes "key saved to macOS keychain (service abox)". Make it platform-accurate, along with `credStatusLabel` (internal/tui/tui.go:154) and the `/provider`, `/credential`, `/mcp` copy. +**4h. Linux OAuth browser launch.** Replace the hardcoded `open` in +`internal/mcpauth/oauth.go` with runtime dispatch: `open` on macOS and `xdg-open` +on Linux. If no graphical launcher is available, print the already validated +authorization URL and continue waiting for the loopback callback so headless +users can open it manually. Add launcher selection, launch failure, manual flow, +and callback timeout tests; never execute a shell command string. + --- ## Phase 5 — Preflight and diagnostics @@ -319,19 +574,111 @@ raw libkrun return code. In `startVM` (Linux) or `runtime.Start`: - `/dev/kvm` `EACCES` — `sudo usermod -aG kvm $USER`, then log out and back in. Arch ships a udev rule granting 0666; Fedora and Debian use `root:kvm 0660`, so this will be the most common first-run failure. -- Host is itself a VM without nested virt — surface `krun_check_nested_virt()`. -- `libkrunfw.so.5` not found — name the package for the detected distribution. +- `/dev/kvm` opens but the KVM API/version or VM-creation capability check fails + — report the actual errno. If the host is itself virtualized, explain that its + administrator must expose nested virtualization. Do not use + `krun_check_nested_virt()` for this: that API reports whether nested + virtualization can be offered to the ABox guest, which ABox does not request. +- compatible libkrunfw SONAME not found — report the SONAME required by the + installed, supported libkrun package and name the package for the pinned distro + baseline; do not hardcode `.so.5` into Fedora diagnostics. +- `libkrun.so` linked at build time but not loadable at runtime — report the + resolved binary dependency and loader search-path remediation. +- libkrun version/API mismatch — enforce the supported version range at build + time and verify every required symbol, including the transitional + `krun_disable_implicit_vsock`, in package/release checks. Map an early loader + `undefined symbol` failure to an incompatible libkrun package rather than an + RPC timeout. A future libkrun release that removes the transitional API needs a + deliberate binding migration, not optimistic SemVer acceptance. +- Fedora permissions look correct but KVM, executable mapping, or `root.raw` + access still returns `EACCES` — identify SELinux enforcing mode and point to the + relevant `ausearch`/journal AVC inspection. Never suggest disabling SELinux; + add or package a narrow policy only if the pinned Fedora hardware test proves + one is required. +- WSL2 or container runtime detected — report it as unsupported for VMM execution + even if `/dev/kvm` is visible; container jobs remain build-only. + +Do not assume `/usr/local/lib64` is on the runtime loader path. Debian/Ubuntu source +installation instructions must include the required loader configuration and +`ldconfig` step (or an explicit reviewed rpath policy), and verification runs +`ldd`/`readelf` before the boot probe. Because libkrun dlopens libkrunfw, the boot +probe must also prove the firmware library can actually be found. + +`runtime.Start` must race guest socket accept against helper exit. Capture a +bounded, redaction-safe helper diagnostic stream while still making it available +for debugging, reap immediately on early exit, and return the mapped KVM/loader +error instead of waiting for a generic RPC accept timeout. SDK callers receive +the same actionable error as the CLI. + +Before invoking Go/cgo, the Makefile's Linux VMM target checks `pkg-config` for +the supported libkrun range and prints a short install/source-build instruction. +If cgo is disabled, explain that the resulting `abox-vmm` is the diagnostic stub +and cannot boot a VM. Do not expose raw pkg-config output as the primary guidance. --- ## Phase 6 — CI and release -- `.github/workflows/test.yml`: matrix `[macos-latest, ubuntu-latest]`. GitHub - runners have no `/dev/kvm`, so the Linux job stays unit tests, `go vet`, and - compile — the same limitation macOS already has. Either install libkrun from - source for the cgo build or skip the `abox-vmm` build on that job. +**Entry criterion:** first land the Phase 7 changes to `PLAN.md` §21.4/§22 and +the Linux support matrix so the hardware workflow consumes named KVM criteria, +not the current Apple-Silicon-only text. Generate a versioned Linux acceptance +manifest from those criteria and pin that manifest digest in each evidence record. + +- `.github/workflows/test.yml`: matrix `[macos-latest, ubuntu-latest]`. The Ubuntu + runner has no KVM and no packaged libkrun/libkrunfw, so it runs unit tests, + `go vet`, builds `abox`, and compiles the explicit VMM stub with + `CGO_ENABLED=0`. It does not build libkrunfw from source or claim real VMM link + coverage. +- Add package/build jobs in Arch and Fedora containers. These require no KVM but + prove the documented package names, pkg-config data, cgo link, image-builder + prerequisites, required libkrun symbols/firmware SONAME, and both cgo-enabled + and cgo-disabled build-tag paths. These are the real Linux cgo/pkg-config jobs. +- Pin the build containers by digest: the initial x86_64 baselines are + `archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b` + and + `fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80`. + Package installation is also pinned as described in Phase 0; image tags or + moving distro repositories alone are not reproducible CI. +- In both distro containers, create an unprivileged user and run `make image` + with no Docker daemon and no KVM. Verify manifest/digest, ELF architecture, + root ownership/mode, clean `e2fsck`, and that an injected failure leaves the + prior current-generation pointer unchanged. - Matrix the guest cross-build over `amd64` and `arm64`. -- `release.yml`: add Linux artifacts. +- Add a separate hardware workflow on named physical x86_64 KVM hosts for Arch + and Fedora. It runs the complete `PLAN.md` §21.4/§22 suite, lifecycle tests, and + packaged-artifact smoke test. Record host CPU, distro/kernel, KVM modules, + libkrun/libkrunfw versions and SONAME, commit, artifact digest, SELinux mode, + runner identity, and results. Use a restricted self-hosted runner group bound + only to the protected release environment/reusable workflow, plus dedicated + routing labels such as + `[self-hosted, linux, x64, abox-kvm, arch]` and the Fedora equivalent; the + workflow refuses an unrecognized runner/baseline. Labels alone are not a trust + boundary. Prefer ephemeral runner registration and require environment approval + for release execution. +- Refactor `release.yml` into version resolution, macOS build, Linux candidate + build, Arch/Fedora hardware acceptance, and publish jobs. Build jobs upload + immutable candidates; hardware jobs download and test the exact Linux digest; + the final publish job depends on both distro results and is the only job that + calls `gh release create`. It downloads both platform artifacts rather than + relying on one `macos-15` workspace. +- Linux release publication depends on hardware evidence for the exact commit and + candidate digest. Each Arch and Fedora hardware job emits its own in-toto + statement with subject = candidate SHA-256 and a versioned custom predicate + (for example + `https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1`) containing runner + identity, pinned baseline, acceptance-manifest digest, and test results. Sign it + through GitHub OIDC from the protected workflow. The publish job verifies two + distinct valid distro predicates plus build provenance, and attaches both + evidence statements to the release; ordinary build provenance alone is not + evidence that hardware tests passed. +- Publish `abox__linux_amd64.tar.gz` containing `abox` and `abox-vmm`, an + amd64 guest binary, a compressed amd64 golden image plus its manifest, and + SHA-256 checksums. Do not publish a Linux arm64 host artifact until that + architecture has its own hardware gate. +- Linux archives do not silently bundle system libkrun/libkrunfw. Document the + supported ABI/package versions and installation paths, then run `ldd`, checksum + verification, image-manifest verification, and a VM boot from the unpacked + release archive before upload. --- @@ -339,12 +686,22 @@ raw libkrun return code. In `startVM` (Linux) or `runtime.Start`: - README: per-platform prerequisites; Linux quickstart; state that `make image` on Linux needs no Docker, no root, and no privileged container. +- README: require Go 1.25+ consistently with `go.mod`; remove the current Go 1.24 + prerequisite and do not list Linux as runnable until the hardware gate passes. - Per-distro install: Arch (`pacman`), Fedora (`dnf`), Debian/Ubuntu source build - (`apt install python3-pyelftools build-essential flex bison libelf-dev`, then - `make && sudo make install`; libkrunfw compiles a Linux kernel, so it is slow), - marked **untested**. -- `docs/troubleshooting.md`: `/dev/kvm` permissions, missing libkrunfw, - architecture mismatch, nested virtualization, and no Secret Service provider. + with pinned and checksum-verified libkrunfw 5.5.0 and libkrun 1.19.4 sources, + marked **untested**. The exact Debian/Ubuntu sequence installs the kernel build + toolchain, `python3-pyelftools`, Rust/Cargo, static libc development files, + `patchelf`, and pkg-config; builds and installs libkrunfw first; then runs + `make BLK=1 && sudo make BLK=1 install` for libkrun. It configures both + `/usr/local/lib64` for the loader and `/usr/local/lib64/pkgconfig` for + pkg-config, runs `ldconfig`, and verifies `KRUN_FEATURE_BLK` before ABox build. + A plain featureless `make && make install` is not sufficient. +- `docs/troubleshooting.md`: `/dev/kvm` missing/permissions/API failures, + incompatible libkrun symbols or firmware SONAME, loader/pkg-config failures, + architecture mismatch, nested virtualization, Fedora SELinux AVC diagnosis, + Secret Service absence/timeout, and the explicit unsupported status of WSL2 or + containerized VMM execution. Never recommend disabling SELinux. - `docs/credentials.md` and the README credentials table: rename the row to `keystore`, note it resolves to macOS Keychain or Secret Service per platform, and record `keychain`/`secretservice` as accepted aliases. State plainly that @@ -352,7 +709,25 @@ raw libkrun return code. In `startVM` (Linux) or `runtime.Start`: answer on Linux. - **`PLAN.md` §22**: the acceptance matrix is written against Hypervisor.framework. Give it a per-platform column so KVM enforcement is - tracked separately, per Global decision 7. + tracked separately, per Global decision 7. Add a named Linux Phase 0.5 and + Phase 18 evidence record rather than reusing Apple Silicon results. +- Complete a repository-wide platform consistency pass, not only §22. Update + `PLAN.md` §2/§5/§20/§24/§26 and Phase C so Linux/KVM is a current explicit + target rather than a first-milestone non-goal or future alternative backend; + retain Windows and other unimplemented backends as deferred. +- Update `docs/index.md`, `docs/quickstart.md`, `docs/cli.md`, + `docs/troubleshooting.md`, `pkg/abox/doc.go`, and any generated docs-site copy + that carries stale platform restrictions, a global `kern.hv_support` + requirement, a platform-independent Docker requirement, or no Linux VMM path. +- Update `docs/sessions.md` and shutdown/lifecycle prose explicitly: `Close` + starts with guest shutdown RPC, then macOS uses its existing interrupt fallback + while Linux uses SIGTERM, followed by bounded kill/`Wait`. Document top-level + SIGINT/SIGTERM/SIGHUP cleanup for the Linux CLI. Do not rely on the generic + platform-wording search to catch signal-specific behavior. +- Reconcile `PLAN-CRED.md` and `PLAN-ABOX-SDK.md` with the portable keystore and + Linux runtime. Preserve historical decisions where useful, but add an explicit + superseded/current-platform note so those plans do not contradict shipped + behavior. --- @@ -364,13 +739,24 @@ libkrunfw on the library path. Nested virtualization only if the host is itself VM. Default budget is 1 vCPU / 768 MiB per session (`vmmconfig.DefaultVCPU`, `DefaultRAMMiB`). -**Credentials (optional):** libsecret (`secret-tool`) plus a Secret Service -provider — GNOME Keyring, KWallet, or KeePassXC — for the `keystore` source. -Without one, ABox falls back to `credentials.env` at 0600 with a warning, or use -`vault`/`azure`/`aws`, which need nothing platform-specific. +The acceptance records must name the Arch repository snapshot and Fedora +release, kernel/update level, libkrun package, required symbols, and matching +libkrunfw SONAME. Fedora must be tested with SELinux enforcing. Those hardware +records do not yet exist. WSL2 and containerized VMM execution are explicitly +outside the initial support table. + +**Credentials (optional):** libsecret (`secret-tool`), GLib's `gdbus`, plus a +Secret Service provider — GNOME Keyring, KWallet, or KeePassXC — for the +`keystore` source. Without one, ABox falls back to `credentials.env` at 0600 with +a warning, or use `vault`/`azure`/`aws`, which need nothing platform-specific. +Desktop MCP OAuth additionally uses `xdg-open`; headless users receive a manual +authorization URL. **Build:** Go 1.25+, gcc, pkg-config, libkrun headers. Image builds additionally -need e2fsprogs ≥ 1.43 (for `mke2fs -d`) and fakeroot. No codesign, no Docker. +need e2fsprogs ≥ 1.43 (for `mke2fs -d` and `debugfs`), fakeroot, a TLS downloader, +and a SHA-256 utility. No codesign, no Docker. A source install under +`/usr/local/lib64` also needs loader configuration plus `ldconfig` unless the +project adopts and tests an explicit rpath. Note: Arch's libkrun pulls `libvirglrenderer` and `libpipewire` because GPU and sound are compiled in. ABox uses neither; this is dependency weight only. @@ -380,14 +766,17 @@ sound are compiled in. ABox uses neither; this is dependency weight only. 1. Architecture-mismatched image produces an opaque guest panic. Mitigated by arch-tagged filenames plus the `Prepare` check (Phase 2). Highest-severity item in this plan. -2. `/dev/kvm` permissions differ across distributions. Mitigated by Phase 4 - preflight. -3. Host running inside a VM without nested virtualization. Surfaced via - `krun_check_nested_virt()`. +2. `/dev/kvm` permissions differ across distributions. Phase 5 preflight is + still incomplete; manual troubleshooting is documented meanwhile. +3. Host running inside a VM without usable KVM passthrough. This must be + surfaced by `/dev/kvm` API/version and VM-creation capability checks; do not + confuse it with offering nested virtualization to the ABox guest. 4. On ext4 there is no reflink, so each session clone is a real 768 MiB copy. `CopyFileRange` keeps it in-kernel; btrfs and XFS get true reflink. -5. libkrun version skew across distributions. `krun_has_feature` already guards - at runtime; add a version gate if a symbol gap appears. +5. libkrun version/API/firmware-SONAME skew across distributions. Pinned host + baselines, build-time version checks, and `krun_has_feature` are implemented; + required-symbol release checks, mapped early-loader diagnostics, and + packaged boot tests remain part of the gate. 6. `apk.static` is fetched from the Alpine CDN at image-build time. Pin by SHA-256. 7. Debian/Ubuntu source builds of libkrunfw compile a kernel. Documented, not @@ -399,38 +788,105 @@ sound are compiled in. ABox uses neither; this is dependency weight only. secret, producing a credential that fails authentication with no visible cause. Covered by a round-trip test in Verification. 10. Headless Linux with no session bus still lands keys in plaintext. Accepted - and now warned; `vault`/`azure`/`aws` are the documented alternative. + and now warned; `vault`/`azure`/`aws` are the documented alternative. +11. A reachable D-Bus session without a Secret Service owner looks superficially + usable. Mitigated by the bounded provider activation/ownership probe and + unavailable-provider tests. +12. Reflink/copy fallbacks can leave a partial session disk if offsets and + truncation are mishandled. Mitigated by failure injection and cleanup tests. +13. A release binary can link successfully while libkrunfw remains undiscoverable + through libkrun's runtime `dlopen`. Mitigated by packaged-artifact boot tests, + not `ldd` alone. +14. Supervisor death can bypass normal `Sandbox.Stop` cleanup. Mitigated by the + inherited liveness pipe, validated stale-PID cleanup, and abrupt-death tests. +15. A two-file image/manifest update cannot be atomic. Mitigated by immutable + generation directories and one atomically replaced current-generation pointer. +16. Fedora SELinux denials can resemble ordinary KVM permission failures. + Mitigated by enforcing-mode hardware tests and AVC-aware diagnostics without + recommending that SELinux be disabled. +17. Linux SIGTERM/SIGHUP or supervisor death can bypass a Ctrl-C-only graceful + path. Mitigated by top-level signal handling plus the liveness pipe. +18. Guest-only FIFREEZE code selected during Linux host tests could freeze the + developer's root filesystem if invoked. Mitigated by an explicit guest build + tag and an untagged non-freezing host stub. +19. A Secret Service unlock prompt can outlive the save deadline. Mitigated by + context-bound subprocess cleanup and `ErrLocked` fallback mapping. ## Verification -1. **Compile and unit:** `make test` and `go build ./cmd/abox ./cmd/abox-vmm` - succeed on Linux. They currently do **not**, because of `unix.Clonefile`; this - is the first regression to clear. -2. **VM liveness:** `abox --probe-vm` lists guest files. -3. **Isolation spot-checks** (not the full §22 suite, which requires the hardware - matrix): - - `abox exec --prompt "run: ip addr"` shows loopback only — no NIC. - - A host canary file outside the snapshot directory is unreachable from the - guest. - - `~/.abox/sessions//` contains only `root.raw` and `config.raw`; +1. **Compile and unit:** `make test`, `go vet ./...`, and + `go build ./cmd/abox ./cmd/abox-vmm` succeed on Linux with cgo enabled against + libkrun. `CGO_ENABLED=0 go build ./cmd/abox-vmm` also succeeds with the + diagnostic stub. +2. **VM liveness and API parity:** on both pinned Arch and Fedora baselines, the + required-symbol header/`nm -D` check passes and `abox --probe-vm` lists guest + files. Store separate versioned evidence for each package pair. +3. **Pre-gate isolation spot-checks** (useful during development, but not release + evidence): + - A deterministic guest probe, not an LLM command, shows loopback but no NIC, + GPU, sound, or unexpected virtio device. + - Deterministic TCP/UDP, IPv4/IPv6, and Unix-socket probes cannot reach host + canaries, LAN, or external endpoints; guest-local loopback/Unix sockets work. + - A host canary file outside the snapshot directory is unreachable from the guest. + - VMM configuration attaches only `root.raw` and `config.raw`; no session + metadata, transcript, console log, socket, or host path is attached. `config.raw` is mode 0400 and carries no credential material. 4. **Agent loop:** set a provider key via `/provider`, run a prompt requiring `read_file` and `run_command`; confirm the approval prompt appears and defaults to deny. -5. **Resume:** `abox --resume ` reloads transcript and conversation. +5. **Resume:** `abox --resume ` reloads transcript and conversation on a + matching architecture and protocol; an incompatible or metadata-free Linux + session fails before VMM start with an actionable error. A future protocol is + not silently capped, and the hello protocol matches session metadata. 6. **Image builder:** `make image` completes as a non-root user with the Docker - daemon stopped; `debugfs -R "ls -l /usr/local/bin" ` reports `0 0`. + daemon stopped; `e2fsck -fn` is clean; `debugfs` reports `0 0` and mode 0755 + for the guest; ELF architecture, manifest architecture, and image SHA-256 + agree. An injected failure leaves the prior golden image unchanged. 7. **Credential round-trip:** `/provider` stores a key; `secret-tool lookup service abox account ANTHROPIC_API_KEY` returns it **byte-identical** (no trailing newline); `~/.abox/credentials.env` does not contain it; a turn authenticates against the provider. -8. **Fallback path:** with `DBUS_SESSION_BUS_ADDRESS` unset, the same flow warns, - writes to `credentials.env` at 0600, and still completes a turn. +8. **Fallback path:** with `DBUS_SESSION_BUS_ADDRESS` unset, a provider absent, or + an unlock prompt blocked past the save deadline, the same flow warns, reaps + helper subprocesses, writes to `credentials.env` at 0600, and still completes + a turn. 9. **Alias compatibility:** existing `source: keychain` config resolves on Linux; - `keystore` and `secretservice` resolve identically; an unknown source is still - rejected by `config.Validate`. + `keystore` and `secretservice` resolve identically; save emits `keystore`; MCP + OAuth persists it; an unknown source is still rejected by `config.Validate`. 10. **Cloud sources unaffected:** a model pinned to `source: vault` and another to `source: keystore` both resolve in the same session. 11. **Cross-platform regression:** the same suite still passes on Apple Silicon, - including `security(1)` keychain storage. - + including `security(1)` keychain storage. +12. **Source snapshot regression:** start from clean, dirty, and commitless Git + worktrees; repository-root discovery, tracked modifications, non-ignored + untracked files, ignored-file omission, executable bits, and `.git`/ABox + state exclusion match the macOS behavior. +13. **Lifecycle:** cancel during boot, graceful stop, forced stop, and a failed + VMM setup leave no helper process, listener, open KVM descriptor, or stale RPC + socket. Killing the supervisor without calling `Stop` produces the same result + through the inherited liveness pipe. SIGINT, SIGTERM, and SIGHUP take the + bounded shutdown path, and sentinel parent descriptors are absent from the + helper except for the fixed liveness fd 3 contract. +14. **Packaged artifact:** on clean supported Arch and Fedora hosts, verify + checksums, unpack the release archive, resolve dynamic libraries, validate the + image manifest, and boot that exact artifact without the source tree. Fedora + runs with SELinux enforcing and the recorded libkrun/libkrunfw SONAME pair. +15. **Mandatory KVM security gate:** run every applicable `PLAN.md` §21.4/§22 + hardware test on named Arch and Fedora x86_64 KVM hosts. Guest canaries cannot + reach host home, SSH/cloud/Docker-socket canaries, host listeners, LAN, + external IPv4/IPv6, or hijacked Unix sockets; mount/device inspection shows no + NIC, TSI, or host-path filesystem; destructive guest operations leave host + state unchanged. Capture the evidence against the release digest. Until this + passes, Linux isolation and Linux release support remain Planned. +16. **Build-only CI:** Ubuntu proves cgo-disabled compilation, tests, and vet; + unprivileged Arch/Fedora container jobs prove real cgo/pkg-config linking and + the rootless image builder without KVM or Docker. +17. **Release evidence:** the publish job accepts only the candidate digest tested + on both protected hardware runners, verifies its provenance/evidence + attestation, and attaches that evidence to the GitHub release. +18. **Host-test safety:** ordinary Linux `go test` selects the non-freezing stub; + only the explicitly tagged guest build contains FIFREEZE/FITHAW. +19. **Documentation consistency:** repository/docs search finds no current claim + that Linux is out of scope, that only Apple Silicon/HVF is supported, or that + Linux image creation requires Docker. Historical plans carry a clear + superseded/current-platform note. diff --git a/PLAN.md b/PLAN.md index 86fd7f3..35ff457 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,15 +52,15 @@ The current plan makes these decisions: | Implementation language | Go 1.25 | | Go module | `github.com/AdminTurnedDevOps/ABox` | | License | Apache-2.0 | -| Initial host | macOS on Apple Silicon | -| Initial guest | ARM64 Linux | -| Initial microVM backend | libkrun over Apple Hypervisor.framework | +| Host runtimes | macOS/arm64 over Hypervisor.framework is runnable; Linux amd64/arm64 over KVM is implemented but runtime/release support remains Planned pending separate Phase 0.5/18 evidence | +| Guest architectures | Linux arm64 and amd64 images/builds; runtime support follows the matching host hardware gate | +| MicroVM backend | libkrun over Apple Hypervisor.framework (`hvf`) or Linux KVM (`kvm`) | | Runtime integration | Dedicated `abox-vmm` Go helper with a narrow cgo boundary | | Guest network | No guest NIC and no TSI inet or Unix hijacking; RPC uses vsock only. Hardware isolation remains unverified | | Model traffic | Protocol-4 host LLM broker; the guest supplies a configured model alias and bounded request data | | Remote MCP traffic | Protocol-4 host Streamable HTTP broker; the guest supplies configured server/tool identities and arguments, never endpoints or credentials | | Providers | OpenAI and xAI through Chat Completions today; Anthropic through Messages. OpenAI/xAI Responses remain Planned | -| Source state | Any host directory is snapshotted exactly; host Git state is not inspected and `.git` metadata is excluded | +| Source state | Git worktree root is discovered; clean `HEAD` or a private dirty-tree snapshot is transferred without `.git` metadata | | Host workspace sharing | Prohibited | | Repository transfer | Private snapshot copied into a writable guest disk | | Change return | Guest patch export is implemented. Reviewed host import remains Planned | @@ -81,6 +81,8 @@ The current plan makes these decisions: | Default VM resources | 1 vCPU and 768 MiB RAM; upper resource limits and acceptance measurements remain Planned | | VM concurrency | No global limit is enforced today; the target is one running VM by default. Checkpoint and fork orchestration are not implemented | | Background services | No resident ABox daemon | +| Linux build baselines | Arch 2026-09-17 x86_64 snapshot: libkrun 1.19.4-1/libkrunfw 5.5.0-1; Fedora 44 x86_64: libkrun/libkrun-devel 1.19.0-1.fc44/libkrunfw 5.5.0-1.fc44 | +| Unsupported VMM environments | WSL2 and containerized execution; containers remain valid compile/image-build environments | ## 2.1 Resource Efficiency @@ -108,8 +110,10 @@ Docker, a container engine, Kubernetes, or another agent harness. private disk and resumable session state. - Terminate the helper and release VM resources immediately when the session is destroyed or ABox exits. -- Use one verified immutable base image and APFS copy-on-write clones for - session disks. Fall back to a full copy only when clone support is absent. +- Use one verified immutable base image and copy-on-write clones where the host + filesystem supports them: APFS `clonefile` on macOS, FICLONE on Linux btrfs, + XFS with reflink, or bcachefs. Linux then tries `copy_file_range`; all hosts + fall back to a full private copy. Never replace a copy with a host mount. - Stream provider, RPC, command, search, and patch data rather than buffering unbounded results in memory. - Keep bounded TUI scrollback and spill retained session events to compact @@ -120,8 +124,8 @@ Docker, a container engine, Kubernetes, or another agent harness. ### Initial Resource Budgets -These are milestone targets to validate on a named baseline Apple Silicon -machine. Measurements must be recorded and the budgets may be changed only +These are milestone targets to validate separately on named platform baseline +machines. Measurements must be recorded per backend and may be changed only with benchmark evidence and an ADR update. | Resource | Initial target | @@ -191,8 +195,10 @@ The host-side `abox` process owns: - Patch review and confirmed import Provider credentials and MCP tokens are entered or referenced on the host and -resolved from env-backed storage, macOS keychain, Vault, Azure Key Vault, or -AWS Secrets Manager. Resolved values are never written to session metadata, +resolved from env-backed storage, the platform OS keystore (macOS Keychain or +Linux Secret Service), Vault, Azure Key Vault, or AWS Secrets Manager. +`keystore` is the canonical config name; `keychain` and `secretservice` are +accepted aliases. Resolved values are never written to session metadata, `guest-config.json`, `config.raw`, or the guest disk. Host brokers perform provider and remote MCP HTTPS; the agent loop remains in the guest. @@ -259,14 +265,16 @@ The UI and documentation must communicate that behavior clearly. ## 5. Why libkrun -libkrun provides hardware-backed isolation. On Apple Silicon, the stack is: +libkrun provides a userspace VMM over a host hardware virtualization API. The +implemented host stacks are: ```text ABox supervisor (Go) -> abox-vmm helper (Go + cgo) -> libkrun userspace VMM - -> Apple Hypervisor.framework - -> ARM hardware virtualization + -> Apple Hypervisor.framework (macOS/arm64) + or KVM (Linux/amd64 or Linux/arm64) + -> host hardware virtualization -> isolated Linux guest kernel and memory ``` @@ -278,8 +286,8 @@ libkrun is preferred for the initial backend because: - It is explicitly designed for lightweight microVM-style workloads. - It uses Hypervisor.framework on macOS ARM64. -- It supports KVM on Linux, providing a credible future backend path without - coupling the higher-level harness to macOS. +- It uses KVM on Linux through the same narrow helper binding. Linux runtime + and isolation support stay Planned until the independent KVM hardware gate. - It supports raw block devices and virtio-vsock. - It can add an explicit vsock device with TSI feature flags set to zero. - It has a stable C API suitable for a narrow Go cgo wrapper. @@ -344,10 +352,10 @@ The current device-plan calls and remaining profile requirements are: `KRUN_SYNC_RELAXED`. Never pass qcow2 or vmdk. Never probe format. - Call `krun_has_feature(KRUN_FEATURE_BLK)` and refuse to start if block devices are unavailable. -- Use `krun_get_shutdown_eventfd` for orderly `Sandbox.Stop` when the - pinned flavor provides it. On `stable-1.19.x` that call is documented as - libkrun-efi only. If the pin lacks it, document forced stop as the - remaining path. +- Current orderly stop sends the guest shutdown RPC, then uses the + platform-specific helper fallback: interrupt on macOS or SIGTERM on Linux, + followed by bounded SIGKILL and `Wait`. Generic Linux libkrun does not expose + the EFI-only `krun_get_shutdown_eventfd` path used by some flavors. - Reject unknown runtime options and arbitrary extra device arguments. - Bind the RPC Unix socket inside a mode `0700` session directory. - Current code configures vCPU/RAM and bounds command duration/output; @@ -356,24 +364,28 @@ The current device-plan calls and remaining profile requirements are: Guest process configuration is no longer undecided in the product code: `abox-vmm` calls `krun_set_exec` with `/usr/local/bin/abox-guest` and a fixed -environment on the documented libkrun 1.19.4 path. The composed product boot -path is implemented and runnable. Its no-NIC, no-TSI, and no-host-path- -filesystem isolation properties remain implemented but unverified until the -named Apple Silicon hardware suite passes. Documentation must distinguish -"boots successfully" from "hardware isolation verified." The effective -device configuration is an allowlist. +environment on the documented libkrun 1.19.x path. The composed product boot +path is implemented, and the macOS/arm64 path is runnable. Its no-NIC, no-TSI, +and no-host-path-filesystem properties remain implemented but unverified until +the named platform hardware suites pass. Linux/KVM needs separate evidence on +both pinned Arch and Fedora baselines. Documentation must distinguish "boots +successfully" from "hardware isolation verified." The effective device +configuration is an allowlist. ### 5.3 Initial Backend Limitations - Hardware virtualization does not protect against a compromised trusted host. -- It does not provide confidential memory or remote attestation on Apple - Silicon. -- A VMM or Hypervisor.framework escape remains in scope as a residual risk. +- It does not provide confidential memory or remote attestation on the current + macOS or Linux targets. +- A VMM, Hypervisor.framework, KVM, or device-emulation escape remains in scope + as a residual risk. - Packaging requires pinned libkrun and libkrunfw artifacts. - The cgo helper is platform-specific even though the higher-level runtime interface is not. - macOS runtime upgrades can change the effective hypervisor behavior and must be tested. +- Linux kernel, KVM, libkrun, libkrunfw, and SELinux changes can change the + effective behavior and must be tested independently per pinned baseline. ## 6. Runtime Abstraction @@ -490,7 +502,7 @@ ABox uses one Go module with three binaries and a public SDK: and fallback storage - `internal/repository`, `internal/runtime`, `internal/session`, `internal/config`, `internal/tui`, and `internal/vmmconfig` -- `images`: current Docker-based guest-image builder +- `images`: Docker guest-image builder on macOS and rootless native builder on Linux - `docs` and `examples`: SDK and CLI documentation and examples Dedicated audit, patch-import, checkpoint-lineage, memory, skills, @@ -506,14 +518,15 @@ The current default root is `~/.abox`; `ABOX_HOME` overrides it: ~/.abox/config.yaml ~/.abox/credentials.env ~/.abox/sessions// -~/.abox/images/abox-guest.raw +~/.abox/images/abox-guest-linux-.raw ``` The former `~/Library/Application Support/ABox` and `~/Library/Caches/ABox/images` locations are legacy migration or fallback paths, not the primary layout. The configuration file stores credential -references, never credential values. A signed or checksummed image manifest -and digest verification remain Planned. +references, never credential values. Each resolved image has an adjacent +schema-1 manifest with architecture, image ID, guest protocol, and SHA-256; +new sessions verify the cloned disk digest before boot. The ABox application-support root, every session directory, and every preserved disk directory must be mode `0700`. Cleanup resolves and validates every target @@ -522,9 +535,10 @@ path outside that root. ## 8. Guest Image -The current guest is a 768 MiB raw ext4 ARM64 Linux root filesystem packed -with Docker. Docker is used only to build or update the golden filesystem and -is not on the session execution path. The image contains: +The current guest is a 768 MiB raw ext4 Linux root filesystem for amd64 or +arm64. macOS packs it with Docker; Linux uses a rootless native builder based on +checksum-pinned `apk.static`, `fakeroot`, and `mke2fs -d`. Docker is never on +the session execution path. The image contains: - The statically compiled `abox-guest` worker - A POSIX-compatible shell @@ -535,10 +549,10 @@ is not on the session execution path. The image contains: - No systemd, SSH server, Docker engine, graphical stack, or idle package daemon -A reproducible controlled build, signed or checksummed manifest, image -identity, digest verification, vulnerability-update policy, and measured -compressed-image budget remain first-milestone requirements. The future image -pipeline must: +A controlled build, architecture-tagged schema-1 manifest, image identity, and +digest verification are implemented. Release reproducibility evidence, +vulnerability-update policy, and measured compressed-image budget remain +first-milestone requirements. The release pipeline must: - Run in a controlled CI or Linux build environment. - Produce a raw disk image or a trusted kernel plus raw root disk supported by @@ -548,9 +562,9 @@ pipeline must: - Keep the immutable base image separate from per-session writable copies. - Provide a documented update process for guest OS vulnerabilities. -On APFS, ABox may use `clonefile` to create an efficient copy-on-write session -disk. On filesystems where cloning is unavailable, it must make a full private -copy. It must never fall back to a read-write directory mount. +On APFS, ABox uses `clonefile` when available. Linux tries FICLONE, then +`copy_file_range`, then a full copy. Other filesystems use a full private copy. +It never falls back to a read-write directory mount. The initial image will not contain every language toolchain. Missing toolchains must be reported as image limitations rather than bypassed through host @@ -571,22 +585,25 @@ brokered fetches. ## 9. Source Provisioning -The current implementation accepts any host directory. It snapshots exactly -the requested directory and does not discover a Git root or inspect host -branches, commits, ignore rules, or working state. +The current implementation requires a Git worktree and discovers its root from +the selected path. Clean worktrees archive committed `HEAD`. Dirty or +commitless worktrees are copied into a private host-side repository before +transfer so host Git remains unchanged. ### 9.1 Preconditions -ABox requires a readable directory. It includes regular files, dotfiles, and -empty directories, preserves executable bits, excludes files or directories -named `.git`, and rejects symlinks and unsupported special files. The snapshot -is bounded to the same entry, per-file, and total-byte limits enforced by the -guest extractor. Host Git and host Git configuration are not required. +ABox requires a readable Git worktree without submodules. Dirty snapshots use +`git ls-files --cached --others --exclude-standard`, which includes tracked +files and non-ignored untracked files while omitting ignored build output, +caches, and local secrets. Executable bits are preserved; `.git`, active ABox +state, symlinks, and unsupported special files are excluded. The archive is +bounded to the same entry, per-file, and total-byte limits enforced by the +guest extractor. ### 9.2 Transfer -The host creates a bounded tar snapshot directly with the Go standard library -and streams bounded chunks over authenticated RPC. +The host creates a bounded archive from committed `HEAD` or from the private +dirty-tree commit and streams bounded chunks over authenticated RPC. The guest extraction code must reject: @@ -627,8 +644,9 @@ The current host-guest RPC protocol is version 4. Normal CLI and SDK sessions require protocol 4. `abox --probe-vm` may speak to an older guest only far enough to perform its limited probe. -The transport is bounded length-prefixed JSON over virtio-vsock. On macOS, -libkrun maps the selected vsock port to a protected Unix socket. +The transport is bounded length-prefixed JSON over virtio-vsock. libkrun maps +the selected guest vsock port to a protected host Unix socket on the implemented +macOS/HVF and Linux/KVM paths. Protocol history: @@ -1012,8 +1030,9 @@ blocks while preserving the assistant content needed for subsequent turns. ### 13.4 Credentials - LLM credentials and MCP tokens remain host-side. -- Approved host sources are env-backed storage, macOS keychain, Vault KV v2, - Azure Key Vault, and AWS Secrets Manager. +- Approved host sources are env-backed storage, the OS `keystore` (macOS + Keychain or Linux Secret Service), Vault KV v2, Azure Key Vault, and AWS + Secrets Manager. `keychain` and `secretservice` remain input aliases. - Credential references may be stored in `config.yaml`; resolved values are not. - Resolved values are never written to session logs, `guest-config.json`, @@ -1383,15 +1402,15 @@ because it is a reviewed user action owned by the trusted control plane. The first lifecycle is: -1. Validate configuration and repository state. +1. Validate configuration and the selected readable source directory. 2. Create a mode `0700` session directory. -3. Capture repository baseline metadata. +3. Discover the Git root and capture a bounded clean or private dirty snapshot. 4. Verify the trusted guest image. 5. Clone or copy a private writable session disk. 6. Start `abox-vmm` with a fixed device plan. 7. Wait for authenticated guest readiness and set the guest clock from the host clock. -8. Transfer the selected clean or ephemeral repository snapshot. +8. Transfer the repository snapshot, excluding `.git` and host-only state. 9. Run the agent and tool loop. 10. Idle-stop and resume the same session disk when resource policy requires. Set the guest clock again after every resume. @@ -1406,6 +1425,12 @@ The first lifecycle is: 17. Destroy or preserve the private disk according to the session setting. 18. Persist compact session state and a redacted audit summary. +Current orderly stop begins with the guest shutdown RPC. If the helper remains, +macOS sends interrupt while Linux sends SIGTERM; both use a bounded wait, +SIGKILL if required, and final `Wait`. The Linux CLI routes SIGINT, SIGTERM, and +SIGHUP through cleanup. Supervisor death closes the inherited liveness pipe so +the helper does not keep an unmanaged VM running. + Unexpected supervisor termination should cause the VMM helper to terminate or be recoverable through recorded process and session metadata. Stale session cleanup must never delete paths outside ABox's protected session root. @@ -1415,7 +1440,7 @@ cleanup must never delete paths outside ABox's protected session root. The host stores structured records for: - Session identifier -- Repository identity and baseline commit +- Source-directory identity and private guest baseline - Selected provider and model - Connectivity mode - Runtime backend and image digest @@ -1456,7 +1481,8 @@ and clearly distinguish implemented-but-unverified controls from future work. - Supported platform and backend - Clear security disclaimer - Explicit statement that the project is experimental -- Clean and ephemeral dirty-tree snapshot behavior +- Git-aware clean and dirty worktree snapshot behavior +- Architecture-specific image/manifest and platform build requirements - Link to architecture and threat model ### 19.2 `docs/architecture.md` @@ -1506,7 +1532,7 @@ Initial ADRs: - ADR-0005: Separate guest network isolation from host connectivity routing - ADR-0006: Use native provider adapters behind a common model interface - ADR-0007: Use a dedicated VMM helper process for the cgo boundary -- ADR-0008: Use clean `HEAD` archives or private dirty/unborn snapshots +- ADR-0008: Use private Git-aware source snapshots without modifying host Git - ADR-0009: Enforce lightweight default resource budgets - ADR-0010: Use cold disk checkpoints for rollback and fork - ADR-0011: Use a semantic host broker for remote MCP and keep future stdio MCP in the guest @@ -1550,7 +1576,7 @@ release. **Status note:** These phase checklists are the acceptance roadmap, not a claim that implementation proceeded in this order. Development advanced out of order: protocol 4, the public SDK, TUI, basic resume, host LLM/MCP brokers, -host-only credentials, dirty-tree snapshots, and `run_command` approval exist, +host-only credentials, exact-directory snapshots, and `run_command` approval exist, while several earlier documentation, image, runtime-hardening, and hardware gates remain incomplete. Completing an implementation task does not imply that its phase exit criteria or security evidence passed. @@ -1559,17 +1585,18 @@ that its phase exit criteria or security evidence passed. - Record the selected Go module path, `github.com/AdminTurnedDevOps/ABox`. - Record the selected Apache-2.0 license. -- Confirm the minimum macOS version. -- Pin a maintained stable libkrun release and compatible libkrunfw artifact. +- Confirm the minimum macOS version and the Linux package/kernel baselines. +- Pin maintained libkrun and compatible libkrunfw artifacts per platform. - Decide whether runtime artifacts are downloaded, bundled, or discovered from an installation. -- Record the current `~/.abox` session/image layout and name the Apple Silicon - resource baseline machine. +- Record the current `~/.abox` session/image layout and name separate macOS/HVF + and Linux/KVM resource baseline machines. - Name the demonstration repository and the exact guest toolchain set used to judge the image-size budget. - Secure a dedicated Apple Silicon host that can run Hypervisor.framework - without nested virtualization. GitHub-hosted macOS ARM runners are not - sufficient for Phase 0.5, Phase 6, or Phase 18. + without nested virtualization, plus native x86_64 Arch and Fedora KVM hosts. + Generic GitHub-hosted or container runners are not sufficient for Phase 0.5, + Phase 6, or Phase 18 hardware evidence. - Write draft `docs/architecture.md`, `docs/threat-model.md`, and ADR-0002 marked Planned. These drafts exist so the spike has a written target. They must not claim a verified isolation profile. @@ -1585,12 +1612,13 @@ Exit criteria: ### Phase 0.5: libkrun Boot Spike -The product VMM path now boots with the intended device-plan calls, but the -disposable research record and named-hardware evidence required by this phase -do not exist. Reproduce the product call sequence on the dedicated Apple -Silicon host and record the evidence without treating a successful boot as -proof of isolation. Isolation claims stay Planned until the hardware suite -passes. +The product VMM path now contains the intended device-plan calls for macOS/HVF +and Linux/KVM, but the disposable research records and named-hardware evidence +required by this phase do not exist. Reproduce and record the product call +sequence independently on the dedicated Apple Silicon host, pinned Arch KVM +host, and pinned Fedora KVM host. A successful build or boot is not proof of +isolation. Each backend's isolation claims stay Planned until its own Phase 18 +suite passes. - Link the pinned libkrun and libkrunfw from a narrow cgo helper. - Compare the current 1.19-style product API to `containers/libkrun` main and @@ -1633,7 +1661,7 @@ Exit criteria: hazards explicitly. Do not document `krun_disable_implicit_*` as required APIs unless the chosen pin still has them. - Document origin rewrite, untrusted repo instructions, checkpoint quiesce, - dirty-tree-after-import, and resource-metric definitions. + source-directory changes after snapshot/import, and resource-metric definitions. - Do not describe the section 5.2 profile as verified until Phase 0.5 passes. Exit criteria: @@ -1699,7 +1727,7 @@ Exit criteria: ### Phase 5: Guest Image -- Build the ARM64 Linux image reproducibly. +- Build architecture-tagged amd64 and arm64 Linux images reproducibly. - Install the guest worker and required tooling. - Publish and verify an image manifest and digest. - Add image boot-readiness tests. @@ -1732,15 +1760,18 @@ Exit criteria: format. - Unit tests do not claim to prove what libkrun would do if TSI were left implicit. That proof is a Phase 0.5 and Phase 18 guest probe. -- A real Apple Silicon integration test boots and communicates with the guest. +- Real Apple Silicon/HVF and separate Arch/Fedora x86_64 KVM integration tests + boot and communicate with the matching guest. Until the Linux records exist, + Linux runtime/release support stays Planned. - Device inspection shows no network device and no host-path filesystem share. ### Phase 7: Source Transfer -- Snapshot exactly the selected host directory without requiring host Git. -- Include regular files, dotfiles, and empty directories; preserve executable - bits and exclude `.git` metadata. +- Discover the selected Git worktree and snapshot clean `HEAD` or a private + dirty-tree commit. +- Include tracked and non-ignored untracked files; preserve executable bits and + exclude ignored files, `.git` metadata, and active ABox state. - Reject symlinks and unsafe or unsupported file types. - Stream and safely extract the selected snapshot in the guest. - Initialize the private guest baseline. @@ -1748,7 +1779,7 @@ Exit criteria: Exit criteria: -- Plain directories and directories containing Git metadata transfer correctly. +- Clean, dirty, and commitless Git worktrees transfer correctly. - Unsafe symlinks, special files, and malicious archive paths fail clearly. - Malicious archive-path tests are rejected. @@ -1885,8 +1916,9 @@ Exit criteria: - Freeze the guest filesystem, acknowledge while frozen, then stop and flush before creating a cold checkpoint. Thaw only if aborting while the VM is still running. -- Clone the raw disk using APFS copy-on-write where available. Never attach - a checkpoint file writable. Clone again before every boot. +- Clone the raw disk using APFS `clonefile` or Linux FICLONE where available, + then the platform copy fallback. Never attach a checkpoint file writable. + Clone again before every boot. - Store the checkpoint bundle: disk identity and digest, host event cursor, working context, continuation state, instructions, and approvals. - Keep the session audit log append-only. @@ -1944,8 +1976,8 @@ Exit criteria: Exit criteria: -- Default-path measurements meet the resource budgets on the named baseline - machine. +- Default-path measurements meet the resource budgets on each named supported + platform baseline machine. - Any exception is documented with evidence and accepted through an ADR. ### Phase 18: Security Acceptance @@ -2014,8 +2046,11 @@ Exit criteria: ### 21.4 Hardware Security Tests -These tests require a real Apple Silicon host capable of hardware -virtualization. A mocked libkrun API is not sufficient evidence. +These tests require real named hardware: Apple Silicon for +Hypervisor.framework, plus native x86_64 KVM hosts for both pinned Arch and +Fedora baselines. A mocked libkrun API, container job, WSL2 environment, or +successful boot probe is not sufficient evidence. Evidence is backend- and +baseline-specific and must identify the exact artifact digest. The suite should: @@ -2049,25 +2084,24 @@ The suite should: ## 22. Security Acceptance Matrix -| Acceptance criterion | Enforcement | Required evidence | -| --- | --- | --- | -| Guest cannot read host home | No host filesystem device | Real guest canary probe plus device inspection | -| Guest cannot read SSH keys | No host filesystem device | Synthetic SSH canary probe | -| Guest cannot read cloud credentials | No host filesystem device and no credential forwarding | Synthetic credential canary probe and RPC review | -| Repository is not mounted read-write | Private raw disk only | Device-plan test, guest mount inspection, host hash comparison | -| Guest cannot reach host or LAN | No net device and `krun_add_vsock(ctx, 0)` | Guest-local loopback works; host canary, LAN, and external probes fail | -| Model shell commands execute in guest | Agent dispatches only typed RPC | Fake-provider dispatch test and real guest command test | -| Destructive guest command cannot damage host | Hardware VM and no host mounts | Host canaries survive destructive guest test | -| Guest cannot access Docker socket | No host filesystem or socket forwarding | Synthetic socket probe and device inspection | -| Changes return only through review | No shared workspace and gated import | Reject/approve end-to-end tests | -| ABox works without agentgateway | Direct provider adapter | End-to-end direct-mode test | -| Gateway does not weaken isolation | Connectivity independent from runtime plan | Device-plan equality and real guest probes | -| MCP does not expose a host shell | Guest stdio execution and endpoint-bound broker | Local and remote MCP integration tests | -| Guest cannot induce arbitrary host fetches | Identifier-to-endpoint mapping; no raw URL method | SSRF, redirect, header, and unconfigured-origin tests | -| Repo instructions cannot relax policy | Host config is the only policy source | Hostile `AGENTS.md` fixture cannot change approvals or limits | -| Checkpoints are independent | ABox-enforced bundle: frozen disk clone plus host cursor | Rollback restores disk and working context; parent files stay read-only; audit stays append-only | -| Default use is lightweight | Explicit budgets and on-demand lifecycle | Named-host resource benchmark report | -| Unimplemented controls are visible | Explicit feature status | Documentation and generated status report | +| Acceptance criterion | Enforcement | macOS/HVF evidence | Linux/KVM evidence | +| --- | --- | --- | --- | +| Guest cannot read host home | No host filesystem device | Apple Silicon Phase 0.5/18 canary + device inspection | Separate Arch and Fedora Phase 0.5/18 canary + device inspection; Planned | +| Guest cannot read SSH/cloud credentials | No host filesystem device and no credential forwarding | Synthetic canaries + RPC review | Same tests on both pinned KVM baselines; Planned | +| Repository is not mounted read-write | Private raw disk only | Device plan, mounts, host hash | Same tests on both pinned KVM baselines; Planned | +| Guest cannot reach host or LAN | No net device and `krun_add_vsock(ctx, 0)` | Loopback works; host/LAN/external/Unix canaries fail | Independent KVM inet/Unix TSI probes on Arch and Fedora; Planned | +| Model shell commands execute in guest | Agent dispatches only typed RPC | Fake-provider + real guest command | Same code tests plus real KVM guest command; Planned | +| Destructive guest command cannot damage host | Hardware VM and no host mounts | Host canaries survive | Host canaries survive on Arch and Fedora; Planned | +| Guest cannot access Docker socket | No host filesystem or socket forwarding | Synthetic socket probe + devices | Same tests on both KVM baselines; Planned | +| Changes return only through review | No shared workspace and gated import | Reject/approve end-to-end | Same test on both KVM baselines after import exists | +| ABox works without agentgateway | Direct provider adapter | End-to-end direct mode | Same integration test on both KVM baselines; Planned where runtime-dependent | +| Gateway does not weaken isolation | Connectivity independent from runtime plan | Device-plan equality + guest probes | Same tests on both KVM baselines; Planned | +| MCP does not expose a host shell | Guest stdio execution and endpoint-bound broker | Local/remote MCP integration | Same integration tests on both KVM baselines; Planned where runtime-dependent | +| Guest cannot induce arbitrary host fetches | Identifier-to-endpoint mapping; no raw URL method | SSRF, redirect, header, and origin tests | Same integration tests on both KVM baselines; Planned where runtime-dependent | +| Repo instructions cannot relax policy | Host config is the only policy source | Hostile `AGENTS.md` fixture | Platform-independent code evidence; repeat integration per backend | +| Checkpoints are independent | Frozen disk clone plus host cursor | Rollback/lineage hardware test | Same tests on both KVM baselines after checkpoint support exists | +| Default use is lightweight | Explicit budgets and on-demand lifecycle | Named Apple Silicon report | Separate named Arch and Fedora reports; Planned | +| Unimplemented controls are visible | Explicit feature status | Documentation/status report | Documentation/status report; Linux support remains Planned until complete | Passing unit tests prove code intent but do not, by themselves, prove guest isolation. Hardware-backed tests are required before describing those controls @@ -2077,10 +2111,12 @@ as verified. The milestone is complete when a user can: -1. Start `abox` in a Git repository on Apple Silicon, using either a clean - `HEAD` archive or a private ephemeral snapshot of a dirty or unborn tree. +1. Start `abox` anywhere inside a Git worktree on a qualified host. macOS/arm64 + is the current runnable path; Linux/x86_64 joins only after both pinned KVM + gates pass. Snapshot the discovered repository without modifying host Git. 2. Select a configured OpenAI, Anthropic, or Grok model. -3. Start a real libkrun hardware-isolated ARM64 Linux microVM. +3. Start the matching amd64 or arm64 Linux guest through the qualified libkrun + backend. Hardware isolation remains a claim only after Phase 18 evidence. 4. Transfer the captured repository privately into the guest. 5. Enter a prompt in the dark full-screen TUI. 6. Watch model text and tool activity stream in the terminal. @@ -2125,7 +2161,7 @@ Do not implement yet: - Browser automation - OBO or enterprise identity - Semantic authorization policies -- Cross-platform runtime support +- Windows and additional unimplemented VMM backends - Rich IDE integrations - A cloud control plane - Production deployment @@ -2167,7 +2203,8 @@ enforcement mechanism. - Live memory snapshots where supported and verifiable - Faster incremental checkpoints - Concurrent opt-in fork execution with explicit resource budgets -- Alternative Linux/KVM backend validation +- Additional Linux architectures and KVM baseline expansion after the initial + Arch/Fedora x86_64 gate - Windows backend research ### Phase D: Multi-Agent and Enterprise Features @@ -2182,9 +2219,14 @@ These phases require separate ADRs and threat-model updates. ## 26. Assumptions -- The first host is Apple Silicon running a supported modern macOS release. -- The host supports Hypervisor.framework and permits hardware virtualization. -- The first guest can be ARM64 Linux. +- macOS/arm64 with Hypervisor.framework is the current runnable host path. +- Linux amd64/arm64 KVM code and build paths are implemented, but initial Linux + runtime/release support is x86_64 and remains Planned until separate Arch and + Fedora Phase 0.5/18 evidence passes. +- WSL2 and containerized VMM execution are unsupported. Containers may compile, + link, test, and build images without making a runtime claim. +- Guests are Linux amd64 or arm64 and must match the host architecture recorded + in the adjacent image manifest. - Source directories do not require Git. Host Git metadata and state do not participate in snapshotting or session identity. - Provider HTTPS originates from the trusted host broker; the model loop and @@ -2197,17 +2239,17 @@ These phases require separate ADRs and threat-model updates. these modes changes the guest device plan. - Planned package adapters will use origin rewrite rather than HTTP(S) proxy variables; no package adapter exists today. -- Users accept that regular files in the selected source directory are included - regardless of Git ignore rules, except `.git` metadata itself. +- Users accept that tracked files and non-ignored untracked files are included; + ignored files, `.git` metadata, and active ABox state are omitted. - Users accept that the initial image has a limited toolchain set. - Users accept that a successful future patch import modifies the host source directory. - The host and local administrator are trusted. - The guest, model output, generated code, repository content, and repo-sourced instruction files are untrusted. Host configuration is the only source of approval, connectivity, limit, and tool-allowlist policy. -- Phase 0.5, Phase 6, and Phase 18 require a dedicated Apple Silicon host - that can use Hypervisor.framework. Nested cloud macOS runners are not that - host. +- Phase 0.5, Phase 6, and Phase 18 require dedicated hardware per backend: + Apple Silicon/HVF and separate native x86_64 Arch/Fedora KVM hosts. Nested + cloud runners, WSL2, and containers are not substitutes. ## 27. Resolved Decisions and Open Work @@ -2219,24 +2261,30 @@ Resolved decisions: - Primary storage root: `~/.abox`, overridable with `ABOX_HOME` - Current host-guest protocol: 4 - Current documented runtime: libkrun 1.19.4-style API +- Linux package baselines: Arch 2026-09-17 snapshot with + libkrun 1.19.4-1/libkrunfw 5.5.0-1; Fedora 44 with + libkrun/libkrun-devel 1.19.0-1.fc44/libkrunfw 5.5.0-1.fc44 - Current guest launch: `krun_set_exec` - Current remote MCP path: host Streamable HTTP broker -- Current source path: bounded filesystem snapshot of the exact configured directory +- Current source path: bounded clean-HEAD or private dirty-worktree Git snapshot +- Current image identity: `abox-guest-linux-.raw` plus adjacent schema-1 + manifest; Linux rootless native builder and macOS Docker builder +- Current local credential source: canonical `keystore`, dispatched to macOS + Keychain or Linux Secret Service Still open or incomplete: - Minimum supported macOS version -- Exact pinned libkrun and libkrunfw versions, including whether the pin is - `stable-1.19.x` or a main-line commit after the implicit-API removal -- Recorded Phase 0.5 evidence for the current `krun_set_exec`, - `krun_add_vsock(ctx, 0)`, two-disk boot and vsock direction +- Recorded platform-specific Phase 0.5 evidence for the current + `krun_set_exec`, `krun_add_vsock(ctx, 0)`, two-disk boot and vsock direction: + Apple Silicon/HVF plus separate pinned Arch/Fedora KVM records - Guest-side rewrite rules for pip, npm, and cargo absolute follow-up URLs - Runtime artifact distribution and code-signing approach -- Reproducible guest image build environment +- Release reproducibility evidence for both guest image build environments - Image update and vulnerability-response policy - Named demonstration repository and toolchain set for the image budget -- Named Apple Silicon baseline machine for resource budgets -- Dedicated non-nested Apple Silicon hardware runner, decided in Phase 0 +- Named Apple Silicon, Arch, and Fedora baseline machines for resource budgets +- Dedicated non-nested Apple Silicon and native Arch/Fedora hardware runners - Validation or evidence-based adjustment of the initial resource budgets - Exact explicit-confirmation interaction for patch import - Go sum-database policy for origin-rewritten `GOPROXY` diff --git a/README.md b/README.md index a5d4f86..ef98482 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,17 @@ The agent, prompts, model calls, and tools runs in isolation. Right now, the industry is incredibly focused on agent sandboxes for production (servers, cloud, Kubernetes, etc), but the biggest security entry point are agents running locally on someone’s laptop. -**Status:** experimental. Runnable today: guest agent (prompt, tools) in a -libkrun microVM on Apple Silicon; host TUI/SDK, LLM/MCP brokers, and -`run_command` approval. Protocol 4. +**Status:** experimental. Runnable today on Apple Silicon: guest agent (prompt, +tools) in a libkrun microVM; host TUI/SDK, LLM/MCP brokers, and `run_command` +approval. Protocol 4. The Linux host port and build/image paths are implemented, +but Linux VMM execution, release support, and KVM isolation remain **Planned** +pending separate Phase 0.5 and Phase 18 hardware evidence on the pinned Arch and +Fedora baselines. ## Prerequisites -- Apple Silicon Mac or Linux -- Go 1.24+ -- Docker (today: pack the guest **root filesystem** image only; not on the session path) +- Apple Silicon Mac for the currently runnable host path +- Go 1.25+ - libkrun and libkrunfw (VMM + **guest Linux kernel**; the kernel is not inside the `.raw` disk) ### Mac @@ -44,20 +46,31 @@ brew install libkrun libkrunfw ### Linux -## Quickstart +Linux builds require cgo, gcc, pkg-config, and libkrun 1.19.x headers. The +pinned build baselines are Arch's 2026-09-17 x86_64 snapshot with +libkrun `1.19.4-1` / libkrunfw `5.5.0-1`, and Fedora 44 x86_64 with +libkrun/libkrun-devel `1.19.0-1.fc44` / libkrunfw `5.5.0-1.fc44`. + +Linux `make image` is rootless and native: no Docker, root, loop mount, or +privileged container. It additionally needs `fakeroot`, e2fsprogs 1.43+, +`curl` or `wget`, `sha256sum`, `tar`, `od`, `awk`, and `flock`. -From this directory or any other directory. ABox snapshots that exact directory -without inspecting host Git state. `.git` metadata is excluded; the guest -creates its own private baseline for change tracking. +See [Platform support](docs/platforms.md) for package commands and the precise +support boundary. WSL2 and running the VMM inside a container are unsupported; +containers remain valid compile/image-build environments. -Git ignore rules are not consulted. Every regular file beneath the selected -directory is copied, including dotfiles, except `.git` metadata. Start ABox -from a directory containing only files the guest is allowed to read. +## Quickstart + +Run ABox from anywhere inside a Git worktree. ABox discovers the repository +root and copies a private snapshot into the guest without modifying host Git. +Clean worktrees use committed `HEAD`; dirty or commitless worktrees snapshot +tracked files plus non-ignored untracked files. Git-ignored files, `.git` +metadata, and the host-only `~/.abox` directory are not copied. ![](img/abox-quickstart.gif) ### Mac -`make image`: uses Docker once (today) to pack a raw ext4 root filesystem -(`~/.abox/images/abox-guest.raw`): Alpine userspace, git, patch, and +`make image`: uses Docker once to pack a raw ext4 root filesystem +(`~/.abox/images/abox-guest-linux-arm64.raw`): Alpine userspace, git, patch, and abox-guest. Not the guest kernel. Needed the first time, or when you want a full disk rebuild. Depends on `make guest`. @@ -90,11 +103,37 @@ abox ### Linux +Build-only workflow on either pinned baseline: + +```bash +make build +make image GUEST_ARCH=amd64 +``` + +For the local amd64 test bundle built on this machine, add both `abox` and its +bundled `abox-vmm` helper to the current shell's `PATH`: + +```bash +export PATH="$HOME/abox-local-linux-amd64:$PATH" +cd "$HOME/gitrepos/ABox" +abox --probe-vm +abox +``` + +Run those commands from anywhere inside the Git worktree you want ABox to +snapshot. The normal state and configuration location is `~/.abox`; the guest +image is at `~/.abox/images/abox-guest-linux-amd64.raw`. + +Do not present a successful Linux build or boot probe as supported KVM +isolation. Native Arch and Fedora x86_64 Phase 0.5/18 evidence is still +required before using this as a supported runtime workflow. + ## microVM > Docker `abox` does not run the agent in Docker. A session is a **libkrun microVM**: -Linux kernel + `abox-guest` on Apple Hypervisor.framework. The guest repo is -a copy of your files on that VM disk, not a container mount. +Linux kernel + `abox-guest` on Apple Hypervisor.framework, or on the implemented +but not yet hardware-qualified Linux/KVM path. The guest source tree is a copy +of your files on that VM disk, not a container mount. ### Kernel vs disk @@ -102,19 +141,19 @@ The `.raw` file is **only a disk**: a 768 MiB ext4 **root filesystem** (userspace). Alpine base, `git`, `patch`, `/usr/local/bin/abox-guest`. No kernel, no bootloader, no hypervisor. -The guest kernel comes from **libkrunfw** (Homebrew), not from that disk. -`abox-vmm` links libkrun + libkrunfw, starts the VM, then attaches host files -as virtio-blk: +The guest kernel comes from **libkrunfw**, not from that disk. `abox-vmm` starts +libkrun, which uses Hypervisor.framework on macOS or KVM on Linux, then attaches +host files as virtio-blk: ```text abox → abox-vmm → libkrun (userspace VMM) → libkrunfw ← Linux kernel - → Hypervisor.framework - → ARM virtualization + → Hypervisor.framework (macOS) / KVM (Linux, Planned support) + → hardware virtualization -host: ~/.abox/sessions//root.raw ordinary Mac file (ext4) +host: ~/.abox/sessions//root.raw ordinary host file (ext4) ↓ libkrun virtio-blk guest: /dev/vda block device ↓ kernel mounts ext4 as / @@ -128,20 +167,24 @@ guest: /dev/vdb session id + model alias (no secrets) Swap the `.raw` and you change userspace. Swap libkrunfw and you change the guest kernel. -### Docker packs that filesystem (today) +### Platform image builders -Docker is only the packer: a privileged container runs `apk` and `mkfs.ext4` -because a Mac cannot build that ARM64 ext4 tree itself. Session boot does not -use Docker. Replacing this packer is follow-up work. +On macOS, Docker is only the packer: a privileged container runs `apk` and +`mkfs.ext4` because macOS cannot build that ext4 tree natively. On Linux, the +native builder uses `apk.static`, `fakeroot`, and `mke2fs -d` as an ordinary +user. Session boot never uses Docker. | Step | What runs | | --- | --- | -| `make image` | Docker, to pack the golden **root filesystem** (ext4 `.raw`) | -| `make image-update` | Docker, to replace `/usr/local/bin/abox-guest` on that `.raw` | +| `make image` (macOS) | Docker, to pack the golden **root filesystem** (ext4 `.raw`) | +| `make image` / `make image-update` (Linux) | Rootless native rebuild; no Docker or KVM | | `abox` / `--probe-vm` | `abox` + `abox-vmm` + libkrun + libkrunfw. No Docker. | -1. Golden image — ~/.abox/images/abox-guest.raw -Packed once (make image). Alpine + git + patch + abox-guest. Template only. Not attached to a running VM. +1. Golden image: `~/.abox/images/abox-guest-linux-.raw` +Packed once (`make image`). Alpine + git + patch + abox-guest. Template only. +Not attached to a running VM. Its resolved immutable image has an adjacent +`.manifest.json` recording schema, architecture, image ID, protocol, and +SHA-256. Custom images require the same adjacent manifest. 2. Session hard disk — ~/.abox/sessions//root.raw Clone of (1) for that run. This is /dev/vda → /. Repo, guest Git, agent writes. Destroy the session dir and this disk is gone; the golden stays. @@ -151,7 +194,11 @@ ABox does not boot (1). It copies (1) → (2), then the microVM uses (2). --resu 3. Config disk — sessions//config.raw ~1 MiB, read-only /dev/vdb. Session id and model alias. No API keys, no MCP URLs. Not cloned from the golden image, not an OS. It lives inside of the directory where your sandbox harness session lives. -The VM boots **only** the session clone, not the golden file. Destroy a session directory and that run’s guest files are gone; the golden image stays clean for the next `abox`. `make image-update` patches `/usr/local/bin/abox-guest` on an existing golden disk; `make image` rebuilds the golden disk from scratch. +The VM boots **only** the session clone, not the golden file. Destroy a session +directory and that run's guest files are gone; the golden image stays clean for +the next `abox`. `make image-update` refreshes the golden image with the current +guest binary (a full new generation on Linux); `make image` rebuilds it from +scratch. ### Resume Command @@ -299,7 +346,7 @@ The following credential providers are supported (where your LLM API key lives): | Source | `name` is | Auth | | --- | --- | --- | | `env` | environment variable (also reads `~/.abox/credentials.env`) | — | -| `keychain` | macOS keychain account (service `abox`) | — | +| `keystore` | macOS Keychain or Linux Secret Service account (service `abox`) | `secret-tool` + `gdbus` + a provider on Linux | | `vault` | Vault KV v2 path (`secret/abox/anthropic`) | `VAULT_ADDR` + `VAULT_TOKEN` (or `~/.vault-token`) | | `azure` | Key Vault secret URI (`https://myvault.vault.azure.net/secrets/name`) | `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_CLIENT_SECRET`, or `az login` | | `aws` | Secrets Manager secret id | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (`AWS_REGION`), or `~/.aws/credentials` | @@ -308,15 +355,23 @@ Config lives at `~/.abox/config.yaml`. Keys are **not** stored in that file. Eac ```yaml credential: - source: keychain # env | keychain | vault | azure | aws - name: ANTHROPIC_API_KEY # env var, keychain account, vault path, Azure secret URI, or AWS secret id + source: keystore # env | keystore | vault | azure | aws + name: ANTHROPIC_API_KEY # env var, OS-keystore account, vault path, Azure secret URI, or AWS secret id # field: value # vault/aws only # version: "4" # vault/azure only ``` `credential_env: XAI_API_KEY` is the same as `{source: env, name: XAI_API_KEY}`. -`/provider` and `/mcp` in the TUI save to the macOS keychain first, falling back to `credentials.env` (mode 0600) if the keychain is locked or missing. `/credential` writes a Vault / Azure Key Vault / AWS Secrets Manager reference into `config.yaml` (it does not store cloud tokens). When the cloud auth env vars are unset, Azure uses the local `az login` session and AWS uses `~/.aws/credentials` (and region from `~/.aws/config`). `abox creds migrate` moves existing `credentials.env` entries into the keychain. +`/provider` and `/mcp` save to the OS keystore first: macOS Keychain or Linux +Secret Service. Linux supports GNOME Keyring, KWallet's Secret Service +compatibility service, and KeePassXC. If the keystore is absent, locked, loses +its provider, or times out, ABox warns and falls back to `credentials.env` +(mode 0600). `keychain` and `secretservice` remain accepted config aliases, but +saved config uses `keystore`. `/credential` writes a Vault / Azure Key Vault / +AWS Secrets Manager reference into `config.yaml`; those sources are portable +and are the supported headless Linux alternatives. `abox creds migrate` moves +existing file entries into the available OS keystore. LLM keys and MCP tokens stay on the host. The guest never receives them. @@ -338,7 +393,7 @@ type StreamableClientTransport struct { `StreamableClientTransport` is the best choice for this architectural setup as when running an AI agent inside an isolated sandbox (e.g., microVM), the sandbox itself becomes part of your security trust boundary. -![](img/mcpsandbox.png.png) +![](img/mcpsandbox.png) Config lives at `~/.abox/config.yaml` (same pattern as `~/.claude`, `~/.codex`). First `abox` run creates `~/.abox/` (mode 0700) and a default `config.yaml` if they are missing. MCP tokens use the same credential sources as LLM keys (see [Credentials](#credentials)). @@ -413,9 +468,11 @@ _, err = sess.Turn(ctx, "List the repo files", func(ev abox.Event) { }) ``` -Import `github.com/AdminTurnedDevOps/ABox/pkg/abox`. Apple Silicon, libkrun, -golden image. `Open` / `Resume` require protocol 4 (host LLM/MCP brokers and -`run_command` approval). Resume of a pre-rebuild disk returns `ErrGuestTooOld`. +Import `github.com/AdminTurnedDevOps/ABox/pkg/abox`. The SDK needs a qualified +host runtime, libkrun/libkrunfw, and an architecture-tagged golden image plus +manifest. Linux runtime support remains Planned as described above. `Open` / +`Resume` require protocol 4 (host LLM/MCP brokers and `run_command` approval). +Resume of a pre-rebuild disk returns `ErrGuestTooOld`. ## Why? @@ -463,8 +520,8 @@ isolation stays Planned. Do not describe this build as verified isolation. The device plan is allowlisted (no guest NIC, no TSI inet, no host-path virtio-fs). LLM and MCP -HTTPS are host-brokered. Claims stay Planned until the hardware suite in -`PLAN.md` §21.4 passes. +HTTPS are host-brokered. Claims stay Planned until the platform-specific Phase +0.5 and Phase 18 hardware suites in `PLAN.md` §21.4/§22 pass. ## Whats Currently In Place @@ -474,7 +531,7 @@ HTTPS are host-brokered. Claims stay Planned until the hardware suite in ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ Agent loop │ In the guest. Host is TUI + VMM + LLM/MCP brokers │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ -│ MicroVM boot │ libkrun 1.19.4, raw disks, vsock, abox-vmm │ +│ MicroVM boot │ libkrun 1.19.x, raw disks, vsock, abox-vmm │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ Five tools │ list_files, read_file, search, apply_patch, run_command in guest │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ @@ -488,7 +545,7 @@ HTTPS are host-brokered. Claims stay Planned until the hardware suite in ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ TUI │ Instrument panel; /provider /credential /mcp /help; cmd approval │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ -│ Credentials │ env, keychain, vault, azure, aws. Keys stay on the host │ +│ Credentials │ env, keystore, vault, azure, aws. Keys stay on the host │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ Approvals │ run_command prompts (default deny). MCP tools do not │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ @@ -509,9 +566,6 @@ Harness Runtime • Cold checkpoint, rollback, fork (quiesce ioctl exists; no lineage/UI) • Idle-stop / resume / preserve -• Image manifest + SHA-256 verify -• VMM liveness pipe, stale-PID cleanup -• Orderly shutdown via krun_get_shutdown_eventfd • Device-plan unit tests; hardware canary suite (§21.4 / §22) • Resource budgets and named-machine benches @@ -523,4 +577,4 @@ Providers (as specified) Docs / Phase 0 leftovers • threat model, ADRs, roadmap • Phase 0.5 spike recorded as Planned vs verified -• PLAN still says “no TSI” in one table and “TSI inet for HTTPS” in another — needs a single decision (code: vsock flags 0, no TSI) +• Separate macOS/HVF and Linux/KVM Phase 0.5 and Phase 18 evidence records diff --git a/cmd/abox-guest/main.go b/cmd/abox-guest/main.go index 8873a47..46ce08b 100644 --- a/cmd/abox-guest/main.go +++ b/cmd/abox-guest/main.go @@ -26,6 +26,8 @@ import ( "golang.org/x/sys/unix" ) +var imageID = "abox-guest-dev" + func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "abox-guest: %v\n", err) @@ -34,6 +36,9 @@ func main() { } func run() error { + if os.Geteuid() != 0 { + return fmt.Errorf("guest supervisor must run as root so tool subprocesses can drop privileges") + } prepMounts() cfg, err := loadConfig() if err != nil { @@ -43,6 +48,9 @@ func run() error { if err := os.MkdirAll(repo.Root, 0o755); err != nil { return err } + if err := os.Chown(repo.Root, 1000, 1000); err != nil { + return fmt.Errorf("set guest repository ownership: %w", err) + } if len(cfg.Secrets) > 0 || len(cfg.MCPServers) > 0 { return fmt.Errorf("legacy guest config contains credentials or MCP endpoints; rebuild the session") } @@ -68,7 +76,7 @@ func run() error { hello, _ := protocol.EncodeParams(protocol.HelloParams{ SessionID: cfg.SessionID, Capability: cfg.Capability, - ImageID: "abox-guest-dev", + ImageID: imageID, Protocol: protocol.Version, GuestReady: true, History: loop.History(), diff --git a/cmd/abox-vmm/build_tags_test.go b/cmd/abox-vmm/build_tags_test.go new file mode 100644 index 0000000..58bdafe --- /dev/null +++ b/cmd/abox-vmm/build_tags_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "bufio" + "os" + "testing" +) + +func TestPlatformBuildTags(t *testing.T) { + want := map[string]string{ + "cgoflags_darwin_arm64.go": "//go:build cgo && darwin && arm64", + "cgoflags_linux.go": "//go:build cgo && linux && (amd64 || arm64)", + "start_libkrun.go": "//go:build cgo && ((darwin && arm64) || (linux && (amd64 || arm64)))", + "start_stub.go": "//go:build !cgo || (!darwin && !linux) || (darwin && !arm64) || (linux && !amd64 && !arm64)", + } + for path, tag := range want { + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + file.Close() + t.Fatalf("read build tag from %s: %v", path, scanner.Err()) + } + got := scanner.Text() + file.Close() + if got != tag { + t.Errorf("%s build tag = %q, want %q", path, got, tag) + } + } +} diff --git a/cmd/abox-vmm/cgoflags_darwin_arm64.go b/cmd/abox-vmm/cgoflags_darwin_arm64.go new file mode 100644 index 0000000..dd99b8c --- /dev/null +++ b/cmd/abox-vmm/cgoflags_darwin_arm64.go @@ -0,0 +1,9 @@ +//go:build cgo && darwin && arm64 + +package main + +/* +#cgo CFLAGS: -I/opt/homebrew/include +#cgo LDFLAGS: -L/opt/homebrew/lib -lkrun -lkrunfw -Wl,-rpath,/opt/homebrew/lib +*/ +import "C" diff --git a/cmd/abox-vmm/cgoflags_linux.go b/cmd/abox-vmm/cgoflags_linux.go new file mode 100644 index 0000000..40a8e0e --- /dev/null +++ b/cmd/abox-vmm/cgoflags_linux.go @@ -0,0 +1,8 @@ +//go:build cgo && linux && (amd64 || arm64) + +package main + +/* +#cgo pkg-config: libkrun +*/ +import "C" diff --git a/cmd/abox-vmm/main.go b/cmd/abox-vmm/main.go index cf8b063..94ff384 100644 --- a/cmd/abox-vmm/main.go +++ b/cmd/abox-vmm/main.go @@ -19,6 +19,16 @@ func main() { } func run() error { + liveness := os.NewFile(3, "supervisor-liveness") + if liveness == nil { + return fmt.Errorf("liveness fd 3 is required") + } + if _, err := liveness.Stat(); err != nil { + return fmt.Errorf("liveness fd 3: %w", err) + } + defer liveness.Close() + go watchLiveness(liveness, os.Exit) + data, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("read config: %w", err) @@ -33,3 +43,12 @@ func run() error { } return startVM(cfg) } + +func watchLiveness(r io.Reader, exit func(int)) { + _, err := io.Copy(io.Discard, r) + if err != nil { + exit(1) + return + } + exit(0) +} diff --git a/cmd/abox-vmm/main_test.go b/cmd/abox-vmm/main_test.go new file mode 100644 index 0000000..e159400 --- /dev/null +++ b/cmd/abox-vmm/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "testing" + "time" +) + +func TestWatchLivenessExitsOnEOF(t *testing.T) { + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer readEnd.Close() + exited := make(chan int, 1) + go watchLiveness(readEnd, func(code int) { exited <- code }) + if err := writeEnd.Close(); err != nil { + t.Fatal(err) + } + select { + case code := <-exited: + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + case <-time.After(time.Second): + t.Fatal("watchLiveness did not exit on EOF") + } +} diff --git a/cmd/abox-vmm/preflight_darwin.go b/cmd/abox-vmm/preflight_darwin.go new file mode 100644 index 0000000..00b1141 --- /dev/null +++ b/cmd/abox-vmm/preflight_darwin.go @@ -0,0 +1,5 @@ +//go:build darwin + +package main + +func platformPreflight() error { return nil } diff --git a/cmd/abox-vmm/preflight_linux.go b/cmd/abox-vmm/preflight_linux.go new file mode 100644 index 0000000..9d1fd61 --- /dev/null +++ b/cmd/abox-vmm/preflight_linux.go @@ -0,0 +1,97 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const ( + kvmGetAPIVersion = 0xae00 + kvmCreateVM = 0xae01 + kvmAPIVersion = 12 +) + +func platformPreflight() error { + if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { + release := strings.ToLower(string(data)) + if strings.Contains(release, "microsoft") || strings.Contains(release, "wsl") { + return fmt.Errorf("WSL is unsupported for ABox VMM execution; use a physical Linux KVM host") + } + } + if runningInContainer() { + return fmt.Errorf("containerized ABox VMM execution is unsupported; containers are build-only environments") + } + + f, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0) + if err != nil { + switch { + case errors.Is(err, os.ErrNotExist): + return fmt.Errorf("/dev/kvm is unavailable; enable virtualization in firmware and load kvm_intel or kvm_amd") + case errors.Is(err, os.ErrPermission): + return fmt.Errorf("cannot open /dev/kvm: %w; add the user to the kvm group, then log out and back in", err) + default: + return fmt.Errorf("open /dev/kvm: %w", err) + } + } + defer f.Close() + + version, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), kvmGetAPIVersion, 0) + if errno != 0 { + return fmt.Errorf("query KVM API version: %w; if this host is virtualized, enable nested virtualization", errno) + } + if int(version) != kvmAPIVersion { + return fmt.Errorf("unsupported KVM API version %d; expected %d", version, kvmAPIVersion) + } + vmfd, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), kvmCreateVM, 0) + if errno != 0 { + message := fmt.Sprintf("create KVM VM: %v; if this host is virtualized, enable nested virtualization", errno) + if errno == syscall.EACCES || errno == syscall.EPERM { + message += "; on Fedora with correct file permissions, inspect SELinux AVCs with ausearch or journalctl rather than disabling SELinux" + } + return errors.New(message) + } + if err := unix.Close(int(vmfd)); err != nil { + return fmt.Errorf("close KVM preflight VM: %w", err) + } + return nil +} + +func runningInContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + return true + } + if _, err := os.Stat("/run/systemd/container"); err == nil { + return true + } + if _, err := os.Stat("/usr/bin/systemd-detect-virt"); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if exec.CommandContext(ctx, "/usr/bin/systemd-detect-virt", "--quiet", "--container").Run() == nil { + return true + } + } + data, err := os.ReadFile("/proc/1/cgroup") + if err != nil { + return false + } + text := strings.ToLower(string(data)) + for _, marker := range []string{"docker", "kubepods", "containerd", "libpod", "lxc"} { + if strings.Contains(text, marker) { + return true + } + } + return false +} diff --git a/cmd/abox-vmm/start_darwin_arm64.go b/cmd/abox-vmm/start_libkrun.go similarity index 55% rename from cmd/abox-vmm/start_darwin_arm64.go rename to cmd/abox-vmm/start_libkrun.go index 3faf27d..1048a3a 100644 --- a/cmd/abox-vmm/start_darwin_arm64.go +++ b/cmd/abox-vmm/start_libkrun.go @@ -1,10 +1,8 @@ -//go:build darwin && arm64 +//go:build cgo && ((darwin && arm64) || (linux && (amd64 || arm64))) package main /* -#cgo CFLAGS: -I/opt/homebrew/include -#cgo LDFLAGS: -L/opt/homebrew/lib -lkrun -lkrunfw -Wl,-rpath,/opt/homebrew/lib #include #include */ @@ -12,37 +10,47 @@ import "C" import ( "fmt" + "syscall" "unsafe" "github.com/AdminTurnedDevOps/ABox/internal/vmmconfig" ) func startVM(cfg vmmconfig.Config) error { + if err := platformPreflight(); err != nil { + return err + } ctx := C.krun_create_ctx() if ctx < 0 { - return fmt.Errorf("krun_create_ctx: %d", int(ctx)) + return libkrunError("create VM context", int(ctx)) } id := C.uint32_t(ctx) + owned := true + defer func() { + if owned { + C.krun_free_ctx(id) + } + }() if rc := C.krun_set_vm_config(id, C.uint8_t(cfg.VCPU), C.uint32_t(cfg.RAMMiB)); rc < 0 { - return fmt.Errorf("krun_set_vm_config: %d", int(rc)) + return libkrunError("configure VM resources", int(rc)) } if rc := C.krun_has_feature(C.KRUN_FEATURE_BLK); rc != 1 { - return fmt.Errorf("libkrun build lacks block devices (krun_has_feature=%d)", int(rc)) + return fmt.Errorf("installed libkrun build does not provide required block-device support") } if rc := C.krun_disable_implicit_vsock(id); rc < 0 { - return fmt.Errorf("krun_disable_implicit_vsock: %d", int(rc)) + return libkrunError("disable implicit vsock forwarding", int(rc)) } if rc := C.krun_add_vsock(id, 0); rc < 0 { - return fmt.Errorf("krun_add_vsock: %d", int(rc)) + return libkrunError("add guest vsock device", int(rc)) } sock := C.CString(cfg.RPCSocket) defer C.free(unsafe.Pointer(sock)) if rc := C.krun_add_vsock_port(id, C.uint32_t(cfg.VsockPort), sock); rc < 0 { - return fmt.Errorf("krun_add_vsock_port: %d", int(rc)) + return libkrunError("add guest RPC vsock port", int(rc)) } root := C.CString(cfg.RootDisk) @@ -50,7 +58,7 @@ func startVM(cfg vmmconfig.Config) error { rootID := C.CString("root") defer C.free(unsafe.Pointer(rootID)) if rc := C.krun_add_disk3(id, rootID, root, C.KRUN_DISK_FORMAT_RAW, false, false, C.KRUN_SYNC_FULL); rc < 0 { - return fmt.Errorf("krun_add_disk3 root: %d", int(rc)) + return libkrunError("attach root disk", int(rc)) } if cfg.ConfigDisk != "" { @@ -59,7 +67,7 @@ func startVM(cfg vmmconfig.Config) error { cfgID := C.CString("config") defer C.free(unsafe.Pointer(cfgID)) if rc := C.krun_add_disk3(id, cfgID, cfgPath, C.KRUN_DISK_FORMAT_RAW, true, false, C.KRUN_SYNC_FULL); rc < 0 { - return fmt.Errorf("krun_add_disk3 config: %d", int(rc)) + return libkrunError("attach read-only config disk", int(rc)) } } @@ -68,7 +76,7 @@ func startVM(cfg vmmconfig.Config) error { fstype := C.CString("ext4") defer C.free(unsafe.Pointer(fstype)) if rc := C.krun_set_root_disk_remount(id, dev, fstype, nil); rc < 0 { - return fmt.Errorf("krun_set_root_disk_remount: %d", int(rc)) + return libkrunError("configure root disk", int(rc)) } execPath := C.CString(cfg.ExecPath) @@ -94,15 +102,36 @@ func startVM(cfg vmmconfig.Config) error { envp = append(envp, nil) if rc := C.krun_set_exec(id, execPath, &argv[0], &envp[0]); rc < 0 { - return fmt.Errorf("krun_set_exec: %d", int(rc)) + return libkrunError("configure guest process", int(rc)) } if cfg.ConsoleLog != "" { clog := C.CString(cfg.ConsoleLog) defer C.free(unsafe.Pointer(clog)) - _ = C.krun_set_console_output(id, clog) + if rc := C.krun_set_console_output(id, clog); rc < 0 { + return libkrunError("configure guest console", int(rc)) + } } + // krun_start_enter consumes the context even when it returns an error. + owned = false rc := C.krun_start_enter(id) - return fmt.Errorf("krun_start_enter returned %d", int(rc)) + return libkrunError("start VM", int(rc)) +} + +func libkrunError(operation string, rc int) error { + if rc >= 0 { + return fmt.Errorf("%s ended unexpectedly", operation) + } + errno := syscall.Errno(-rc) + switch errno { + case syscall.ENOENT: + return fmt.Errorf("%s: %w; verify the libkrunfw package required by the installed libkrun build and refresh the loader cache", operation, errno) + case syscall.EACCES, syscall.EPERM: + return fmt.Errorf("%s: %w; verify disk permissions and inspect SELinux AVCs on Fedora rather than disabling SELinux", operation, errno) + case syscall.ENODEV, syscall.ENOSYS: + return fmt.Errorf("%s: %w; verify KVM support and the installed libkrun/libkrunfw package pair", operation, errno) + default: + return fmt.Errorf("%s: %w", operation, errno) + } } diff --git a/cmd/abox-vmm/start_stub.go b/cmd/abox-vmm/start_stub.go index 538fc44..0defa6c 100644 --- a/cmd/abox-vmm/start_stub.go +++ b/cmd/abox-vmm/start_stub.go @@ -1,4 +1,4 @@ -//go:build !darwin || !arm64 +//go:build !cgo || (!darwin && !linux) || (darwin && !arm64) || (linux && !amd64 && !arm64) package main @@ -8,9 +8,6 @@ import ( "github.com/AdminTurnedDevOps/ABox/internal/vmmconfig" ) -// startVM is a GOOS link stub: main.go always calls it, so this OS needs a -// definition. The libkrun implementation is start_darwin_arm64.go (macOS -// Apple Silicon). Not a placeholder for a Linux/Windows VMM. func startVM(cfg vmmconfig.Config) error { - return fmt.Errorf("abox-vmm requires macOS on Apple Silicon with libkrun") + return fmt.Errorf("abox-vmm requires cgo and libkrun on macOS arm64 or Linux amd64/arm64") } diff --git a/cmd/abox/creds.go b/cmd/abox/creds.go index 9ce51e3..7d580ee 100644 --- a/cmd/abox/creds.go +++ b/cmd/abox/creds.go @@ -14,25 +14,25 @@ import ( ) var ( - migrationKeychainAvailable = credsource.KeychainAvailable - migrationSetKeychain = credsource.SetKeychain + migrationKeystoreAvailable = credsource.OSKeystoreAvailable + migrationSetKeystore = credsource.SetOSKeystore ) -func runCreds(args []string) error { +func runCreds(ctx context.Context, args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: abox creds migrate (move credentials.env entries to the macOS keychain)") + return fmt.Errorf("usage: abox creds migrate (move credentials.env entries to the OS keystore)") } switch args[0] { case "migrate": - return credsMigrate() + return credsMigrate(ctx) default: return fmt.Errorf("unknown creds command %q (try: abox creds migrate)", args[0]) } } -func credsMigrate() error { - if !migrationKeychainAvailable() { - return fmt.Errorf("macOS keychain unavailable (this command needs /usr/bin/security on darwin); the env credential source keeps working") +func credsMigrate(parent context.Context) error { + if !migrationKeystoreAvailable() { + return fmt.Errorf("OS keystore unavailable; credentials.env remains available as a mode 0600 plaintext fallback") } cfg, _, err := config.Load() if err != nil { @@ -47,7 +47,7 @@ func credsMigrate() error { return nil } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(parent, 60*time.Second) defer cancel() remaining := make(map[string]string, len(creds)) @@ -64,7 +64,7 @@ func credsMigrate() error { dropped++ continue } - if err := migrationSetKeychain(ctx, name, []byte(creds[name])); err != nil { + if err := migrationSetKeystore(ctx, name, []byte(creds[name])); err != nil { fmt.Printf("skipped %s: %v (entry stays in the file; set it manually or re-login)\n", name, err) failed++ continue @@ -75,7 +75,7 @@ func credsMigrate() error { } if configChanged { if err := cfg.Save(); err != nil { - return fmt.Errorf("keychain writes succeeded but config update failed: %w", err) + return fmt.Errorf("keystore writes succeeded but config update failed: %w", err) } } if err := rewriteCredentialFile(remaining); err != nil { @@ -85,7 +85,7 @@ func credsMigrate() error { fmt.Printf("migrated %d, dropped %d refresh token(s), skipped %d (only skipped entries remain; fix them and run abox creds migrate again)\n", migrated, dropped, failed) return nil } - fmt.Printf("migrated %d credential(s) to the macOS keychain (service %s), dropped %d refresh token(s)\n", + fmt.Printf("migrated %d credential(s) to the OS keystore (service %s), dropped %d refresh token(s)\n", migrated, credsource.KeychainService, dropped) fmt.Printf("rewrote %s (kept, mode 0600)\n", credentials.Path()) return nil @@ -117,7 +117,7 @@ func upsertCredentialRefs(cfg *config.File, name string) bool { ref := cfg.Models[i].CredentialReference() if ref.Source == "env" && ref.Name == name { cfg.Models[i].CredentialEnv = "" - cfg.Models[i].Credential = &config.CredentialRef{Source: "keychain", Name: name} + cfg.Models[i].Credential = &config.CredentialRef{Source: "keystore", Name: name} changed = true } } @@ -125,7 +125,7 @@ func upsertCredentialRefs(cfg *config.File, name string) bool { ref := cfg.MCPServers[i].CredentialReference() if ref.Source == "env" && ref.Name == name { cfg.MCPServers[i].CredentialEnv = "" - cfg.MCPServers[i].Credential = &config.CredentialRef{Source: "keychain", Name: name} + cfg.MCPServers[i].Credential = &config.CredentialRef{Source: "keystore", Name: name} changed = true } } @@ -136,7 +136,7 @@ func rewriteCredentialFile(creds map[string]string) error { var body strings.Builder body.WriteString("# ABox credentials. Mode 0600. Do not commit.\n") if len(creds) == 0 { - body.WriteString("# Credentials migrated to the macOS keychain; env fallback remains supported.\n") + body.WriteString("# Credentials migrated to the OS keystore; env fallback remains supported.\n") } else { for _, name := range sortedCredNames(creds) { body.WriteString(name) diff --git a/cmd/abox/creds_test.go b/cmd/abox/creds_test.go index 18b6d26..cd9637d 100644 --- a/cmd/abox/creds_test.go +++ b/cmd/abox/creds_test.go @@ -39,11 +39,11 @@ func TestCredsMigrateRemovesSuccessAndRefreshButKeepsFailure(t *testing.T) { } } - origAvailable := migrationKeychainAvailable - origSet := migrationSetKeychain - migrationKeychainAvailable = func() bool { return true } + origAvailable := migrationKeystoreAvailable + origSet := migrationSetKeystore + migrationKeystoreAvailable = func() bool { return true } called := map[string]bool{} - migrationSetKeychain = func(_ context.Context, name string, _ []byte) error { + migrationSetKeystore = func(_ context.Context, name string, _ []byte) error { called[name] = true if name == "FAILED_KEY" || name == "MODEL_REFRESH" || name == "MCP_CRED_REFRESH" { return errors.New("write failed") @@ -51,11 +51,11 @@ func TestCredsMigrateRemovesSuccessAndRefreshButKeepsFailure(t *testing.T) { return nil } t.Cleanup(func() { - migrationKeychainAvailable = origAvailable - migrationSetKeychain = origSet + migrationKeystoreAvailable = origAvailable + migrationSetKeystore = origSet }) - if err := credsMigrate(); err != nil { + if err := credsMigrate(context.Background()); err != nil { t.Fatal(err) } remaining, err := credentials.Load() @@ -66,7 +66,7 @@ func TestCredsMigrateRemovesSuccessAndRefreshButKeepsFailure(t *testing.T) { t.Fatalf("remaining credentials %#v", remaining) } if called["MCP_CRED_REFRESH_REFRESH"] || called["MCP_TOKEN_REFRESH"] { - t.Fatal("known legacy refresh token was sent to the keychain") + t.Fatal("known legacy refresh token was sent to the keystore") } if !called["MODEL_REFRESH"] || !called["MCP_CRED_REFRESH"] { t.Fatalf("configured refresh-suffixed credentials were dropped: calls %#v", called) @@ -75,7 +75,7 @@ func TestCredsMigrateRemovesSuccessAndRefreshButKeepsFailure(t *testing.T) { if err != nil { t.Fatal(err) } - if got := savedCfg.Models[0].CredentialReference(); got != (config.CredentialRef{Source: "keychain", Name: "CUSTOM_SOURCE"}) { + if got := savedCfg.Models[0].CredentialReference(); got != (config.CredentialRef{Source: "keystore", Name: "CUSTOM_SOURCE"}) { t.Fatalf("custom reference %#v", got) } if got := savedCfg.Models[1].CredentialReference(); got != (config.CredentialRef{Source: "env", Name: "FAILED_KEY"}) { @@ -116,13 +116,13 @@ func TestUpsertCredentialRefsUsesEffectiveEnvReference(t *testing.T) { if !upsertCredentialRefs(&cfg, "CUSTOM_ENV") { t.Fatal("custom env reference was not changed") } - if got := cfg.Models[3].CredentialReference(); got != (config.CredentialRef{Source: "keychain", Name: "CUSTOM_ENV"}) { + if got := cfg.Models[3].CredentialReference(); got != (config.CredentialRef{Source: "keystore", Name: "CUSTOM_ENV"}) { t.Fatalf("custom env reference %#v", got) } if !upsertCredentialRefs(&cfg, "CUSTOM_MCP_ENV") { t.Fatal("custom MCP env reference was not changed") } - if got := cfg.MCPServers[0].CredentialReference(); got != (config.CredentialRef{Source: "keychain", Name: "CUSTOM_MCP_ENV"}) { + if got := cfg.MCPServers[0].CredentialReference(); got != (config.CredentialRef{Source: "keystore", Name: "CUSTOM_MCP_ENV"}) { t.Fatalf("custom MCP env reference %#v", got) } } diff --git a/cmd/abox/main.go b/cmd/abox/main.go index 875f97d..cf8d1be 100644 --- a/cmd/abox/main.go +++ b/cmd/abox/main.go @@ -11,6 +11,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "strings" "time" @@ -28,21 +29,29 @@ import ( ) func main() { - if err := run(); err != nil { + ctx, stop := signal.NotifyContext(context.Background(), terminationSignals()...) + go func() { + <-ctx.Done() + // Restore default handling so a second termination signal forces exit. + stop() + }() + err := run(ctx) + stop() + if err != nil && !(ctx.Err() != nil && errors.Is(err, context.Canceled)) { fmt.Fprintf(os.Stderr, "abox: %v\n", err) os.Exit(1) } } -func run() error { +func run(ctx context.Context) error { if err := scrubLegacySessions(); err != nil { return err } if len(os.Args) > 1 && os.Args[1] == "mcp" { - return runMCP(os.Args[2:]) + return runMCP(ctx, os.Args[2:]) } if len(os.Args) > 1 && os.Args[1] == "creds" { - return runCreds(os.Args[2:]) + return runCreds(ctx, os.Args[2:]) } fs := flag.NewFlagSet("abox", flag.ContinueOnError) execFlag := fs.Bool("exec", false, "headless driver") @@ -85,6 +94,9 @@ func run() error { var sess *session.Session var archive []byte resuming := strings.TrimSpace(*resumeID) != "" + if resuming && *probeVM { + return fmt.Errorf("--probe-vm cannot resume a real session") + } if resuming { loaded, err := session.Load(strings.TrimSpace(*resumeID)) if err != nil { @@ -97,18 +109,34 @@ func run() error { if err != nil { return err } - sourceDir, data, err := repository.ArchiveDirectory(wd) + created, err := session.Create(wd) + if err != nil { + return err + } + snap, err := repository.OpenForSessionExcluding(wd, filepath.Join(created.Dir, "host-tree"), config.Dir()) if err != nil { + _ = os.RemoveAll(created.Dir) return err } - archive = data - created, err := session.Create(sourceDir) + archive, err = repository.ArchiveHEAD(snap.Root) if err != nil { + _ = os.RemoveAll(created.Dir) + return err + } + created.SourceDir = snap.HostSource + created.RepoRoot = snap.HostSource + created.HEAD = snap.HEAD + if err := created.WriteMeta(); err != nil { + _ = os.RemoveAll(created.Dir) return err } sess = created + if snap.Ephemeral { + fmt.Fprintln(os.Stderr, "abox: worktree has local changes; using a private Git snapshot") + } fmt.Fprintf(os.Stderr, "abox: created session %s\n", sess.ID) } + defer sess.ReleaseRuntimeLock() var sb *runtime.Sandbox var broker *hostbroker.Broker @@ -117,17 +145,32 @@ func run() error { if image == "" { image = config.GuestImagePath() } - if err := runtime.Prepare(sess, image, sel, resuming); err != nil { + if err := ctx.Err(); err != nil { + return err + } + var prepareErr error + if *probeVM { + prepareErr = runtime.PrepareProbe(sess, image, sel) + } else { + prepareErr = runtime.Prepare(sess, image, sel, resuming) + } + if err := ctx.Err(); err != nil { + return err + } + if prepareErr != nil { if execMode { - return err + return prepareErr } - fmt.Fprintf(os.Stderr, "abox: vm prepare: %v\n", err) + fmt.Fprintf(os.Stderr, "abox: vm prepare: %v\n", prepareErr) vmState = "unavailable" } else { - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() + if err := ctx.Err(); err != nil { + return err + } + bootCtx, cancel := context.WithTimeout(ctx, 45*time.Second) vcpu, ram := cfg.Resources.Resolved() - started, err := runtime.Start(ctx, sess, cfg.Runtime.VMMPath, vcpu, ram) + started, err := runtime.Start(bootCtx, sess, cfg.Runtime.VMMPath, vcpu, ram) + cancel() if err != nil { if execMode && *prompt != "" { return fmt.Errorf("start vm: %w", err) @@ -152,20 +195,34 @@ func run() error { sb = started vmState = "ready" defer sb.Stop() + if *probeVM { + if err := sb.TransferArchive(ctx, archive); err != nil { + return fmt.Errorf("source transfer: %w", err) + } + var res protocol.ListFilesResult + if err := sb.Call(ctx, "list_files", protocol.ListFilesParams{Path: ".", Depth: 4, Limit: 50}, &res); err != nil { + return fmt.Errorf("guest list_files: %w", err) + } + fmt.Println("guest ready; files:") + for _, p := range res.Paths { + fmt.Println(p) + } + return nil + } broker, err = brokerForMode(cfg, sel, resolver, execMode) if err != nil { return err } defer broker.Close() sb.SetGuestCallHandler(broker) - if err := pushSecrets(sb, cfg, resolver, sel); err != nil { + if err := pushSecrets(ctx, sb, cfg, resolver, sel); err != nil { if execMode { return err } fmt.Fprintf(os.Stderr, "abox: %v\n", err) } if !resuming { - if err := sb.TransferArchive(context.Background(), archive); err != nil { + if err := sb.TransferArchive(ctx, archive); err != nil { return fmt.Errorf("source transfer: %w", err) } } @@ -173,30 +230,19 @@ func run() error { } if *probeVM { - if sb == nil { - return fmt.Errorf("vm not ready (%s)", vmState) - } - var res protocol.ListFilesResult - if err := sb.Call(context.Background(), "list_files", protocol.ListFilesParams{Path: ".", Depth: 4, Limit: 50}, &res); err != nil { - return fmt.Errorf("guest list_files: %w", err) - } - fmt.Println("guest ready; files:") - for _, p := range res.Paths { - fmt.Println(p) - } - return nil + return fmt.Errorf("vm not ready (%s)", vmState) } if execMode { - return runExec(sb, *prompt) + return runExec(ctx, sb, *prompt) } var transcript []string if resuming { - transcript = resumeLog(sess, sb) + transcript = resumeLog(ctx, sess, sb) if len(transcript) > 0 { _ = session.WriteTranscript(sess.TranscriptPath(), transcript) } } - return tui.Run(cfg, sel, sb, broker, vmState, transcript, resolver, sess.TranscriptPath()) + return runTUI(ctx, cfg, sel, sb, broker, vmState, transcript, resolver, sess.TranscriptPath()) } // Headless logs stream lifecycle; the TUI stays quiet so logs never paint into the UI. @@ -211,11 +257,11 @@ func brokerForMode(cfg config.File, sel config.Model, resolver *credsource.Resol return b, nil } -func pushSecrets(sb *runtime.Sandbox, cfg config.File, resolver *credsource.Resolver, sel config.Model) error { +func pushSecrets(parent context.Context, sb *runtime.Sandbox, cfg config.File, resolver *credsource.Resolver, sel config.Model) error { if sb.GuestProtocol >= 3 { return nil } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(parent, 30*time.Second) defer cancel() secrets, resolveErr := credsource.ResolveSelected(ctx, resolver, cfg, sel) pushErr := sb.PushSecrets(ctx, sel, secrets) @@ -240,7 +286,7 @@ func scrubLegacySessions() error { return nil } -func resumeLog(sess *session.Session, sb *runtime.Sandbox) []string { +func resumeLog(parent context.Context, sess *session.Session, sb *runtime.Sandbox) []string { if lines, err := session.ReadTranscript(sess.TranscriptPath()); err == nil && len(lines) > 0 { return lines } @@ -250,7 +296,7 @@ func resumeLog(sess *session.Session, sb *runtime.Sandbox) []string { if len(sb.History) > 0 { return tui.LogFromHistory(sb.History) } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(parent, 10*time.Second) defer cancel() var got protocol.GetContextResult if err := sb.Call(ctx, "get_context", struct{}{}, &got); err == nil && len(got.History) > 0 { @@ -267,15 +313,13 @@ func resumeLog(sess *session.Session, sb *runtime.Sandbox) []string { return tui.LogFromHistory(hist) } -func runExec(sb *runtime.Sandbox, prompt string) error { +func runExec(ctx context.Context, sb *runtime.Sandbox, prompt string) error { if prompt == "" { return fmt.Errorf("abox exec requires --prompt") } if sb == nil { return fmt.Errorf("agent runs only in the microVM") } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() enc := json.NewEncoder(os.Stdout) _, err := sb.UserTurnCtx(ctx, prompt, runtime.TurnOptions{RichEvents: true}, func(e protocol.AgentEvent) { _ = enc.Encode(e) @@ -283,7 +327,11 @@ func runExec(sb *runtime.Sandbox, prompt string) error { return err } -func runMCP(args []string) error { +func runTUI(ctx context.Context, cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbroker.Broker, vmState string, transcript []string, resolver *credsource.Resolver, transcriptPath string) error { + return tui.Run(ctx, cfg, sel, sb, broker, vmState, transcript, resolver, transcriptPath) +} + +func runMCP(ctx context.Context, args []string) error { if len(args) == 0 { return fmt.Errorf("usage: abox mcp add --mode [--credential-env NAME] \n abox mcp login ") } @@ -294,7 +342,7 @@ func runMCP(args []string) error { if len(args) < 2 { return fmt.Errorf("usage: abox mcp login ") } - return mcpLogin(args[1]) + return mcpLogin(ctx, args[1]) default: return fmt.Errorf("unknown mcp command %q\nusage: abox mcp add --mode ", args[0]) } @@ -333,7 +381,7 @@ func mcpAdd(args []string) error { return nil } -func mcpLogin(name string) error { +func mcpLogin(ctx context.Context, name string) error { cfg, _, err := config.Load() if err != nil { return err @@ -341,5 +389,5 @@ func mcpLogin(name string) error { if err := credentials.ApplyToEnv(); err != nil { return err } - return mcpauth.LoginNamed(context.Background(), cfg, name) + return mcpauth.LoginNamed(ctx, cfg, name) } diff --git a/cmd/abox/signals_linux.go b/cmd/abox/signals_linux.go new file mode 100644 index 0000000..af1a849 --- /dev/null +++ b/cmd/abox/signals_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package main + +import ( + "os" + "syscall" +) + +func terminationSignals() []os.Signal { + return []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP} +} diff --git a/cmd/abox/signals_linux_test.go b/cmd/abox/signals_linux_test.go new file mode 100644 index 0000000..3b83102 --- /dev/null +++ b/cmd/abox/signals_linux_test.go @@ -0,0 +1,22 @@ +//go:build linux + +package main + +import ( + "os" + "syscall" + "testing" +) + +func TestTerminationSignalsLinux(t *testing.T) { + got := terminationSignals() + want := []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP} + if len(got) != len(want) { + t.Fatalf("signals = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("signals = %v, want %v", got, want) + } + } +} diff --git a/cmd/abox/signals_other.go b/cmd/abox/signals_other.go new file mode 100644 index 0000000..d2f05a1 --- /dev/null +++ b/cmd/abox/signals_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "os" + +func terminationSignals() []os.Signal { + return []os.Signal{os.Interrupt} +} diff --git a/docs/api.md b/docs/api.md index 577e2ab..7c3890a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -39,12 +39,15 @@ Always `defer sess.Close()`. `Close` stops the VM and the host broker. | --- | --- | --- | | `RepoPath` | `string` | cwd; exact source directory snapshotted by `Open` | | `Model` | `string` | first profile in `config.yaml` | -| `Image` | `string` | config / `~/.abox/images/abox-guest.raw` | +| `Image` | `string` | config / `~/.abox/images/abox-guest-linux-.raw` | | `VMMPath` | `string` | config or `abox-vmm` on `PATH` | | `VCPU`, `RAMMiB` | `int` | `0` = config resolved (1 / 768) | | `BootTimeout` | `time.Duration` | 45s | Home directory is `~/.abox` unless `ABOX_HOME` is set. +Every image requires an adjacent `.manifest.json`; architecture, protocol, and +digest are checked before the session disk is accepted. Linux rejects legacy +metadata-free images and sessions. ## Session diff --git a/docs/approvals.md b/docs/approvals.md index d551325..5154e53 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -14,6 +14,11 @@ permalink: /approvals/ Model-authored `run_command` does not run until the host says so. Default is **deny**. Protocol 4 is required. +An allowed command runs as the unprivileged guest user (UID/GID 1000), not as +the root-owned guest agent. Its dedicated process group is terminated when the +command returns or is canceled, so `allow_once` cannot leave a background shell +or replace `/usr/local/bin/abox-guest` for a later resume. + MCP tools, `apply_patch`, and the read-only builtins do not prompt. Host import of a guest patch is not built yet. diff --git a/docs/cli.md b/docs/cli.md index 4792db0..d01e72b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,7 +23,7 @@ abox --probe-vm # boot + list_files; no model call abox exec --prompt "…" # headless JSON events on stdout abox mcp add --mode [--credential-env NAME] abox mcp login -abox creds migrate # credentials.env → macOS keychain +abox creds migrate # credentials.env -> OS keystore ``` `--exec` is an alias of the `exec` subcommand. `exec` requires `--prompt`. @@ -77,9 +77,11 @@ Footer hints change with the mode. `/help` lists slash commands. | `/help` | Print the list above into the transcript | `/provider` and `/mcp` save the secret with -[SavePreferred]({{ '/credentials' | relative_url }}#where-keys-live): -macOS keychain first, `~/.abox/credentials.env` if the keychain is locked. -OAuth for MCP is `abox mcp login`, not the TUI. +[SavePreferred]({{ '/credentials' | relative_url }}#where-keys-live): OS +keystore first (macOS Keychain or Linux Secret Service), then a warned +`~/.abox/credentials.env` fallback if the keystore is unavailable or locked. +OAuth for MCP is `abox mcp login`, not the TUI. Linux uses `xdg-open`; if it is +missing or fails, ABox prints the authorization URL for manual use. `/credential` writes a **reference** into `config.yaml`. It does not store cloud tokens. The host must already be able to talk to that backend. @@ -115,9 +117,12 @@ runtime: isolation: microvm backend: libkrun network: deny-by-default - # image: /path/to/abox-guest.raw + # image: /path/to/abox-guest-linux-amd64.raw # vmm_path: /path/to/abox-vmm ``` SDK `Options.VCPU` / `RAMMiB` / `Image` / `VMMPath` override the file for that process. + +Custom images require an adjacent `.manifest.json` with a matching +guest architecture, protocol, and SHA-256. diff --git a/docs/concepts.md b/docs/concepts.md index feb2ce8..596ff4a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -16,7 +16,7 @@ permalink: /concepts/ | | Host (your process) | Guest (`abox-guest`) | | --- | --- | --- | | Runs | `pkg/abox` or `abox`, `abox-vmm`, LLM broker, MCP broker | Agent loop, five tools | -| Sees | Session dir on the Mac, `config.yaml`, credentials | `/work/repo` inside the VM | +| Sees | Session dir on the host, `config.yaml`, credentials | `/work/repo` inside the VM | | HTTPS | Provider and MCP Streamable HTTP | None. No guest NIC, no TSI inet | | Must not | Execute model-authored commands itself | Import `internal/tui`, `internal/provider`, or `cmd/abox` | @@ -25,20 +25,22 @@ vsock and renders `agent_event` frames. When the guest needs a model or an MCP tool, it names a configured alias; the host broker dials the network. {: .important } -Isolation claims stay **Planned**. The device plan is vsock-only (`krun_add_vsock` -with flags 0), no guest NIC, no host-path virtio-fs. That is an intended -allowlist, not a passed hardware suite. +Isolation claims stay **Planned**. The device plan is vsock-only +(`krun_add_vsock` with flags 0), no guest NIC, no host-path virtio-fs. That is +an intended allowlist, not a passed hardware suite. Linux/KVM needs independent +Phase 0.5 and Phase 18 evidence on both pinned distributions; macOS evidence +does not cover it. ## Kernel vs disk The `.raw` file is **only a disk**: ext4 userspace (Alpine, `git`, `patch`, `abox-guest`). No kernel, no bootloader. -The guest kernel is **libkrunfw** (Homebrew). `abox-vmm` attaches host files -as virtio-blk: +The guest kernel is **libkrunfw** (Homebrew on macOS or the pinned distro +package on Linux). `abox-vmm` attaches host files as virtio-blk: ```text -host: ~/.abox/sessions//root.raw Mac file (ext4) +host: ~/.abox/sessions//root.raw host file (ext4) ↓ libkrun virtio-blk guest: /dev/vda → / Alpine + abox-guest + /work/repo @@ -50,7 +52,7 @@ guest: /dev/vdb session id + model alias ## Three files -1. **Golden image** — `~/.abox/images/abox-guest.raw`. Packed once (`make image`). Template. Not attached to a running VM. +1. **Golden image** — `~/.abox/images/abox-guest-linux-.raw`. Packed once (`make image`). Template plus adjacent manifest. Not attached to a running VM. 2. **Session disk** — `~/.abox/sessions//root.raw`. Clone of (1). This is `/dev/vda`. Destroy the session dir and this disk is gone; the golden stays. 3. **Config disk** — `sessions//config.raw`. ~1 MiB, read-only `/dev/vdb`. Not cloned from the golden image. Model profile only; credentials stay on the host. @@ -58,6 +60,11 @@ guest: /dev/vdb session id + model alias rewrites (3) without secrets. Startup also scrubs leftover plaintext keys out of old `config.raw` / `guest-config.json` files. +The image manifest records schema, architecture, image ID, protocol, and +SHA-256. `Open` rejects an architecture/protocol mismatch or changed image. +Linux also rejects metadata-free legacy images and sessions rather than +guessing their architecture. + A guest that still finds `secrets` or `mcp_servers` in that config refuses to boot. Rebuild the image and start a new session. @@ -114,13 +121,11 @@ not model tool calls, and do not go through that gate. ## Source snapshot -`Open` snapshots exactly the configured source directory into the guest. It -does not discover a Git root, inspect branches or `HEAD`, or require Git on the -host. `.git` files and directories are excluded, and symlinks and special files -are rejected. The guest creates its own private Git baseline after transfer so -patch export remains available. `Resume(id)` boots the existing disk and does -not recopy the host source directory. - -Git ignore rules do not control this snapshot. All regular files and dotfiles -other than `.git` metadata are included, so the selected source directory must -contain only files the guest is allowed to read. +`Open` discovers the enclosing Git worktree and snapshots its repository root. +A clean worktree archives committed `HEAD`. A dirty or commitless worktree is +copied into a private host-side repository using tracked files and non-ignored +untracked files, then archived from a private commit. This preserves local +changes without modifying host Git while omitting ignored caches, build output, +and local secrets. `.git` metadata and the active ABox state directory are not +copied; symlinks and special files are rejected. `Resume(id)` boots the existing +disk and does not recopy the host repository. diff --git a/docs/credentials.md b/docs/credentials.md index 3e8a72c..99f1e94 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -23,7 +23,7 @@ plaintext secrets out of old session files. | Source | `name` is | Auth | | --- | --- | --- | | `env` | environment variable (also reads `~/.abox/credentials.env`) | — | -| `keychain` | macOS keychain account (service `abox`) | — | +| `keystore` | macOS Keychain or Linux Secret Service account (service `abox`) | Linux: `secret-tool`, `gdbus`, session bus/provider | | `vault` | Vault KV v2 path (`secret/abox/anthropic`) | `VAULT_ADDR` + `VAULT_TOKEN` (or `~/.vault-token`) | | `azure` | Key Vault secret URI | `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_CLIENT_SECRET`, or `az login` | | `aws` | Secrets Manager secret id | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (`AWS_REGION`), or `~/.aws/credentials` | @@ -34,8 +34,8 @@ models: provider: anthropic model: claude-sonnet-4-20250514 credential: - source: keychain # env | keychain | vault | azure | aws - name: ANTHROPIC_API_KEY # env var, keychain account, vault path, Azure URI, or AWS id + source: keystore # env | keystore | vault | azure | aws + name: ANTHROPIC_API_KEY # env var, OS-keystore account, vault path, Azure URI, or AWS id # field: value # vault/aws only # version: "4" # vault/azure only base_url: https://api.anthropic.com @@ -51,13 +51,29 @@ Each destination env name must be unique across models and MCP servers. `/provider` and `/mcp` in the TUI, and `abox mcp login`, call `SavePreferred`: -1. macOS keychain, service `abox`, account = env name -2. If the keychain is locked or missing: `~/.abox/credentials.env` (mode 0600) +1. OS keystore, service `abox`, account = env name +2. If unavailable, locked, or timed out: warned plaintext fallback at + `~/.abox/credentials.env` (mode 0600) ```bash -abox creds migrate # move existing credentials.env entries into the keychain +abox creds migrate # move existing credentials.env entries into the OS keystore ``` +On macOS, `keystore` uses Keychain through `security(1)`. On Linux, it uses +Secret Service through `secret-tool` and a bounded `gdbus` availability/item +probe. GNOME Keyring, KWallet (`org.kde.secretservicecompat`), and KeePassXC +can provide that service. Values are sent to `secret-tool store` on stdin with +no trailing newline and never placed in argv. + +If there is no session bus/provider, an item is locked, the provider disappears, +or an unlock prompt exceeds the timeout, ABox warns and uses the 0600 file +fallback. `keychain` and `secretservice` are accepted input aliases for existing +config; saved config canonicalizes them to `keystore`. + +For headless Linux, prefer `vault`, `azure`, or `aws` when plaintext fallback is +not acceptable. These cloud sources are cross-platform and do not depend on +Secret Service. + Refresh-token leftovers (`*_REFRESH`) are dropped during migrate. Re-login when an MCP access token expires; ABox does not persist refresh tokens. diff --git a/docs/examples.md b/docs/examples.md index 66f9987..a8655b6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -28,8 +28,9 @@ sess, err := abox.Open(ctx, abox.Options{}) Each child page has the matching `abox` CLI (or `config.yaml`) and a sample `main` that calls one SDK method. Copy the Go into your program. `abox-vmm` -on `PATH` (the `darwin_arm64` archive on the -[GitHub release](https://github.com/AdminTurnedDevOps/ABox/releases)). Golden +must be on `PATH` (the current runnable release path is `darwin_arm64`; Linux +release support remains gated) from the +[GitHub release](https://github.com/AdminTurnedDevOps/ABox/releases). Golden image and provider key: [Quickstart]({{ '/quickstart' | relative_url }}). Probe methods do not need a key. A successful `Open` speaks protocol 4. diff --git a/docs/examples/cancel.md b/docs/examples/cancel.md index ad609cc..c0e96b4 100644 --- a/docs/examples/cancel.md +++ b/docs/examples/cancel.md @@ -23,6 +23,9 @@ abox exec --prompt "Count slowly from 1 to 50 in words." ``` In the TUI, Ctrl+C quits (and denies an in-flight approval first). +On Linux, top-level SIGTERM and SIGHUP also cancel and return through bounded VM +cleanup; the Linux helper fallback is SIGTERM. macOS retains its interrupt +fallback. `MaxTurns` and a turn `Timeout` are [SDK-only](#sdk) ([Turn options]({{ '/examples/turn-opts' | relative_url }})). diff --git a/docs/examples/custom-vm.md b/docs/examples/custom-vm.md index ac058a3..a738fbd 100644 --- a/docs/examples/custom-vm.md +++ b/docs/examples/custom-vm.md @@ -25,7 +25,7 @@ runtime: isolation: microvm backend: libkrun network: deny-by-default - # image: /path/to/abox-guest.raw + # image: /path/to/abox-guest-linux-amd64.raw # vmm_path: /path/to/abox-vmm ``` @@ -37,6 +37,8 @@ abox --probe-vm CLI boot timeout is 45s (not a flag). SDK `Options.BootTimeout` / `VCPU` / `RAMMiB` / `Image` / `VMMPath` override the file for that process. +The image needs an adjacent `.manifest.json` matching its architecture, +protocol, and SHA-256. ## SDK diff --git a/docs/examples/mcp-tokens.md b/docs/examples/mcp-tokens.md index e8b73a5..5fd7ea3 100644 --- a/docs/examples/mcp-tokens.md +++ b/docs/examples/mcp-tokens.md @@ -10,7 +10,7 @@ permalink: /examples/mcp-tokens/ `Session.SetMCPTokens` on the one SDK, [`pkg/abox`]({{ '/api' | relative_url }}). -Overrides a Bearer token on the **host** MCP broker. Configure servers with `abox mcp add` first. `Open` already resolves tokens from keychain / `credentials.env` / cloud sources; this example is the live override path. Keys are destination env names (`GITHUB_MCP_TOKEN`, or `ABOX_MCP__TOKEN`). +Overrides a Bearer token on the **host** MCP broker. Configure servers with `abox mcp add` first. `Open` already resolves tokens from the OS keystore / `credentials.env` / cloud sources; this example is the live override path. Keys are destination env names (`GITHUB_MCP_TOKEN`, or `ABOX_MCP__TOKEN`). ## CLI diff --git a/docs/index.md b/docs/index.md index 2e5047b..095e606 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,9 +39,10 @@ _, err = sess.Turn(ctx, "What does this repo do?", func(ev abox.Event) { ``` {: .important } -Isolation claims stay **Planned** until the hardware suite in `PLAN.md` passes. -The SDK boots the same microVM as the CLI. A current guest speaks **protocol 4**. -`Open` / `Resume` reject older disks. +Isolation claims stay **Planned** until the platform-specific Phase 0.5 and +Phase 18 hardware suites in `PLAN.md` pass. The SDK boots the same microVM as +the CLI. A current guest speaks **protocol 4**. `Open` / `Resume` reject older +disks. ## What the SDK does @@ -59,8 +60,10 @@ The SDK boots the same microVM as the CLI. A current guest speaks **protocol 4** - No host-side tool loop. Tools execute in the guest only. - No `write_file`. Edits go through `apply_patch`. -- No Docker on the session path. Docker (today) only packs the golden `.raw`. -- No Linux/Windows VMM yet. Apple Silicon + libkrun. +- No Docker on the session path. macOS uses Docker only to pack the golden + `.raw`; Linux has a rootless native image builder. +- The Linux/KVM host path is implemented but runtime/release support remains + Planned pending the pinned Arch/Fedora hardware gate. Windows is unsupported. - No guest NIC and no TSI inet. LLM and MCP HTTPS are host-brokered. - No MCP tool approval yet. Only `run_command` prompts. @@ -72,7 +75,7 @@ your process (pkg/abox or abox) → host MCP broker (Streamable HTTP; tokens stay here) → abox-vmm → libkrun + libkrunfw ← Linux kernel (not on the disk) - → Hypervisor.framework + → Hypervisor.framework (macOS) / KVM (Linux, Planned support) → vsock only (krun_add_vsock flags 0) guest: /dev/vda = session root.raw (ext4 userspace) /dev/vdb = config.raw (session id + model alias; no secrets) @@ -82,7 +85,8 @@ your process (pkg/abox or abox) ## Next 1. [Quickstart]({{ '/quickstart' | relative_url }}) — install, key, first turn -2. [Concepts]({{ '/concepts' | relative_url }}) — host vs guest, protocol 4 -3. [CLI and TUI]({{ '/cli' | relative_url }}) — `abox`, slash commands, keys -4. [API]({{ '/api' | relative_url }}) — `Open`, `Turn`, `SetApprover`, … -5. [Troubleshooting]({{ '/troubleshooting' | relative_url }}) +2. [Platforms]({{ '/platforms' | relative_url }}) — build baselines and support gates +3. [Concepts]({{ '/concepts' | relative_url }}) — host vs guest, protocol 4 +4. [CLI and TUI]({{ '/cli' | relative_url }}) — `abox`, slash commands, keys +5. [API]({{ '/api' | relative_url }}) — `Open`, `Turn`, `SetApprover`, … +6. [Troubleshooting]({{ '/troubleshooting' | relative_url }}) diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..a6c5c29 --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,156 @@ +--- +layout: default +title: Platforms +nav_order: 14 +permalink: /platforms/ +--- + +# Platforms +{: .no_toc } + +1. TOC +{:toc} + +## Support status + +The macOS/Apple Silicon runtime is implemented and runnable. The Linux host +port, KVM binding, rootless image builder, lifecycle handling, and Secret +Service integration are implemented, but Linux VMM execution and Linux/KVM +isolation remain **Planned** until separate Phase 0.5 and Phase 18 hardware +evidence passes on both pinned x86_64 Linux baselines. A successful build or VM +boot is not that evidence. + +| Environment | Build | `make image` | VMM execution | +| --- | --- | --- | --- | +| macOS/arm64 | Implemented | Docker packer | Implemented; isolation evidence still Planned | +| Arch Linux x86_64 baseline | Implemented | Rootless native builder | Planned pending Phase 0.5/18 evidence | +| Fedora 44 x86_64 baseline | Implemented | Rootless native builder | Planned pending Phase 0.5/18 evidence | +| Linux/arm64 | Host and guest builds are parameterized | Rootless native builder | Not a supported runtime/release target yet | +| Debian/Ubuntu | Source build path only, untested | Rootless native builder when dependencies are available | Unsupported | +| Linux container or WSL2 | Compile and image-build use only | Supported as a build job when unprivileged tools work | Unsupported, even when `/dev/kvm` is visible | +| Windows | Unsupported | Unsupported | Unsupported | + +Running ABox in a conventional VM also requires explicitly exposed nested KVM +and the same hardware acceptance suite. It is not covered merely because +`/dev/kvm` exists. + +## Linux build baselines + +The documented, pinned x86_64 package baselines are: + +| Distribution | Repository baseline | libkrun | libkrunfw | ABI | +| --- | --- | --- | --- | --- | +| Arch Linux | 2026-09-17 Arch Linux Archive snapshot | `1.19.4-1` | `5.5.0-1` | `libkrun.so.1`, firmware loaded as `libkrunfw.so.5` by this package | +| Fedora | Fedora 44 updates | `libkrun-1.19.0-1.fc44`, `libkrun-devel-1.19.0-1.fc44` | `5.5.0-1.fc44` | `libkrun.so.1`, `libkrunfw.so.5` | + +Configure Arch to use the dated archive before installing packages; a moving +mirror is not the pinned baseline. + +```bash +# Arch Linux, after selecting the 2026-09-17 archive snapshot +sudo pacman -S --needed base-devel git curl ca-certificates pkgconf \ + libkrun=1.19.4-1 libkrunfw=5.5.0-1 fakeroot e2fsprogs zstd python \ + shadow util-linux binutils file + +# Fedora 44 +sudo dnf install git curl ca-certificates gcc gcc-c++ make pkgconf-pkg-config \ + fakeroot e2fsprogs zstd python3 shadow-utils \ + util-linux binutils file tar gzip findutils diffutils +sudo sh scripts/install-fedora-libkrun.sh # verifies exact RPM SHA-256 and signatures +``` + +Both builds require Go 1.25+ and cgo. Linux links libkrun through +`pkg-config`; the accepted build API range is libkrun 1.19.x. libkrun loads its +matching firmware library at runtime, so a successful `pkg-config` or link +check does not prove that libkrunfw is discoverable. + +Arch and Fedora containers are valid build-only environments for compilation, +cgo/pkg-config checks, and rootless image creation. They are not supported VMM +runtimes. Generic Ubuntu CI can test pure Go code and the `CGO_ENABLED=0` +diagnostic VMM stub, but does not establish Linux VMM compatibility. + +The release workflow can build a Linux amd64 candidate and is wired to require +distinct protected Arch and Fedora KVM evidence before publication. Workflow +plumbing, an acceptance manifest, or an unexecuted gate is not passing evidence; +no checked-in report currently opens the Linux support gate. +Runner provisioning must pin `ABOX_KVM_ACCEPTANCE_SHA256` to the reviewed +`.github/acceptance/linux-kvm-v1.json` bytes as well as the per-distro runner +identity and root-owned harness digest variables. + +Debian and Ubuntu do not provide the pinned package pair used by this project. +Their source-build route is explicitly untested: build checksum-pinned +libkrunfw 5.5.0 first, then libkrun 1.19.4 with block support +(`make BLK=1`), configure `/usr/local/lib64` for the runtime loader and +`/usr/local/lib64/pkgconfig` for pkg-config, and run `ldconfig`. Do not treat a +featureless `make && make install` as an ABox-compatible build. + +## Linux image builder + +On Linux, `make image` runs as an ordinary user and requires no Docker daemon, +root, loop mount, or privileged container. In addition to the compiler +requirements above, it needs: + +- `fakeroot` +- e2fsprogs 1.43 or newer (`mke2fs -d`, `e2fsck`, and `debugfs`) +- `curl` or `wget` with HTTPS +- `sha256sum`, `tar`, `od`, `awk`, and `flock` +- at least 896 MiB free in the image output directory + +The builder downloads checksum-pinned `apk.static` and Alpine keys, installs a +pinned package closure, checks ext4 ownership/modes and the guest ELF machine, +and publishes an immutable generation through one atomic current-image symlink. + +For optional desktop keystore integration, install `libsecret` (for +`secret-tool`) and GLib (for `gdbus`), then provide GNOME Keyring, KWallet's +Secret Service compatibility service, or KeePassXC on the session bus. + +## Images and manifests + +Image names include the guest architecture: + +```text +~/.abox/images/abox-guest-linux-amd64.raw +~/.abox/images/abox-guest-linux-arm64.raw +``` + +`make image GUEST_ARCH=amd64` or `make image GUEST_ARCH=arm64` selects the +guest architecture. The default is the host `GOARCH`. Each resolved image has +an adjacent `.manifest.json` containing schema, architecture, image ID, +guest protocol, and SHA-256. A custom `runtime.image` or SDK `Options.Image` +also requires that adjacent manifest. + +Development images use image ID `abox-guest-dev`. Release builds set `IMAGE_ID` +to the release tag and inject that same value into the guest binary and manifest; +custom builders must preserve that identity match. + +New sessions verify the manifest architecture/protocol and the cloned image +digest before boot. Session metadata records the image identity, guest +architecture/protocol, and backend (`hvf` or `kvm`). Linux refuses old +metadata-free images and sessions. macOS/arm64 alone retains a narrow legacy +fallback for the former `abox-guest.raw` image and old arm64 sessions. + +## Linux runtime prerequisites + +These are prerequisites for development probes, not a declaration of supported +Linux VMM execution: + +- Intel VT-x or AMD-V enabled in firmware +- `kvm_intel` or `kvm_amd` loaded +- read/write access to `/dev/kvm` +- the pinned libkrun/libkrunfw pair on the runtime loader path +- x86_64 host hardware for the initial acceptance target + +The Fedora acceptance run must keep SELinux enforcing and record any AVCs; it +must not disable SELinux to make the VMM run. + +WSL2 and containerized VMM execution are unsupported. Do not work around that +policy by passing `/dev/kvm`, weakening seccomp, or adding privileges. Use a +native host for hardware evidence. + +## Evidence gate + +Linux/KVM has its own Phase 0.5 boot/device/network record and Phase 18 security +acceptance record. macOS Hypervisor.framework results cannot be reused for KVM, +and Arch results cannot be reused for Fedora. Until both pinned Linux baselines +pass against the exact release artifacts, Linux runtime/release support and all +Linux isolation claims stay **Planned**. diff --git a/docs/quickstart.md b/docs/quickstart.md index 28ea0c0..2966aab 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,10 +13,11 @@ permalink: /quickstart/ ## Prerequisites -- Apple Silicon Mac (`kern.hv_support` = 1) - Go 1.25+ -- Docker once, to pack the golden root filesystem (`make image`) -- Homebrew libkrun + libkrunfw + +The currently runnable host path requires an Apple Silicon Mac with +`kern.hv_support = 1`, Homebrew libkrun/libkrunfw, and Docker to pack the guest +root filesystem. Docker is never on the session execution path. ```bash brew tap libkrun/krun @@ -24,7 +25,14 @@ brew trust libkrun/krun brew install libkrun libkrunfw ``` -From a clone of ABox: +The Linux host/build port is implemented for amd64 and arm64, and Linux image +creation is rootless and Docker-free. Linux VMM execution, release support, and +KVM isolation remain **Planned** until separate Phase 0.5/18 evidence passes on +the pinned Arch and Fedora x86_64 baselines. WSL2 and containerized VMM +execution are unsupported. See [Platforms]({{ '/platforms' | relative_url }}) +for package versions, build dependencies, and the exact boundary. + +For the currently runnable macOS setup, from a clone of ABox: ```bash make build @@ -38,18 +46,21 @@ Put a provider key on the **host** (the guest never receives it). In the TUI: /provider ``` -That saves to the macOS keychain first, then `~/.abox/credentials.env` (mode -`0600`) if the keychain is locked. Cloud stores (Vault, Azure Key Vault, AWS -Secrets Manager) use [`/credential`]({{ '/credentials' | relative_url }}). +That saves to the OS keystore first: macOS Keychain or Linux Secret Service. +If it is locked, absent, or times out, ABox warns and saves to +`~/.abox/credentials.env` (mode `0600`). Cloud stores (Vault, Azure Key Vault, +AWS Secrets Manager) use [`/credential`]({{ '/credentials' | relative_url }}). You can also write `~/.abox/credentials.env` yourself with `XAI_API_KEY=…` (or OpenAI / Anthropic). ## First program -One import: `github.com/AdminTurnedDevOps/ABox/pkg/abox`. Work from any -directory; the SDK snapshots exactly that directory into the guest without -requiring or inspecting host Git. +One import: `github.com/AdminTurnedDevOps/ABox/pkg/abox`. Set `RepoPath` to any +path inside a Git worktree. The SDK discovers the repository root and snapshots +committed files plus any tracked modifications and non-ignored untracked files. +Git-ignored files, `.git` metadata, the active ABox state directory, symlinks, +and special files are excluded. ```bash go get github.com/AdminTurnedDevOps/ABox@latest @@ -123,4 +134,5 @@ MCP login. - [Concepts]({{ '/concepts' | relative_url }}) — host brokers, secretless config - [Examples]({{ '/examples' | relative_url }}) — resume, cancel, probe, approvals, … -- [Troubleshooting]({{ '/troubleshooting' | relative_url }}) — missing image, old guest, codesign +- [Platforms]({{ '/platforms' | relative_url }}) — host support, Linux builds, images +- [Troubleshooting]({{ '/troubleshooting' | relative_url }}) — images, loaders, KVM, codesign diff --git a/docs/sessions.md b/docs/sessions.md index b6c0c1f..ea8b66c 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -19,7 +19,7 @@ the home): | `root.raw` | Writable VM disk (`/dev/vda`) | | `config.raw` | Sealed config (`/dev/vdb`): session id + model alias. **No secrets** | | `guest-config.json` | Host-side copy of that config, also secretless | -| `session.json` | Host metadata (id, source directory, created) | +| `session.json` | Host metadata: id/source, image identity, guest architecture/protocol, VMM backend | | `transcript.json` | CLI TUI log (SDK does not write this) | | `console.log` | Guest serial | | `rpc.sock` | Host vsock proxy | @@ -28,6 +28,11 @@ the home): required; resume does not infer session identity from a directory or Git state. The host source directory is not copied on resume. +Only one supervisor may own a session at a time. A per-session runtime lock +rejects concurrent resume before config or disk preparation. The root-owned +guest agent remains protected from model commands because shell and Git tool +subprocesses run as the unprivileged guest repository owner. + On every start, ABox scrubs leftover plaintext secrets out of `config.raw` and `guest-config.json` (including leftover sessions under the old `~/Library/Application Support/ABox` path). It never deletes sessions. @@ -37,11 +42,18 @@ and `guest-config.json` (including leftover sessions under the old ```text Open → snapshot source directory → clone golden → boot → transfer into /work/repo Turn → user_turn / agent_event (repeat); host brokers HTTPS -Close → shutdown RPC, SIGINT abox-vmm +Close → shutdown RPC, platform helper signal, bounded kill, Wait ``` Always `defer sess.Close()`. Leaking a session leaves a VM and a disk. +On macOS, the helper fallback signal is interrupt (`SIGINT`). On Linux, it is +`SIGTERM`, never `SIGINT`; if the helper does not exit within the bound, ABox +uses `SIGKILL` and always waits/reaps it. The Linux CLI handles top-level +`SIGINT`, `SIGTERM`, and `SIGHUP` through the same cleanup path. A second +termination signal restores the operating system's default forced-exit behavior. +The inherited liveness pipe also makes `abox-vmm` exit if its supervisor dies. + Guest conversation state lives on the session disk at `/var/lib/abox/context.json`. Resume reloads it. The TUI also keeps `transcript.json` on the host. @@ -55,5 +67,10 @@ Guest conversation state lives on the session disk at | Source files | Fresh snapshot of exact configured directory | Guest files already on disk | | Config disk | Secretless, current model | Rewritten secretless; old keys stripped | +Resume also checks that the recorded guest architecture, protocol, and VMM +backend match the current host. Linux rejects sessions that predate this +metadata; macOS/arm64 may backfill the narrow legacy format because arm64/HVF +was the only previously runnable combination. + To pick up a new `abox-guest` (protocol 4), `make image-update` then **Open**, not Resume of an old id. Resume of a pre-v4 disk returns `ErrGuestTooOld`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3fb0436..34aab15 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -11,15 +11,37 @@ permalink: /troubleshooting/ 1. TOC {:toc} -## `guest image missing … (run: make image)` +## `guest image missing ... (run: make image)` -No golden `.raw`. Docker must be running: +The default image is architecture-specific: ```bash make image +ls -l ~/.abox/images/abox-guest-linux-$(go env GOARCH).raw ``` -Confirm `~/.abox/images/abox-guest.raw` exists (~768 MiB). +The path is a 768 MiB image (or, on Linux, an architecture-specific symlink to +an immutable generation). Its resolved regular file must have an adjacent +`.manifest.json`. macOS image creation needs Docker. Linux image creation is +rootless and needs `fakeroot`, e2fsprogs 1.43+, an HTTPS `curl` or `wget`, +`sha256sum`, `tar`, `od`, `awk`, `flock`, and at least 896 MiB free; it needs no +Docker daemon, root, loop mount, KVM, or privileged container. + +## Image architecture, protocol, manifest, or digest mismatch + +Do not rename an arm64 image to look like amd64, or vice versa. Rebuild for the +host architecture: + +```bash +make guest GUEST_ARCH=$(go env GOARCH) +make image GUEST_ARCH=$(go env GOARCH) +``` + +The image and manifest must agree on `arch`, protocol, and SHA-256. A custom +`runtime.image` or SDK `Options.Image` requires `.manifest.json` next to +the resolved image. Linux intentionally refuses metadata-free legacy images +and sessions. Start a new session after rebuilding; `Resume` never replaces an +old session's disk. ## `abox-vmm not found; build with make build` @@ -34,12 +56,95 @@ abox.Open(ctx, abox.Options{VMMPath: "/path/to/bin/abox-vmm"}) On Apple Silicon, `abox-vmm` must be codesigned (`make vmm` / `make sign`). Unsigned helpers fail Hypervisor.framework. -## `start vm` / libkrun errors +## macOS `start vm` / libkrun errors - `kern.hv_support` must be `1` - `brew install libkrun libkrunfw` - Ad-hoc sign: `codesign --entitlements assets/entitlements.plist --force -s - bin/abox-vmm` +## Linux support boundary + +Linux VMM execution and Linux/KVM isolation remain **Planned** pending separate +Phase 0.5 and Phase 18 hardware evidence on the pinned Arch and Fedora x86_64 +baselines. Build/link/image success or an ad hoc boot is not support evidence. + +WSL2 and containerized VMM execution are unsupported even if `/dev/kvm` is +visible. Containers are valid compile and rootless image-build environments +only. A conventional VM needs explicitly enabled nested KVM and is not covered +until the same hardware suite passes there. See +[Platforms]({{ '/platforms' | relative_url }}). + +## Linux `/dev/kvm` missing or denied + +For a native-host development probe: + +```bash +test -e /dev/kvm && ls -l /dev/kvm +test -r /dev/kvm && test -w /dev/kvm +lsmod | grep '^kvm' +``` + +If the device is absent, enable VT-x/AMD-V in firmware and load `kvm_intel` or +`kvm_amd`. If it is `root:kvm` mode 0660, add the user to the `kvm` group, then +log out and back in so the new group applies: + +```bash +sudo usermod -aG kvm "$USER" +``` + +Do not change the device to world-writable as a workaround. If `/dev/kvm` +opens but VM creation or the KVM API check fails, inspect the kernel log and +host virtualization policy. On a VM, ask the administrator to expose nested +virtualization; this is different from offering nested virtualization to the +ABox guest. + +## Linux libkrun pkg-config, loader, or API failure + +The build supports libkrun 1.19.x. Verify the selected package metadata and +dynamic dependencies: + +```bash +pkg-config --modversion libkrun +pkg-config --cflags --libs libkrun +ldd bin/abox-vmm +ldconfig -p | grep -E 'libkrun\.so|libkrunfw\.so' +``` + +Pinned pairs are Arch `libkrun 1.19.4-1` with `libkrunfw 5.5.0-1`, and Fedora +44 `libkrun/libkrun-devel 1.19.0-1.fc44` with `libkrunfw 5.5.0-1.fc44`. +Linux links `libkrun`; that library loads its matching libkrunfw SONAME at +runtime. Therefore, `ldd` on `abox-vmm` alone may not reveal missing firmware. + +For a source install, make both paths visible, then refresh the loader cache: + +```bash +export PKG_CONFIG_PATH=/usr/local/lib64/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH} +printf '%s\n' /usr/local/lib64 | sudo tee /etc/ld.so.conf.d/libkrun.conf +sudo ldconfig +``` + +An `undefined symbol` such as `krun_disable_implicit_vsock` means the headers +and loaded library are incompatible, not that the guest boot timed out. A +negative `krun_*` result can also mean the installed libkrun lacks block-device +support; ABox requires `KRUN_FEATURE_BLK`. Reinstall one complete pinned pair +instead of mixing packages or accepting an arbitrary newer ABI. + +## Fedora SELinux denial + +Correct Unix permissions do not rule out SELinux. Keep SELinux enforcing and +inspect recent AVCs instead of disabling it: + +```bash +getenforce +sudo ausearch -m AVC,USER_AVC -ts recent -c abox-vmm +sudo journalctl -k -b | grep -i 'avc.*denied' +``` + +Correlate the denied operation and path with `/dev/kvm`, executable mappings, +the loader, or `root.raw`. Do not run `setenforce 0` and do not install a broad +allow policy. A narrow packaged policy should be considered only if the pinned +Fedora hardware acceptance run demonstrates that it is required. + ## `ErrGuestTooOld` / `guest protocol N cannot enforce…` That session's `root.raw` has a guest older than protocol 4 (host LLM/MCP @@ -66,13 +171,37 @@ that. The host scrubs those fields on startup; if a resume still fails, ## `missing credential XAI_API_KEY` (or OpenAI / Anthropic) Use `/provider` in the TUI, `/credential` for Vault/Azure/AWS, or write -`~/.abox/credentials.env` (mode `0600`). Keychain entries use service -`abox`. Empty env vars are not used. `abox creds migrate` moves the file -into the keychain. +`~/.abox/credentials.env` (mode `0600`). OS-keystore entries use service +`abox`. Empty env vars are not used. `abox creds migrate` moves the file into +macOS Keychain or Linux Secret Service. `abox --probe-vm` does not need a key. A missing key fails the turn, not VM boot. +## Linux Secret Service unavailable, locked, or timed out + +Linux `keystore` needs `secret-tool`, `gdbus`, a session D-Bus, and a provider +such as GNOME Keyring, KWallet Secret Service compatibility, or KeePassXC. +Check provider ownership without storing a secret: + +```bash +command -v secret-tool +command -v gdbus +gdbus call --session --dest org.freedesktop.DBus \ + --object-path /org/freedesktop/DBus \ + --method org.freedesktop.DBus.NameHasOwner org.freedesktop.secrets +``` + +A missing provider/session bus, provider loss, locked collection, or blocked +unlock prompt causes a visible warning and a plaintext fallback to +`~/.abox/credentials.env` at mode 0600. It does not silently claim the key was +stored in Secret Service. On a headless host, configure `vault`, `azure`, or +`aws` if that fallback is unacceptable. + +Linux MCP OAuth uses `xdg-open`. If no graphical launcher exists or launch +fails, ABox prints the validated authorization URL and continues waiting for +the browser callback. + ## Model `run_command` always errors in `abox exec` Headless has no approver. Default is deny. Use the TUI, or @@ -95,16 +224,26 @@ Only one `user_turn` at a time per VM. Wait for the previous `Turn` to return `connectivity.mode: offline` blocks host LLM and MCP HTTPS. Switch to `direct` or `agentgateway`. -## Boot hangs then context deadline +## Boot hangs or helper exits before the deadline Default `BootTimeout` is 45s. First boot after `make image` can be slower. -Raise `Options.BootTimeout`. Check `sessions//console.log`. +Raise `Options.BootTimeout`. Check `sessions//console.log` and the helper +stderr. On Linux, check KVM access, the loader, libkrunfw runtime discovery, +API compatibility, and SELinux before treating this as a generic timeout. + +## macOS Docker packer 404 / daemon down + +The session path does not need Docker. On macOS only, `make image` / +`make image-update` uses Docker. Start Docker Desktop to refresh the golden +disk. On Linux, install the native image dependencies instead; do not start a +privileged container. -## Docker packer 404 / daemon down +## ABox reports "not a git worktree" -Session path does not need Docker. Only `make image` / `make image-update` -does. Start Docker Desktop, or you cannot refresh `abox-guest` on the golden -disk. +Start ABox from any directory inside the Git worktree you want to use. ABox +discovers the repository root automatically. Clean worktrees use committed +`HEAD`; dirty or commitless worktrees use tracked files plus non-ignored +untracked files. Ordinary non-Git directories are not accepted. ## MCP login / token failures diff --git a/images/build-guest-darwin.sh b/images/build-guest-darwin.sh new file mode 100755 index 0000000..9e10472 --- /dev/null +++ b/images/build-guest-darwin.sh @@ -0,0 +1,100 @@ +#!/bin/sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ARCH="${ABOX_GUEST_ARCH:-arm64}" +OUT="${ABOX_IMAGE:-$HOME/.abox/images/abox-guest-linux-$ARCH.raw}" +GUEST_BIN="$ROOT/bin/abox-guest-linux-$ARCH" +PROTOCOL_VERSION=4 +IMAGE_ID="${ABOX_IMAGE_ID:-abox-guest-dev}" + +case "$ARCH" in + amd64|arm64) ;; + *) echo "unsupported guest architecture: $ARCH" >&2; exit 1 ;; +esac +if [ ! -x "$GUEST_BIN" ]; then + echo "missing $GUEST_BIN; run: make guest GUEST_ARCH=$ARCH" >&2 + exit 1 +fi +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to pack the guest disk on macOS" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUT")" +WORKDIR="$(mktemp -d)" +POINTER_TMP="" +BUILD_DIR="" +cleanup() { + rm -rf "$WORKDIR" + if [ -n "$POINTER_TMP" ]; then rm -f "$POINTER_TMP"; fi + if [ -n "$BUILD_DIR" ] && [ -d "$BUILD_DIR" ]; then rm -rf "$BUILD_DIR"; fi +} +trap cleanup EXIT HUP INT TERM +cp "$GUEST_BIN" "$WORKDIR/abox-guest" +chmod 0755 "$WORKDIR/abox-guest" + +# Keep the existing macOS builder: Docker supplies the target-architecture +# Alpine userspace and the privileged loop mount. +docker run --rm --privileged --platform "linux/$ARCH" \ + -v "$WORKDIR:/work" \ + alpine:3.21 \ + sh -c ' + set -eu + apk add --no-cache e2fsprogs + mkdir -p /rootfs/etc/apk + cp /etc/apk/repositories /rootfs/etc/apk/repositories + apk add --no-cache --root /rootfs --initdb --keys-dir /etc/apk/keys alpine-base git patch + mkdir -p /rootfs/usr/local/bin /rootfs/work/repo /rootfs/tmp /rootfs/abox-config /rootfs/home/abox + printf "abox:x:1000:1000:ABox guest:/home/abox:/bin/sh\n" >> /rootfs/etc/passwd + printf "abox:x:1000:\n" >> /rootfs/etc/group + chown 1000:1000 /rootfs/work/repo /rootfs/tmp /rootfs/home/abox + find /rootfs -xdev -perm /6000 -exec chmod a-s {} + + cp /work/abox-guest /rootfs/usr/local/bin/abox-guest + chmod 0755 /rootfs/usr/local/bin/abox-guest + printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions ndots:1\n" > /rootfs/etc/resolv.conf + dd if=/dev/zero of=/work/abox-guest.raw bs=1M count=768 status=none + mkfs.ext4 -F -q /work/abox-guest.raw + mkdir -p /mnt/root + mount -o loop /work/abox-guest.raw /mnt/root + tar -C /rootfs -cf - . | tar -C /mnt/root -xf - + umount /mnt/root + ' + +IMAGE_SHA256="$(shasum -a 256 "$WORKDIR/abox-guest.raw")" +IMAGE_SHA256=${IMAGE_SHA256%% *} +OUT_DIR="$(dirname "$OUT")" +OUT_BASE="$(basename "$OUT")" +STORE="$OUT_DIR/.$OUT_BASE.generations/$ARCH" +mkdir -p "$STORE" +BUILD_DIR="$(mktemp -d "$STORE/.build.XXXXXX")" +IMAGE="$BUILD_DIR/abox-guest-$ARCH.raw" +mv "$WORKDIR/abox-guest.raw" "$IMAGE" +cat > "$IMAGE.manifest.json" <&2; exit 1 ;; + esac +fi + +case "$ARCH" in + amd64) APK_ARCH=x86_64; ELF_MACHINE=3e00 ;; + arm64) APK_ARCH=aarch64; ELF_MACHINE=b700 ;; + *) echo "unsupported guest architecture: $ARCH" >&2; exit 1 ;; +esac + +OUT="${ABOX_IMAGE:-$HOME/.abox/images/abox-guest-linux-$ARCH.raw}" + +case "$(uname -m)" in + x86_64|amd64) + HOST_APK_ARCH=x86_64 + APK_TOOLS_SHA256=f0e0d34d6a8f1f9d8704bae6612b4627b96f13cd20db759e9b43085135cd234f + ALPINE_KEYS_SHA256=f68a8cf46058b77d2ebccc33fec2a645d8da9e3791ffc385cf80403ec4810512 + ;; + aarch64|arm64) + HOST_APK_ARCH=aarch64 + APK_TOOLS_SHA256=910015ebcdb11f92966f5590cf5b538b3e4dddbc1b56bc441fae3a622d05dd0e + ALPINE_KEYS_SHA256=a70d3c55ee676d7d670714aa729285d5ab6fcf18146eb03e05319098bcb715c0 + ;; + *) echo "apk.static is not pinned for host architecture $(uname -m)" >&2; exit 1 ;; +esac + +GUEST_BIN="$ROOT/bin/abox-guest-linux-$ARCH" +if [ ! -x "$GUEST_BIN" ]; then + echo "missing $GUEST_BIN; run: make guest GUEST_ARCH=$ARCH" >&2 + exit 1 +fi + +for tool in fakeroot mke2fs e2fsck debugfs sha256sum tar od awk flock; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Linux image builds require $tool" >&2 + exit 1 + fi +done +if command -v curl >/dev/null 2>&1; then + DOWNLOADER=curl +elif command -v wget >/dev/null 2>&1; then + DOWNLOADER=wget +else + echo "Linux image builds require curl or wget with HTTPS support" >&2 + exit 1 +fi + +elf_machine() { + set -- $(od -An -tx1 -j18 -N2 "$1") + printf '%s%s\n' "${1:-}" "${2:-}" +} + +verify_elf() { + actual="$(elf_machine "$1")" + if [ "$actual" != "$ELF_MACHINE" ]; then + echo "$1 has ELF machine $actual; expected $ARCH ($ELF_MACHINE)" >&2 + exit 1 + fi +} + +download() { + url=$1 + destination=$2 + if [ "$DOWNLOADER" = curl ]; then + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --output "$destination" "$url" + else + wget -q --https-only -O "$destination" "$url" + fi +} + +verify_download() { + expected=$1 + file=$2 + printf '%s %s\n' "$expected" "$file" | sha256sum -c - >/dev/null +} + +verify_elf "$GUEST_BIN" + +OUT_DIR="$(dirname "$OUT")" +mkdir -p "$OUT_DIR" +AVAILABLE_KB="$(df -Pk "$OUT_DIR" | awk 'NR == 2 { print $4 }')" +REQUIRED_KB=917504 +if [ -z "$AVAILABLE_KB" ] || [ "$AVAILABLE_KB" -lt "$REQUIRED_KB" ]; then + echo "at least 896 MiB free is required in $OUT_DIR" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/abox-image.XXXXXX")" +BUILD_DIR="" +UNPUBLISHED_GENERATION="" +POINTER_TMP="" +cleanup() { + chmod -R u+w "$WORKDIR" 2>/dev/null || true + rm -rf "$WORKDIR" + if [ -n "$POINTER_TMP" ]; then + rm -f "$POINTER_TMP" + fi + if [ -n "$BUILD_DIR" ] && [ -d "$BUILD_DIR" ]; then + chmod -R u+w "$BUILD_DIR" 2>/dev/null || true + rm -rf "$BUILD_DIR" + fi + if [ -n "$UNPUBLISHED_GENERATION" ] && [ -d "$UNPUBLISHED_GENERATION" ]; then + chmod -R u+w "$UNPUBLISHED_GENERATION" 2>/dev/null || true + rm -rf "$UNPUBLISHED_GENERATION" + fi +} +trap cleanup EXIT HUP INT TERM + +TOOLS="$WORKDIR/tools" +ROOTFS="$WORKDIR/rootfs" +KEYS="$WORKDIR/keys" +mkdir -p "$TOOLS" "$ROOTFS" "$KEYS" + +REPO_BASE="https://dl-cdn.alpinelinux.org/alpine/$ALPINE_RELEASE" +APK_PACKAGE="$WORKDIR/apk-tools-static.apk" +KEYS_PACKAGE="$WORKDIR/alpine-keys.apk" +download "$REPO_BASE/main/$HOST_APK_ARCH/apk-tools-static-$APK_TOOLS_VERSION.apk" "$APK_PACKAGE" +download "$REPO_BASE/main/$HOST_APK_ARCH/alpine-keys-$ALPINE_KEYS_VERSION.apk" "$KEYS_PACKAGE" +verify_download "$APK_TOOLS_SHA256" "$APK_PACKAGE" +verify_download "$ALPINE_KEYS_SHA256" "$KEYS_PACKAGE" + +tar --warning=no-unknown-keyword -xzf "$APK_PACKAGE" -C "$TOOLS" sbin/apk.static +tar --warning=no-unknown-keyword -xzf "$KEYS_PACKAGE" -C "$TOOLS" etc/apk/keys usr/share/apk/keys +cp "$TOOLS"/etc/apk/keys/*.pub "$KEYS/" +cp "$TOOLS"/usr/share/apk/keys/"$APK_ARCH"/*.pub "$KEYS/" +APK="$TOOLS/sbin/apk.static" +chmod 0755 "$APK" + +# Keep one architecture-specific atomic pointer and architecture-tag every +# immutable generation. Manifest readers resolve this symlink once and append +# .manifest.json to the immutable target. +OUT_BASE="$(basename "$OUT")" +STORE="$OUT_DIR/.$OUT_BASE.generations/$ARCH" +mkdir -p "$STORE" +BUILD_DIR="$(mktemp -d "$STORE/.build.XXXXXX")" +IMAGE="$BUILD_DIR/abox-guest-$ARCH.raw" +MANIFEST="$IMAGE.manifest.json" +APK_LOG="$WORKDIR/apk-install.log" + +export APK ROOTFS KEYS APK_ARCH GUEST_BIN IMAGE REPO_BASE APK_LOG ALPINE_PACKAGES +fakeroot sh -c ' + set -eu + chown 0:0 "$ROOTFS" + chmod 0755 "$ROOTFS" + if ! "$APK" --arch "$APK_ARCH" --root "$ROOTFS" --initdb --no-cache \ + --keys-dir "$KEYS" \ + --repository "$REPO_BASE/main" \ + --repository "$REPO_BASE/community" \ + add --no-scripts --no-chown $ALPINE_PACKAGES >"$APK_LOG" 2>&1; then + cat "$APK_LOG" >&2 + exit 1 + fi + # apk-tools 2.x calls fchownat for package directories even with --no-chown; + # fakeroot does not interpose that call on every distro. Ignore only that + # summary: the recursive fake chown and debugfs checks below are authoritative. + grep -Ev "^ERROR: [0-9]+ errors updating directory permissions$" "$APK_LOG" || true + chown -R 0:0 "$ROOTFS" + mkdir -p "$ROOTFS/etc/apk" "$ROOTFS/usr/local/bin" \ + "$ROOTFS/work/repo" "$ROOTFS/tmp" "$ROOTFS/abox-config" "$ROOTFS/home/abox" + printf "abox:x:1000:1000:ABox guest:/home/abox:/bin/sh\n" >> "$ROOTFS/etc/passwd" + printf "abox:x:1000:\n" >> "$ROOTFS/etc/group" + printf "%s\n%s\n" "$REPO_BASE/main" "$REPO_BASE/community" > "$ROOTFS/etc/apk/repositories" + install -o 0 -g 0 -m 0755 "$GUEST_BIN" "$ROOTFS/usr/local/bin/abox-guest" + printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions ndots:1\n" > "$ROOTFS/etc/resolv.conf" + chown 0:0 "$ROOTFS/etc/apk/repositories" "$ROOTFS/etc/resolv.conf" "$ROOTFS/abox-config" + chown 1000:1000 "$ROOTFS/work/repo" "$ROOTFS/tmp" "$ROOTFS/home/abox" + chmod 0644 "$ROOTFS/etc/apk/repositories" "$ROOTFS/etc/resolv.conf" + chmod 0755 "$ROOTFS/work/repo" "$ROOTFS/tmp" "$ROOTFS/abox-config" "$ROOTFS/home/abox" + # Model-authored commands run as UID 1000 and must not regain guest root. + find "$ROOTFS" -xdev -perm /6000 -exec chmod a-s {} + + # Some package payloads intentionally have no owner-read bit. fakeroot + # records their guest metadata, while this real chmod lets unprivileged + # mke2fs read the payload without changing the metadata mke2fs observes. + env -u LD_PRELOAD -u LD_LIBRARY_PATH chmod -R u+rwX "$ROOTFS" + mke2fs -q -F -t ext4 -d "$ROOTFS" -b 4096 "$IMAGE" 768M +' + +e2fsck -fn "$IMAGE" +ROOT_STAT="$(debugfs -R 'stat /' "$IMAGE" 2>&1)" +GUEST_STAT="$(debugfs -R 'stat /usr/local/bin/abox-guest' "$IMAGE" 2>&1)" +REPO_STAT="$(debugfs -R 'stat /work/repo' "$IMAGE" 2>&1)" +BUSYBOX_STAT="$(debugfs -R 'stat /bin/busybox' "$IMAGE" 2>&1)" +if ! printf '%s\n' "$ROOT_STAT" | grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0'; then + echo "image root is not owned by 0:0" >&2 + exit 1 +fi +if ! printf '%s\n' "$ROOT_STAT" | grep -Eq 'Mode:[[:space:]]+0755'; then + echo "image root mode is not 0755" >&2 + exit 1 +fi +if ! printf '%s\n' "$GUEST_STAT" | grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0'; then + echo "guest binary is not owned by 0:0" >&2 + exit 1 +fi +if ! printf '%s\n' "$GUEST_STAT" | grep -Eq 'Mode:[[:space:]]+0755'; then + echo "guest binary mode is not 0755" >&2 + exit 1 +fi +if ! printf '%s\n' "$REPO_STAT" | grep -Eq 'User:[[:space:]]+1000[[:space:]]+Group:[[:space:]]+1000'; then + echo "guest repository is not owned by 1000:1000" >&2 + exit 1 +fi +if ! printf '%s\n' "$BUSYBOX_STAT" | grep -Eq 'Mode:[[:space:]]+0755'; then + echo "busybox retained elevated mode bits" >&2 + exit 1 +fi +EXTRACTED_GUEST="$WORKDIR/abox-guest" +debugfs -R "dump /usr/local/bin/abox-guest $EXTRACTED_GUEST" "$IMAGE" >/dev/null 2>&1 +verify_elf "$EXTRACTED_GUEST" + +IMAGE_SHA256="$(sha256sum "$IMAGE")" +IMAGE_SHA256=${IMAGE_SHA256%% *} +cat > "$MANIFEST" <"$OUT_DIR/.abox-images.lock" +flock -x 9 +if [ -e "$GENERATION" ]; then + EXISTING_IMAGE="$GENERATION/abox-guest-$ARCH.raw" + verify_download "$IMAGE_SHA256" "$EXISTING_IMAGE" + chmod -R u+w "$BUILD_DIR" + rm -rf "$BUILD_DIR" +else + mv "$BUILD_DIR" "$GENERATION" + chmod 0555 "$GENERATION" + UNPUBLISHED_GENERATION="$GENERATION" +fi +BUILD_DIR="" + +if [ "${ABOX_IMAGE_FAIL_BEFORE_PUBLISH:-0}" = 1 ]; then + echo "injected failure before image publication" >&2 + exit 1 +fi + +POINTER_TMP="$OUT_DIR/.$OUT_BASE.current.$$" +TARGET=".$OUT_BASE.generations/$ARCH/$IMAGE_SHA256/abox-guest-$ARCH.raw" +rm -f "$POINTER_TMP" +ln -s "$TARGET" "$POINTER_TMP" +# From this point an interrupted publish may leave an unreferenced generation, +# but cleanup must never remove a generation that the pointer could select. +UNPUBLISHED_GENERATION="" +mv -Tf "$POINTER_TMP" "$OUT" +POINTER_TMP="" +flock -u 9 + +echo "wrote immutable generation $GENERATION" +echo "updated current image pointer $OUT" diff --git a/images/build-guest.sh b/images/build-guest.sh index 84721ab..29c9f02 100755 --- a/images/build-guest.sh +++ b/images/build-guest.sh @@ -2,46 +2,16 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUT="${ABOX_IMAGE:-$HOME/.abox/images/abox-guest.raw}" -GUEST_BIN="${ROOT}/bin/abox-guest-linux-arm64" -if [ ! -x "$GUEST_BIN" ]; then - echo "missing $GUEST_BIN; run: make guest" >&2 - exit 1 -fi -if ! command -v docker >/dev/null; then - echo "docker is required to pack the ARM64 guest disk" >&2 - exit 1 -fi - -mkdir -p "$(dirname "$OUT")" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT -cp "$GUEST_BIN" "$WORKDIR/abox-guest" -chmod +x "$WORKDIR/abox-guest" - -docker run --rm --privileged \ - -v "$WORKDIR:/in:ro" \ - -v "$(dirname "$OUT"):/out" \ - alpine:3.21 \ - sh -c ' - set -eu - apk add --no-cache e2fsprogs - mkdir -p /rootfs/etc/apk - cp /etc/apk/repositories /rootfs/etc/apk/repositories - apk add --no-cache --root /rootfs --initdb --keys-dir /etc/apk/keys alpine-base git patch - mkdir -p /rootfs/usr/local/bin /rootfs/work/repo /rootfs/tmp /rootfs/abox-config - cp /in/abox-guest /rootfs/usr/local/bin/abox-guest - chmod 0755 /rootfs/usr/local/bin/abox-guest - printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions ndots:1\n" > /rootfs/etc/resolv.conf - rm -f /out/abox-guest.raw - dd if=/dev/zero of=/out/abox-guest.raw bs=1M count=768 status=none - mkfs.ext4 -F -q /out/abox-guest.raw - mkdir -p /mnt/root - mount -o loop /out/abox-guest.raw /mnt/root - tar -C /rootfs -cf - . | tar -C /mnt/root -xf - - umount /mnt/root - ' - -echo "wrote $OUT" -ls -lh "$OUT" +case "${ABOX_HOST_OS:-$(uname -s)}" in + Linux) + exec sh "$ROOT/images/build-guest-linux.sh" + ;; + Darwin) + exec sh "$ROOT/images/build-guest-darwin.sh" + ;; + *) + echo "guest image builds are supported on Linux and macOS" >&2 + exit 1 + ;; +esac diff --git a/images/update-guest-bin.sh b/images/update-guest-bin.sh index 590c500..72edadf 100755 --- a/images/update-guest-bin.sh +++ b/images/update-guest-bin.sh @@ -1,14 +1,20 @@ #!/bin/sh set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" -IMG="${ABOX_IMAGE:-$HOME/.abox/images/abox-guest.raw}" -BIN="${ROOT}/bin/abox-guest-linux-arm64" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT -cp "$BIN" "$WORKDIR/abox-guest" -docker run --rm --privileged \ - -v "$WORKDIR:/in:ro" \ - -v "$(dirname "$IMG"):/out" \ - alpine:3.21 \ - sh -c 'apk add --no-cache e2fsprogs >/dev/null && mkdir -p /mnt && mount -o loop /out/abox-guest.raw /mnt && cp /in/abox-guest /mnt/usr/local/bin/abox-guest && chmod 0755 /mnt/usr/local/bin/abox-guest && umount /mnt' -echo "updated $IMG" + +case "${ABOX_HOST_OS:-$(uname -s)}" in + Linux) + # Rebuild instead of mutating a selected filesystem with debugfs. The native + # builder publishes a complete immutable image+manifest generation. + exec sh "$ROOT/images/build-guest-linux.sh" + ;; + Darwin) + # Preserve the existing Docker-based macOS update workflow by rebuilding the + # image with its Docker packer rather than introducing a second mutation path. + exec sh "$ROOT/images/build-guest-darwin.sh" + ;; + *) + echo "guest image updates are supported on Linux and macOS" >&2 + exit 1 + ;; +esac diff --git a/internal/config/config.go b/internal/config/config.go index 8bc76e9..eec2cb1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "github.com/AdminTurnedDevOps/ABox/internal/vmmconfig" @@ -14,7 +15,11 @@ import ( "gopkg.in/yaml.v3" ) -const GuestImageName = "abox-guest.raw" +const LegacyGuestImageName = "abox-guest.raw" + +func GuestImageName(arch string) string { + return "abox-guest-linux-" + arch + ".raw" +} type File struct { Models []Model `yaml:"models"` @@ -40,6 +45,17 @@ type CredentialRef struct { Version string `yaml:"version,omitempty"` // vault/azure only } +// CanonicalCredentialSource maps accepted local-store aliases to the portable +// spelling written to config files. +func CanonicalCredentialSource(source string) string { + switch source { + case "keychain", "secretservice": + return "keystore" + default: + return source + } +} + type AzureCloud struct { KeyVaultDNSSuffix string AuthorityHost string @@ -109,6 +125,7 @@ func Load() (File, string, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return cfg, path, fmt.Errorf("parse config: %w", err) } + cfg.canonicalizeCredentialSources() if err := cfg.Validate(); err != nil { return cfg, path, err } @@ -187,7 +204,7 @@ func scrubLegacyAppSupportCredentials(legacy string) error { if !exists(path) { return nil } - body := []byte("# ABox credentials. Mode 0600. Do not commit.\n# Leftover Application Support copy; credentials now live under ~/.abox or the macOS keychain.\n") + body := []byte("# ABox credentials. Mode 0600. Do not commit.\n# Leftover Application Support copy; credentials now live under ~/.abox or the OS keystore.\n") tmp := path + ".tmp" if err := os.WriteFile(tmp, body, 0o600); err != nil { return fmt.Errorf("scrub legacy credentials: %w", err) @@ -303,29 +320,30 @@ func (m Model) validate() error { var credentialSources = map[string]struct{}{ "env": {}, - "keychain": {}, + "keystore": {}, "vault": {}, "azure": {}, "aws": {}, } func (c CredentialRef) validate() error { - if _, ok := credentialSources[c.Source]; !ok { - return fmt.Errorf("unknown source %q (want env, keychain, vault, azure, or aws)", c.Source) + source := CanonicalCredentialSource(c.Source) + if _, ok := credentialSources[source]; !ok { + return fmt.Errorf("unknown source %q (want env, keystore, vault, azure, or aws)", c.Source) } if strings.TrimSpace(c.Name) == "" { return fmt.Errorf("name is required") } - switch c.Source { - case "env", "keychain": + switch source { + case "env", "keystore": if c.Field != "" { - return fmt.Errorf("field is not supported for source %q", c.Source) + return fmt.Errorf("field is not supported for source %q", source) } if c.Version != "" { - return fmt.Errorf("version is not supported for source %q", c.Source) + return fmt.Errorf("version is not supported for source %q", source) } if !ValidEnvName(c.Name) { - return fmt.Errorf("invalid %s credential name %q", c.Source, c.Name) + return fmt.Errorf("invalid %s credential name %q", source, c.Name) } case "vault": if c.Version != "" && !isNumeric(c.Version) { @@ -505,7 +523,9 @@ func (m Model) EnvName() string { func (m Model) CredentialReference() CredentialRef { if m.Credential != nil { - return *m.Credential + ref := *m.Credential + ref.Source = CanonicalCredentialSource(ref.Source) + return ref } if m.CredentialEnv != "" { return CredentialRef{Source: "env", Name: m.CredentialEnv} @@ -515,7 +535,9 @@ func (m Model) CredentialReference() CredentialRef { func (s MCPServer) CredentialReference() CredentialRef { if s.Credential != nil { - return *s.Credential + ref := *s.Credential + ref.Source = CanonicalCredentialSource(ref.Source) + return ref } return CredentialRef{Source: "env", Name: TokenEnv(s)} } @@ -552,7 +574,22 @@ func (r Resources) Resolved() (vcpu, ram int) { } func GuestImagePath() string { - return filepath.Join(ImageDir(), GuestImageName) + modern := filepath.Join(ImageDir(), GuestImageName(runtime.GOARCH)) + if exists(modern) || runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + return modern + } + legacy := filepath.Join(ImageDir(), LegacyGuestImageName) + if exists(legacy) { + return legacy + } + home := homeDir() + if home != "" { + legacy = filepath.Join(home, "Library", "Caches", "ABox", "images", LegacyGuestImageName) + if exists(legacy) { + return legacy + } + } + return modern } // ResolvedMCPServers returns the MCP URLs the guest may dial. @@ -608,6 +645,7 @@ func (c File) Save() error { if err := c.Validate(); err != nil { return err } + c = c.canonicalizedCredentialSources() if err := EnsureLayout(); err != nil { return err } @@ -626,6 +664,38 @@ func (c File) Save() error { return os.Chmod(path, 0o600) } +func (c *File) canonicalizeCredentialSources() { + for i := range c.Models { + if c.Models[i].Credential != nil { + c.Models[i].Credential.Source = CanonicalCredentialSource(c.Models[i].Credential.Source) + } + } + for i := range c.MCPServers { + if c.MCPServers[i].Credential != nil { + c.MCPServers[i].Credential.Source = CanonicalCredentialSource(c.MCPServers[i].Credential.Source) + } + } +} + +func (c File) canonicalizedCredentialSources() File { + c.Models = append([]Model(nil), c.Models...) + for i := range c.Models { + if c.Models[i].Credential != nil { + ref := *c.Models[i].Credential + c.Models[i].Credential = &ref + } + } + c.MCPServers = append([]MCPServer(nil), c.MCPServers...) + for i := range c.MCPServers { + if c.MCPServers[i].Credential != nil { + ref := *c.MCPServers[i].Credential + c.MCPServers[i].Credential = &ref + } + } + c.canonicalizeCredentialSources() + return c +} + func TokenEnv(server MCPServer) string { if server.CredentialEnv != "" { return server.CredentialEnv @@ -682,18 +752,7 @@ func CacheDir() string { } func ImageDir() string { - modern := filepath.Join(Dir(), "images") - if exists(filepath.Join(modern, GuestImageName)) { - return modern - } - home := homeDir() - if home != "" { - legacy := filepath.Join(home, "Library", "Caches", "ABox", "images") - if exists(filepath.Join(legacy, GuestImageName)) { - return legacy - } - } - return modern + return filepath.Join(Dir(), "images") } func SessionRoot() string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20aa557..05078e5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,10 +3,20 @@ package config import ( "os" "path/filepath" + "runtime" "strings" "testing" ) +func TestGuestImagePathIncludesArchitecture(t *testing.T) { + t.Setenv("ABOX_HOME", t.TempDir()) + got := GuestImagePath() + want := filepath.Join(ImageDir(), "abox-guest-linux-"+runtime.GOARCH+".raw") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + func TestModelGuestRoundTrip(t *testing.T) { orig := Model{Name: "grok-default", Provider: "xai", Model: "grok-4", CredentialEnv: "XAI_API_KEY", BaseURL: "https://api.x.ai/v1"} got := ModelFromGuest(orig.ToGuest()) @@ -64,6 +74,10 @@ func TestModelCredentialReference(t *testing.T) { if got := (Model{Credential: &explicit}).CredentialReference(); got != explicit { t.Fatalf("explicit: %+v", got) } + alias := CredentialRef{Source: "keychain", Name: "X"} + if got := (Model{Credential: &alias}).CredentialReference(); got.Source != "keystore" { + t.Fatalf("alias not canonicalized: %+v", got) + } } func TestMCPServerCredentialReference(t *testing.T) { @@ -117,7 +131,7 @@ func TestModelBaseURLRequiresHTTPS(t *testing.T) { func TestValidateFieldVersionRules(t *testing.T) { c := Defaults() c.Models[0].CredentialEnv = "" - c.Models[0].Credential = &CredentialRef{Source: "keychain", Name: "K", Field: "f"} + c.Models[0].Credential = &CredentialRef{Source: "keystore", Name: "K", Field: "f"} if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "field is not supported") { t.Fatalf("got %v", err) } @@ -191,11 +205,11 @@ func TestValidateRejectsCredentialDestinationCollisions(t *testing.T) { } } -func TestValidateRejectsUnsafeKeychainAccount(t *testing.T) { +func TestValidateRejectsUnsafeKeystoreAccount(t *testing.T) { c := Defaults() c.Models[0].CredentialEnv = "" c.Models[0].Credential = &CredentialRef{Source: "keychain", Name: "safe-name; delete"} - if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "invalid keychain credential name") { + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "invalid keystore credential name") { t.Fatalf("got %v", err) } } @@ -209,7 +223,7 @@ func TestCredentialYAMLRoundTrip(t *testing.T) { c.Models[0].Credential = &CredentialRef{Source: "vault", Name: "secret/abox/grok", Field: "api_key", Version: "4"} c.MCPServers = []MCPServer{{ Name: "gh", URL: "https://api.githubcopilot.com/mcp/", - Credential: &CredentialRef{Source: "keychain", Name: "ABOX_MCP_GH_TOKEN"}, + Credential: &CredentialRef{Source: "keystore", Name: "ABOX_MCP_GH_TOKEN"}, }} if err := c.Save(); err != nil { t.Fatal(err) @@ -222,11 +236,61 @@ func TestCredentialYAMLRoundTrip(t *testing.T) { if got.Models[0].Credential == nil || *got.Models[0].Credential != *want { t.Fatalf("model credential: %+v", got.Models[0].Credential) } - if got.MCPServers[0].Credential == nil || got.MCPServers[0].Credential.Source != "keychain" { + if got.MCPServers[0].Credential == nil || got.MCPServers[0].Credential.Source != "keystore" { t.Fatalf("mcp credential: %+v", got.MCPServers[0].Credential) } } +func TestCredentialSourceAliasesLoadAndSaveCanonical(t *testing.T) { + for _, source := range []string{"keystore", "keychain", "secretservice"} { + t.Run(source, func(t *testing.T) { + home := t.TempDir() + t.Setenv("ABOX_HOME", home) + body := "models:\n - name: custom\n provider: other\n model: model\n credential:\n source: " + source + "\n name: SAFE_NAME\nconnectivity: {}\nruntime: {}\nresources: {}\n" + if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, _, err := Load() + if err != nil { + t.Fatal(err) + } + if got := cfg.Models[0].Credential.Source; got != "keystore" { + t.Fatalf("loaded source %q", got) + } + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), "source: keystore") || strings.Contains(string(saved), "source: keychain") || strings.Contains(string(saved), "source: secretservice") { + t.Fatalf("non-canonical config:\n%s", saved) + } + }) + } +} + +func TestSaveCanonicalizesAliasesWithoutMutatingCaller(t *testing.T) { + t.Setenv("ABOX_HOME", t.TempDir()) + cfg := Defaults() + cfg.Models[0].CredentialEnv = "" + cfg.Models[0].Credential = &CredentialRef{Source: "secretservice", Name: "XAI_API_KEY"} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + if cfg.Models[0].Credential.Source != "secretservice" { + t.Fatalf("Save mutated caller: %+v", cfg.Models[0].Credential) + } + body, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "source: keystore") { + t.Fatalf("canonical source missing:\n%s", body) + } +} + func TestValidateRejectsUnknownMode(t *testing.T) { c := Defaults() c.Connectivity.Mode = "wide-open" diff --git a/internal/credsource/azure.go b/internal/credsource/azure.go index d7360f2..bb545c9 100644 --- a/internal/credsource/azure.go +++ b/internal/credsource/azure.go @@ -9,6 +9,8 @@ import ( "net/url" "os" "os/exec" + "path/filepath" + "runtime" "strings" "time" @@ -23,7 +25,12 @@ const ( ) var runAz = func(ctx context.Context, args []string) (stdout string, err error) { - cmd := exec.CommandContext(ctx, "az", args...) + path, err := trustedAzureCLI() + if err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = azureCLIEnvironment() out, err := cmd.Output() return string(out), err } @@ -33,10 +40,41 @@ var newAzureClient = func() *http.Client { } var azAvailable = func() bool { - _, err := exec.LookPath("az") + _, err := trustedAzureCLI() return err == nil } +func trustedAzureCLI() (string, error) { + candidates := []string{"/usr/bin/az", "/usr/local/bin/az"} + if runtime.GOOS == "darwin" { + candidates = []string{"/opt/homebrew/bin/az", "/usr/local/bin/az"} + } + for _, candidate := range candidates { + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + continue + } + info, err := os.Stat(resolved) + if err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 { + return resolved, nil + } + } + return "", fmt.Errorf("Azure CLI not found in a trusted system location") +} + +func azureCLIEnvironment() []string { + env := []string{"PATH=/usr/bin:/bin"} + for _, name := range []string{ + "HOME", "AZURE_CONFIG_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "LANG", "LC_ALL", + } { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + return env +} + func (azureSource) Resolve(ctx context.Context, ref Reference) (Value, error) { secretURI, name, version, cloud, err := config.ParseAzureSecretReference(ref.Name, ref.Version) if err != nil { diff --git a/internal/credsource/credsource.go b/internal/credsource/credsource.go index 4743a8f..403170b 100644 --- a/internal/credsource/credsource.go +++ b/internal/credsource/credsource.go @@ -60,7 +60,7 @@ type Resolver struct { func NewResolver() *Resolver { r := &Resolver{sources: map[string]Source{}} r.Register("env", envSource{}) - r.Register("keychain", keychainSource{}) + r.Register("keystore", keystoreSource{}) r.Register("vault", vaultSource{}) r.Register("azure", azureSource{}) r.Register("aws", awsSource{}) @@ -70,10 +70,11 @@ func NewResolver() *Resolver { func (r *Resolver) Register(name string, s Source) { r.mu.Lock() defer r.mu.Unlock() - r.sources[name] = s + r.sources[config.CanonicalCredentialSource(name)] = s } func (r *Resolver) Resolve(ctx context.Context, ref Reference) (Value, error) { + ref.Source = config.CanonicalCredentialSource(ref.Source) r.mu.Lock() s, ok := r.sources[ref.Source] r.mu.Unlock() diff --git a/internal/credsource/credsource_test.go b/internal/credsource/credsource_test.go index 845f3fd..08bf25c 100644 --- a/internal/credsource/credsource_test.go +++ b/internal/credsource/credsource_test.go @@ -1,7 +1,9 @@ package credsource import ( + "bytes" "context" + "encoding/hex" "errors" "fmt" "net/http" @@ -9,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/AdminTurnedDevOps/ABox/internal/credentials" ) @@ -96,136 +99,267 @@ func TestEnvSourceNotFound(t *testing.T) { } } -func TestKeychainResolveReadsPasswordOnly(t *testing.T) { - var argv []string - orig := runSecurity - runSecurity = func(_ context.Context, args []string, stdin string) (string, string, error) { - argv = args - return "kchain-value\n", "", nil +type staticSource struct{ value []byte } + +func (s staticSource) Resolve(context.Context, Reference) (Value, error) { + return Value{Bytes: append([]byte(nil), s.value...)}, nil +} +func (staticSource) Close() error { return nil } + +func TestResolverAcceptsKeystoreAliases(t *testing.T) { + r := &Resolver{sources: map[string]Source{}} + r.Register("keystore", staticSource{value: []byte("value")}) + for _, source := range []string{"keystore", "keychain", "secretservice"} { + v, err := r.Resolve(context.Background(), Reference{Source: source, Name: "SAFE_NAME"}) + if err != nil || string(v.Bytes) != "value" { + t.Fatalf("%s: value=%q err=%v", source, v.Bytes, err) + } } - t.Cleanup(func() { runSecurity = orig }) +} - v, err := testResolver().Resolve(context.Background(), Reference{Source: "keychain", Name: "ANTHROPIC_API_KEY"}) - if err != nil { - t.Fatal(err) +func TestOSKeystoreDispatch(t *testing.T) { + if _, ok := selectedOSKeystore("darwin").(keychainStore); !ok { + t.Fatal("darwin did not select the macOS keychain") } - if string(v.Bytes) != "kchain-value" { - t.Fatalf("got %q", v.Bytes) + if _, ok := selectedOSKeystore("linux").(secretServiceStore); !ok { + t.Fatal("linux did not select Secret Service") } - want := []string{"find-generic-password", "-s", "abox", "-a", "ANTHROPIC_API_KEY", "-w"} - if len(argv) != len(want) { - t.Fatalf("argv=%v", argv) + if _, ok := selectedOSKeystore("windows").(unavailableStore); !ok { + t.Fatal("unsupported platform did not select unavailable store") } - for i := range want { - if argv[i] != want[i] { - t.Fatalf("argv=%v", argv) - } +} + +func installKeystoreFakes(t *testing.T, run func(context.Context, string, []string, []byte) ([]byte, []byte, error)) { + t.Helper() + origLookPath := keystoreLookPath + origRun := runKeystoreCommand + keystoreLookPath = func(name string) (string, error) { return "/fake/" + name, nil } + runKeystoreCommand = run + t.Cleanup(func() { + keystoreLookPath = origLookPath + runKeystoreCommand = origRun + }) +} + +func successfulSecretServiceCommand(_ context.Context, path string, args []string, _ []byte) ([]byte, []byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "StartServiceByName"): + return []byte("(uint32 2,)"), nil, nil + case strings.Contains(joined, "NameHasOwner"): + return []byte("(true,)"), nil, nil + case strings.Contains(joined, "SearchItems"): + return []byte("([objectpath '/org/freedesktop/secrets/collection/login/1'], @ao [])"), nil, nil + case path == "/fake/secret-tool": + return nil, nil, nil + default: + return nil, nil, fmt.Errorf("unexpected command %s %v", path, args) } - for _, a := range argv { - if strings.Contains(a, "kchain-value") { - t.Fatalf("secret value leaked into argv: %v", argv) +} + +func TestSecretServiceGetIsByteExact(t *testing.T) { + want := []byte(" value with spaces\nand newline\n") + installKeystoreFakes(t, func(ctx context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if path == "/fake/secret-tool" { + if got := strings.Join(args, " "); got != "lookup service abox account SAFE_NAME" { + t.Fatalf("lookup args %q", got) + } + return append([]byte(nil), want...), nil, nil } + return successfulSecretServiceCommand(ctx, path, args, stdin) + }) + got, err := (secretServiceStore{}).Get(context.Background(), "SAFE_NAME") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %q want %q", got, want) } } -func TestKeychainResolveNotFoundExit44(t *testing.T) { - orig := runSecurity - runSecurity = func(_ context.Context, args []string, _ string) (string, string, error) { - return "", "could not be found", fakeExitError(44) +func TestSecretServiceSetUsesOnlyByteExactStdin(t *testing.T) { + secret := []byte("plain-key\n") + installKeystoreFakes(t, func(ctx context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if path == "/fake/secret-tool" { + for _, arg := range args { + if strings.Contains(arg, "plain-key") { + t.Fatalf("secret leaked in argv: %v", args) + } + } + if got := strings.Join(args, " "); got != "store --label abox: SAFE_NAME service abox account SAFE_NAME" { + t.Fatalf("store args %q", got) + } + if !bytes.Equal(stdin, secret) { + t.Fatalf("stdin %q want %q", stdin, secret) + } + return nil, nil, nil + } + return successfulSecretServiceCommand(ctx, path, args, stdin) + }) + if err := (secretServiceStore{}).Set(context.Background(), "SAFE_NAME", secret); err != nil { + t.Fatal(err) } - t.Cleanup(func() { runSecurity = orig }) +} - _, err := testResolver().Resolve(context.Background(), Reference{Source: "keychain", Name: "NOPE"}) - if !errors.Is(err, ErrNotFound) { +func TestSecretServiceMissingItemIsNotFound(t *testing.T) { + installKeystoreFakes(t, func(ctx context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if strings.Contains(strings.Join(args, " "), "SearchItems") { + return []byte("(@ao [], @ao [])"), nil, nil + } + if path == "/fake/secret-tool" { + t.Fatal("secret-tool called for confirmed missing item") + } + return successfulSecretServiceCommand(ctx, path, args, stdin) + }) + _, err := (secretServiceStore{}).Get(context.Background(), "MISSING") + if !errors.Is(err, ErrNotFound) || errors.Is(err, ErrLocked) { t.Fatalf("got %v", err) } } -func TestKeychainResolveLocked(t *testing.T) { - orig := runSecurity - runSecurity = func(_ context.Context, _ []string, _ string) (string, string, error) { - return "", "security: SecKeychainSearchCopyNext(): User interaction is not allowed.", fakeExitError(1) +func TestSecretServiceLockedItemIsLocked(t *testing.T) { + installKeystoreFakes(t, func(ctx context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if path == "/fake/secret-tool" { + return nil, []byte("prompt dismissed"), fakeExitError(1) + } + return successfulSecretServiceCommand(ctx, path, args, stdin) + }) + _, err := (secretServiceStore{}).Get(context.Background(), "SAFE_NAME") + if !errors.Is(err, ErrLocked) || errors.Is(err, ErrNotFound) { + t.Fatalf("got %v", err) } - t.Cleanup(func() { runSecurity = orig }) +} - _, err := testResolver().Resolve(context.Background(), Reference{Source: "keychain", Name: "X"}) - if !errors.Is(err, ErrLocked) { +func TestSecretServiceUnavailableWithoutProvider(t *testing.T) { + installKeystoreFakes(t, func(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { + if strings.Contains(strings.Join(args, " "), "NameHasOwner") { + return []byte("(false,)"), nil, nil + } + return nil, []byte("not activatable"), fakeExitError(1) + }) + if err := secretServiceAvailable(context.Background()); !errors.Is(err, ErrLocked) { t.Fatalf("got %v", err) } } -func TestKeychainSetUsesStdinHexNoArgvLeak(t *testing.T) { - var argv, stdin, stderr []string - orig := runSecurity - runSecurity = func(_ context.Context, args []string, in string) (string, string, error) { - argv = args - stdin = append(stdin, in) - return "", "", nil +func TestSecretServiceMissingSessionBusIsLocked(t *testing.T) { + installKeystoreFakes(t, func(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { + if strings.Contains(strings.Join(args, " "), "NameHasOwner") { + return nil, []byte("Cannot autolaunch D-Bus without X11 $DISPLAY"), fakeExitError(1) + } + return nil, []byte("session bus unavailable"), fakeExitError(1) + }) + if err := secretServiceAvailable(context.Background()); !errors.Is(err, ErrLocked) { + t.Fatalf("got %v", err) } - t.Cleanup(func() { runSecurity = orig }) +} - if err := SetKeychain(context.Background(), "ANTHROPIC_API_KEY", []byte("plain-key")); err != nil { - t.Fatal(err) - } - if len(argv) != 1 || argv[0] != "-i" { - t.Fatalf("argv=%v, want only [\"-i\"]", argv) +func TestSecretServiceMissingGDBusIsUnavailable(t *testing.T) { + origLookPath := keystoreLookPath + keystoreLookPath = func(name string) (string, error) { + if name == "gdbus" { + return "", os.ErrNotExist + } + return "/fake/" + name, nil } - if len(stdin) != 1 { - t.Fatalf("stdin writes: %d", len(stdin)) + t.Cleanup(func() { keystoreLookPath = origLookPath }) + if err := secretServiceAvailable(context.Background()); !errors.Is(err, ErrLocked) || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("got %v", err) } - cmd := stdin[0] - if strings.Contains(cmd, "plain-key") { - t.Fatal("plaintext secret in security stdin command") +} + +func TestSecretServiceTimeoutIsLockedAndRunnerReturns(t *testing.T) { + reaped := make(chan struct{}) + installKeystoreFakes(t, func(ctx context.Context, _ string, _ []string, _ []byte) ([]byte, []byte, error) { + <-ctx.Done() + close(reaped) + return nil, nil, ctx.Err() + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err := (secretServiceStore{}).Get(ctx, "SAFE_NAME") + if !errors.Is(err, ErrLocked) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v", err) } - if !strings.Contains(cmd, "add-generic-password -U -s abox -a ANTHROPIC_API_KEY -X") { - t.Fatalf("stdin command: %q", cmd) + select { + case <-reaped: + default: + t.Fatal("command runner did not return on timeout") } - _ = stderr } -func TestKeychainRejectsCommandInputAccountName(t *testing.T) { - called := false - orig := runSecurity - runSecurity = func(_ context.Context, _ []string, _ string) (string, string, error) { - called = true - return "", "", nil +func TestSecretServiceProviderLossDuringSaveIsLocked(t *testing.T) { + installKeystoreFakes(t, func(ctx context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if path == "/fake/secret-tool" { + return nil, []byte("service vanished"), fakeExitError(1) + } + return successfulSecretServiceCommand(ctx, path, args, stdin) + }) + if err := (secretServiceStore{}).Set(context.Background(), "SAFE_NAME", []byte("value")); !errors.Is(err, ErrLocked) { + t.Fatalf("got %v", err) } - t.Cleanup(func() { runSecurity = orig }) +} - err := SetKeychain(context.Background(), "SAFE_NAME\n delete-generic-password", []byte("value")) - if err == nil || !strings.Contains(err.Error(), "invalid keychain account name") { - t.Fatalf("got %v", err) +func TestMacOSKeychainBehaviorRetained(t *testing.T) { + var setInput []byte + installKeystoreFakes(t, func(_ context.Context, path string, args []string, stdin []byte) ([]byte, []byte, error) { + if path != "/usr/bin/security" { + t.Fatalf("path %q", path) + } + if len(args) == 1 && args[0] == "-i" { + setInput = append([]byte(nil), stdin...) + return nil, nil, nil + } + return []byte("keychain-value\n"), nil, nil + }) + value, err := (keychainStore{}).Get(context.Background(), "SAFE_NAME") + if err != nil || string(value) != "keychain-value" { + t.Fatalf("value=%q err=%v", value, err) } - if called { - t.Fatal("security invoked for invalid account name") + if err := (keychainStore{}).Set(context.Background(), "SAFE_NAME", []byte("plain-key")); err != nil { + t.Fatal(err) + } + if bytes.Contains(setInput, []byte("plain-key")) || !bytes.Contains(setInput, []byte(hex.EncodeToString([]byte("plain-key")))) { + t.Fatalf("security input %q", setInput) } } -func TestKeychainCommandErrorWrapsCause(t *testing.T) { - cause := errors.New("security failed") - orig := runSecurity - runSecurity = func(_ context.Context, _ []string, _ string) (string, string, error) { - return "", "diagnostic", cause +func TestMacOSKeychainErrorMappingsRetained(t *testing.T) { + installKeystoreFakes(t, func(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { + if len(args) > 0 && args[0] == "find-generic-password" { + if slicesContain(args, "MISSING") { + return nil, []byte("could not be found"), fakeExitError(44) + } + return nil, []byte("User interaction is not allowed"), fakeExitError(1) + } + return nil, nil, nil + }) + if _, err := (keychainStore{}).Get(context.Background(), "MISSING"); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing: %v", err) } - t.Cleanup(func() { runSecurity = orig }) - - err := SetKeychain(context.Background(), "SAFE_NAME", []byte("value")) - if !errors.Is(err, cause) || !strings.Contains(err.Error(), "diagnostic") { - t.Fatalf("got %v", err) + if _, err := (keychainStore{}).Get(context.Background(), "LOCKED"); !errors.Is(err, ErrLocked) { + t.Fatalf("locked: %v", err) } } -func TestKeychainMissingToolIsUnavailableAndWrapsCause(t *testing.T) { - cause := &os.PathError{Op: "fork/exec", Path: "/usr/bin/security", Err: os.ErrNotExist} - orig := runSecurity - runSecurity = func(_ context.Context, _ []string, _ string) (string, string, error) { - return "", "", cause +func slicesContain(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } } - t.Cleanup(func() { runSecurity = orig }) + return false +} - _, err := testResolver().Resolve(context.Background(), Reference{Source: "keychain", Name: "SAFE_NAME"}) - if !errors.Is(err, ErrLocked) || !errors.Is(err, os.ErrNotExist) { - t.Fatalf("got %v", err) +func TestKeystoreRejectsUnsafeAccountBeforeCommand(t *testing.T) { + called := false + installKeystoreFakes(t, func(context.Context, string, []string, []byte) ([]byte, []byte, error) { + called = true + return nil, nil, nil + }) + err := SetOSKeystore(context.Background(), "SAFE_NAME\naccount", []byte("value")) + if err == nil || !strings.Contains(err.Error(), "invalid keystore account name") || called { + t.Fatalf("err=%v called=%v", err, called) } } @@ -238,50 +372,77 @@ func TestSecurityToolAvailable(t *testing.T) { } } -func TestSavePreferredReportsKeychainSource(t *testing.T) { +func TestSavePreferredWarnsOnUnavailableFallback(t *testing.T) { t.Setenv("HOME", t.TempDir()) - origEnabled := KeychainEnabled - origSecurity := runSecurity - KeychainEnabled = func() bool { return true } - runSecurity = func(_ context.Context, args []string, _ string) (string, string, error) { - if len(args) != 1 || args[0] != "-i" { - t.Fatalf("args %v", args) - } - return "", "", nil + origEnabled := KeystoreEnabled + KeystoreEnabled = func(context.Context) bool { return false } + t.Cleanup(func() { KeystoreEnabled = origEnabled }) + result, err := SavePreferred(context.Background(), "SAFE_NAME", "value") + if err != nil { + t.Fatal(err) } + if result.Source != "env" || result.Keystore || !strings.Contains(result.Note, "warning:") || !strings.Contains(result.Note, "0600") { + t.Fatalf("result %#v", result) + } + info, err := os.Stat(credentials.Path()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("credentials mode=%v", info.Mode()) + } +} + +type lockedStore struct{} + +func (lockedStore) Available(context.Context) bool { return true } +func (lockedStore) Get(context.Context, string) ([]byte, error) { + return nil, ErrLocked +} +func (lockedStore) Set(context.Context, string, []byte) error { return ErrLocked } +func (lockedStore) Delete(context.Context, string) error { return ErrLocked } +func (lockedStore) Description() string { return "locked test store" } + +func TestSavePreferredWarnsOnLockedFallback(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + origEnabled := KeystoreEnabled + origStore := currentOSKeystore + KeystoreEnabled = func(context.Context) bool { return true } + currentOSKeystore = func() osKeystore { return lockedStore{} } t.Cleanup(func() { - KeychainEnabled = origEnabled - runSecurity = origSecurity + KeystoreEnabled = origEnabled + currentOSKeystore = origStore }) - result, err := SavePreferred(context.Background(), "SAFE_NAME", "value") if err != nil { t.Fatal(err) } - if result.Source != "keychain" || !result.Keychain { + if result.Source != "env" || !strings.Contains(result.Note, "locked or unavailable") { t.Fatalf("result %#v", result) } } -func TestSavePreferredRemovesLeftoverFileEntry(t *testing.T) { +func TestSavePreferredReportsCanonicalKeystoreAndRemovesFallback(t *testing.T) { t.Setenv("HOME", t.TempDir()) - if err := credentials.Save("SAFE_NAME", "old-file-value"); err != nil { + if err := credentials.Save("SAFE_NAME", "old-value"); err != nil { t.Fatal(err) } - origEnabled := KeychainEnabled - origSecurity := runSecurity - KeychainEnabled = func() bool { return true } - runSecurity = func(_ context.Context, args []string, _ string) (string, string, error) { - return "", "", nil - } + origEnabled := KeystoreEnabled + origStore := currentOSKeystore + KeystoreEnabled = func(context.Context) bool { return true } + currentOSKeystore = func() osKeystore { return secretServiceStore{} } t.Cleanup(func() { - KeychainEnabled = origEnabled - runSecurity = origSecurity + KeystoreEnabled = origEnabled + currentOSKeystore = origStore }) - - if _, err := SavePreferred(context.Background(), "SAFE_NAME", "new-keychain-value"); err != nil { + installKeystoreFakes(t, successfulSecretServiceCommand) + result, err := SavePreferred(context.Background(), "SAFE_NAME", "new-value") + if err != nil { t.Fatal(err) } + if result.Source != "keystore" || !result.Keystore { + t.Fatalf("result %#v", result) + } got, err := credentials.Load() if err != nil { t.Fatal(err) @@ -291,29 +452,6 @@ func TestSavePreferredRemovesLeftoverFileEntry(t *testing.T) { } } -func TestKeychainDelete(t *testing.T) { - var argv []string - orig := runSecurity - runSecurity = func(_ context.Context, args []string, _ string) (string, string, error) { - argv = args - return "", "", nil - } - t.Cleanup(func() { runSecurity = orig }) - - if err := DeleteKeychain(context.Background(), "ANTHROPIC_API_KEY"); err != nil { - t.Fatal(err) - } - want := []string{"delete-generic-password", "-s", "abox", "-a", "ANTHROPIC_API_KEY"} - if len(argv) != len(want) { - t.Fatalf("argv=%v", argv) - } - for i := range want { - if argv[i] != want[i] { - t.Fatalf("argv=%v", argv) - } - } -} - func TestVaultResolvePathAndToken(t *testing.T) { srv := newVaultServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/secret/data/abox/anthropic" { diff --git a/internal/credsource/keychain.go b/internal/credsource/keychain.go index 59e3e9f..94a89aa 100644 --- a/internal/credsource/keychain.go +++ b/internal/credsource/keychain.go @@ -1,6 +1,7 @@ package credsource import ( + "bytes" "context" "encoding/hex" "errors" @@ -9,123 +10,391 @@ import ( "os/exec" "runtime" "strings" + "time" "github.com/AdminTurnedDevOps/ABox/internal/config" ) -// keychainSource shells out to /usr/bin/security (no cgo). Values are ASCII: -// `security find-generic-password -w` hex-encodes anything else. -type keychainSource struct{} +const ( + KeychainService = "abox" + keystoreCommandTimeout = 10 * time.Second + secretServiceName = "org.freedesktop.secrets" +) + +type osKeystore interface { + Available(context.Context) bool + Get(context.Context, string) ([]byte, error) + Set(context.Context, string, []byte) error + Delete(context.Context, string) error + Description() string +} + +type keychainStore struct{} +type secretServiceStore struct{} +type unavailableStore struct{ goos string } -const KeychainService = "abox" +var ( + keystoreLookPath = trustedKeystoreTool + keystoreStat = os.Stat + currentOSKeystore = func() osKeystore { return selectedOSKeystore(runtime.GOOS) } + runKeystoreCommand = func(ctx context.Context, path string, args []string, stdin []byte) (stdout, stderr []byte, err error) { + cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = keystoreEnvironment() + cmd.Stdin = bytes.NewReader(stdin) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + cmd.WaitDelay = time.Second + err = cmd.Run() + return outBuf.Bytes(), errBuf.Bytes(), err + } +) + +func trustedKeystoreTool(name string) (string, error) { + var path string + switch name { + case "secret-tool": + path = "/usr/bin/secret-tool" + case "gdbus": + path = "/usr/bin/gdbus" + default: + return "", fmt.Errorf("unrecognized keystore helper %q", name) + } + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("keystore helper is not a regular executable: %s", path) + } + return path, nil +} -var runSecurity = func(ctx context.Context, args []string, stdin string) (stdout, stderr string, err error) { - cmd := exec.CommandContext(ctx, "/usr/bin/security", args...) - if stdin != "" { - cmd.Stdin = strings.NewReader(stdin) +func keystoreEnvironment() []string { + env := []string{"PATH=/usr/bin:/bin"} + for _, name := range []string{ + "HOME", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY", "WAYLAND_DISPLAY", "LANG", "LC_ALL", + } { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } } - var outBuf, errBuf strings.Builder - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - err = cmd.Run() - return outBuf.String(), errBuf.String(), err + return env } -func KeychainAvailable() bool { - info, err := os.Stat("/usr/bin/security") +type keystoreSource struct{} + +func (keystoreSource) Resolve(ctx context.Context, ref Reference) (Value, error) { + if !config.ValidEnvName(ref.Name) { + return Value{}, fmt.Errorf("invalid keystore account name %q", ref.Name) + } + value, err := currentOSKeystore().Get(ctx, ref.Name) if err != nil { - return false + return Value{}, err + } + return Value{Bytes: value}, nil +} + +func (keystoreSource) Close() error { return nil } + +func selectedOSKeystore(goos string) osKeystore { + switch goos { + case "darwin": + return keychainStore{} + case "linux": + return secretServiceStore{} + default: + return unavailableStore{goos: goos} + } +} + +func OSKeystoreAvailable() bool { + ctx, cancel := context.WithTimeout(context.Background(), keystoreCommandTimeout) + defer cancel() + return currentOSKeystore().Available(ctx) +} + +func OSKeystoreDescription() string { + return currentOSKeystore().Description() +} + +func SetOSKeystore(ctx context.Context, name string, value []byte) error { + if !config.ValidEnvName(name) { + return fmt.Errorf("invalid keystore account name %q", name) + } + return currentOSKeystore().Set(ctx, name, value) +} + +func DeleteOSKeystore(ctx context.Context, name string) error { + if !config.ValidEnvName(name) { + return fmt.Errorf("invalid keystore account name %q", name) } - return securityToolAvailable(runtime.GOOS, info.Mode()) + return currentOSKeystore().Delete(ctx, name) +} + +func (keychainStore) Available(_ context.Context) bool { + info, err := keystoreStat("/usr/bin/security") + return err == nil && securityToolAvailable("darwin", info.Mode()) } func securityToolAvailable(goos string, mode os.FileMode) bool { return goos == "darwin" && mode.IsRegular() && mode.Perm()&0o111 != 0 } -func (keychainSource) Resolve(ctx context.Context, ref Reference) (Value, error) { - if !config.ValidEnvName(ref.Name) { - return Value{}, fmt.Errorf("invalid keychain account name %q", ref.Name) +func (keychainStore) Get(ctx context.Context, name string) ([]byte, error) { + if !config.ValidEnvName(name) { + return nil, fmt.Errorf("invalid keystore account name %q", name) } - stdout, stderr, err := runSecurity(ctx, []string{ - "find-generic-password", "-s", KeychainService, "-a", ref.Name, "-w", - }, "") + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + stdout, stderr, err := runKeystoreCommand(ctx, "/usr/bin/security", []string{ + "find-generic-password", "-s", KeychainService, "-a", name, "-w", + }, nil) if err == nil { - v := strings.TrimSpace(stdout) - if v == "" { - return Value{}, fmt.Errorf("%w: keychain %s is empty", ErrNotFound, ref.Name) + value := strings.TrimSpace(string(stdout)) + if value == "" { + return nil, fmt.Errorf("%w: keychain %s is empty", ErrNotFound, name) } - return Value{Bytes: []byte(v)}, nil + return []byte(value), nil } if exitCode(err) == 44 { - return Value{}, fmt.Errorf("%w: keychain %s: %w", ErrNotFound, ref.Name, err) + return nil, fmt.Errorf("%w: keychain %s: %w", ErrNotFound, name, err) } - if strings.Contains(stderr, "User interaction is not allowed") { - return Value{}, fmt.Errorf("%w: keychain locked while reading %s (unlock the login keychain or use credential source env): %w", ErrLocked, ref.Name, err) + if bytes.Contains(stderr, []byte("User interaction is not allowed")) { + return nil, fmt.Errorf("%w: keychain locked while reading %s: %w", ErrLocked, name, err) } - return Value{}, keychainCommandError(ctx, "read", ref.Name, stderr, err) + return nil, keychainCommandError(ctx, "read", name, stderr, err) } -func (keychainSource) Close() error { return nil } +func (keychainStore) Set(ctx context.Context, name string, value []byte) error { + if !config.ValidEnvName(name) { + return fmt.Errorf("invalid keystore account name %q", name) + } + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + command := fmt.Sprintf("add-generic-password -U -s %s -a %s -X %s -j \"managed by abox\"\n", + KeychainService, name, hex.EncodeToString(value)) + _, stderr, err := runKeystoreCommand(ctx, "/usr/bin/security", []string{"-i"}, []byte(command)) + if err == nil { + return nil + } + if bytes.Contains(stderr, []byte("User interaction is not allowed")) { + return fmt.Errorf("%w: keychain locked while writing %s: %w", ErrLocked, name, err) + } + return keychainCommandError(ctx, "write", name, stderr, err) +} -func exitCode(err error) int { - var coder interface{ ExitCode() int } - if errors.As(err, &coder) { - return coder.ExitCode() +func (keychainStore) Delete(ctx context.Context, name string) error { + if !config.ValidEnvName(name) { + return fmt.Errorf("invalid keystore account name %q", name) } - return -1 + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + _, stderr, err := runKeystoreCommand(ctx, "/usr/bin/security", []string{ + "delete-generic-password", "-s", KeychainService, "-a", name, + }, nil) + if err == nil { + return nil + } + if exitCode(err) == 44 { + return fmt.Errorf("%w: keychain %s: %w", ErrNotFound, name, err) + } + if bytes.Contains(stderr, []byte("User interaction is not allowed")) { + return fmt.Errorf("%w: keychain locked while deleting %s: %w", ErrLocked, name, err) + } + return keychainCommandError(ctx, "delete", name, stderr, err) +} + +func (keychainStore) Description() string { return "macOS Keychain (service abox)" } + +func (secretServiceStore) Available(ctx context.Context) bool { + return secretServiceAvailable(ctx) == nil } -// SetKeychain writes via `security -i` with `-X` hex on stdin so the secret -// never appears in argv. Names must be env-var syntax: -i parses a command language. -func SetKeychain(ctx context.Context, name string, value []byte) error { +func (secretServiceStore) Get(ctx context.Context, name string) ([]byte, error) { if !config.ValidEnvName(name) { - return fmt.Errorf("invalid keychain account name %q", name) + return nil, fmt.Errorf("invalid keystore account name %q", name) } - cmdStr := fmt.Sprintf("add-generic-password -U -s %s -a %s -X %s -j \"managed by abox\"\n", - KeychainService, name, hex.EncodeToString(value)) - _, stderr, err := runSecurity(ctx, []string{"-i"}, cmdStr) + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + if err := secretServiceAvailable(ctx); err != nil { + return nil, err + } + exists, err := secretServiceItemExists(ctx, name) if err != nil { - if strings.Contains(stderr, "User interaction is not allowed") { - return fmt.Errorf("%w: keychain locked while writing %s (unlock the login keychain or use credential source env): %w", ErrLocked, name, err) - } - return keychainCommandError(ctx, "write", name, stderr, err) + return nil, err + } + if !exists { + return nil, fmt.Errorf("%w: Secret Service item %s", ErrNotFound, name) + } + tool, err := keystoreLookPath("secret-tool") + if err != nil { + return nil, unavailableError("find secret-tool", err) + } + stdout, stderr, err := runKeystoreCommand(ctx, tool, []string{ + "lookup", "service", KeychainService, "account", name, + }, nil) + if err != nil { + return nil, secretServiceCommandError(ctx, "read", name, stderr, err) + } + return append([]byte(nil), stdout...), nil +} + +func (secretServiceStore) Set(ctx context.Context, name string, value []byte) error { + if !config.ValidEnvName(name) { + return fmt.Errorf("invalid keystore account name %q", name) + } + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + if err := secretServiceAvailable(ctx); err != nil { + return err + } + tool, err := keystoreLookPath("secret-tool") + if err != nil { + return unavailableError("find secret-tool", err) + } + _, stderr, err := runKeystoreCommand(ctx, tool, []string{ + "store", "--label", "abox: " + name, "service", KeychainService, "account", name, + }, value) + if err != nil { + return secretServiceCommandError(ctx, "write", name, stderr, err) } return nil } -func DeleteKeychain(ctx context.Context, name string) error { +func (secretServiceStore) Delete(ctx context.Context, name string) error { if !config.ValidEnvName(name) { - return fmt.Errorf("invalid keychain account name %q", name) + return fmt.Errorf("invalid keystore account name %q", name) } - _, stderr, err := runSecurity(ctx, []string{ - "delete-generic-password", "-s", KeychainService, "-a", name, - }, "") + ctx, cancel := boundedKeystoreContext(ctx) + defer cancel() + if err := secretServiceAvailable(ctx); err != nil { + return err + } + exists, err := secretServiceItemExists(ctx, name) if err != nil { - if exitCode(err) == 44 { - return fmt.Errorf("%w: keychain %s: %w", ErrNotFound, name, err) - } - if strings.Contains(stderr, "User interaction is not allowed") { - return fmt.Errorf("%w: keychain locked while deleting %s: %w", ErrLocked, name, err) + return err + } + if !exists { + return fmt.Errorf("%w: Secret Service item %s", ErrNotFound, name) + } + tool, err := keystoreLookPath("secret-tool") + if err != nil { + return unavailableError("find secret-tool", err) + } + _, stderr, err := runKeystoreCommand(ctx, tool, []string{ + "clear", "service", KeychainService, "account", name, + }, nil) + if err != nil { + return secretServiceCommandError(ctx, "delete", name, stderr, err) + } + return nil +} + +func (secretServiceStore) Description() string { return "Secret Service (service abox)" } + +func secretServiceAvailable(ctx context.Context) error { + if _, err := keystoreLookPath("secret-tool"); err != nil { + return unavailableError("find secret-tool", err) + } + gdbus, err := keystoreLookPath("gdbus") + if err != nil { + return unavailableError("find gdbus", err) + } + startArgs := []string{ + "call", "--session", "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus", + "--method", "org.freedesktop.DBus.StartServiceByName", secretServiceName, "0", + } + _, startStderr, startErr := runKeystoreCommand(ctx, gdbus, startArgs, nil) + if ctx.Err() != nil { + return secretServiceCommandError(ctx, "activate provider", "", startStderr, startErr) + } + ownerArgs := []string{ + "call", "--session", "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus", + "--method", "org.freedesktop.DBus.NameHasOwner", secretServiceName, + } + stdout, ownerStderr, ownerErr := runKeystoreCommand(ctx, gdbus, ownerArgs, nil) + if ownerErr != nil { + return secretServiceCommandError(ctx, "check provider", "", ownerStderr, ownerErr) + } + if !strings.Contains(string(stdout), "true") { + if startErr != nil { + return secretServiceCommandError(ctx, "activate provider", "", startStderr, startErr) } - return keychainCommandError(ctx, "delete", name, stderr, err) + return fmt.Errorf("%w: Secret Service has no provider", ErrLocked) } return nil } -func keychainCommandError(ctx context.Context, action, name, stderr string, err error) error { +func secretServiceItemExists(ctx context.Context, name string) (bool, error) { + gdbus, err := keystoreLookPath("gdbus") + if err != nil { + return false, unavailableError("find gdbus", err) + } + attributes := fmt.Sprintf("{'service': <'%s'>, 'account': <'%s'>}", KeychainService, name) + stdout, stderr, err := runKeystoreCommand(ctx, gdbus, []string{ + "call", "--session", "--dest", secretServiceName, "--object-path", "/org/freedesktop/secrets", + "--method", "org.freedesktop.Secret.Service.SearchItems", attributes, + }, nil) + if err != nil { + return false, secretServiceCommandError(ctx, "search", name, stderr, err) + } + return bytes.Contains(stdout, []byte("objectpath '/org/freedesktop/secrets/")), nil +} + +func (unavailableStore) Available(context.Context) bool { return false } + +func (s unavailableStore) Get(_ context.Context, name string) ([]byte, error) { + return nil, fmt.Errorf("%w: OS keystore is unsupported on %s while reading %s", ErrLocked, s.goos, name) +} + +func (s unavailableStore) Set(_ context.Context, name string, _ []byte) error { + return fmt.Errorf("%w: OS keystore is unsupported on %s while writing %s", ErrLocked, s.goos, name) +} + +func (s unavailableStore) Delete(_ context.Context, name string) error { + return fmt.Errorf("%w: OS keystore is unsupported on %s while deleting %s", ErrLocked, s.goos, name) +} + +func (s unavailableStore) Description() string { return "OS keystore" } + +func boundedKeystoreContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(parent, keystoreCommandTimeout) +} + +func exitCode(err error) int { + var coder interface{ ExitCode() int } + if errors.As(err, &coder) { + return coder.ExitCode() + } + return -1 +} + +func keychainCommandError(ctx context.Context, action, name string, stderr []byte, err error) error { if ctxErr := ctx.Err(); ctxErr != nil { - return fmt.Errorf("keychain %s %s: %w", action, name, ctxErr) + return fmt.Errorf("%w: keychain %s %s: %w", ErrLocked, action, name, ctxErr) } - detail := strings.TrimSpace(stderr) + detail := strings.TrimSpace(string(stderr)) if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { - if detail != "" { - return fmt.Errorf("%w: keychain %s %s: %s: %w", ErrLocked, action, name, detail, err) - } - return fmt.Errorf("%w: keychain %s %s: %w", ErrLocked, action, name, err) + return unavailableError("keychain "+action+" "+name, err) } if detail != "" { return fmt.Errorf("keychain %s %s: %s: %w", action, name, detail, err) } return fmt.Errorf("keychain %s %s: %w", action, name, err) } + +func secretServiceCommandError(ctx context.Context, action, name string, stderr []byte, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("%w: Secret Service %s %s: %w", ErrLocked, action, name, ctxErr) + } + detail := strings.TrimSpace(string(stderr)) + if detail != "" { + return fmt.Errorf("%w: Secret Service %s %s: %s: %w", ErrLocked, action, name, detail, err) + } + return fmt.Errorf("%w: Secret Service %s %s: %w", ErrLocked, action, name, err) +} + +func unavailableError(action string, err error) error { + return fmt.Errorf("%w: OS keystore unavailable (%s): %w", ErrLocked, action, err) +} diff --git a/internal/credsource/save.go b/internal/credsource/save.go index 30a0f9e..842f4fd 100644 --- a/internal/credsource/save.go +++ b/internal/credsource/save.go @@ -9,39 +9,39 @@ import ( "github.com/AdminTurnedDevOps/ABox/internal/credentials" ) -// KeychainEnabled is swapped to false in tests: the macOS keychain is -// machine-wide, so HOME-scoped temp dirs do not isolate it. -var KeychainEnabled = KeychainAvailable +// KeystoreEnabled is swapped in tests because OS keystores are not isolated by +// HOME-scoped temporary directories. +var KeystoreEnabled = func(ctx context.Context) bool { return currentOSKeystore().Available(ctx) } type SaveResult struct { Source string - Keychain bool + Keystore bool Note string } func SavePreferred(ctx context.Context, envName, value string) (SaveResult, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - if KeychainEnabled() { - if err := SetKeychain(ctx, envName, []byte(value)); err != nil { + if KeystoreEnabled(ctx) { + if err := SetOSKeystore(ctx, envName, []byte(value)); err != nil { if !errors.Is(err, ErrLocked) { - return SaveResult{}, fmt.Errorf("keychain: %w", err) + return SaveResult{}, fmt.Errorf("keystore: %w", err) } if err := credentials.Save(envName, value); err != nil { return SaveResult{}, err } credentials.SetEnv(envName, value) - return SaveResult{Source: "env", Note: "keychain locked; saved to " + credentials.Path()}, nil + return SaveResult{Source: "env", Note: "warning: OS keystore locked or unavailable; saved plaintext fallback to " + credentials.Path() + " (mode 0600)"}, nil } if err := credentials.Delete(envName); err != nil { - return SaveResult{}, fmt.Errorf("keychain saved %s but leftover file entry could not be removed: %w", envName, err) + return SaveResult{}, fmt.Errorf("keystore saved %s but leftover file entry could not be removed: %w", envName, err) } credentials.SetEnv(envName, value) - return SaveResult{Source: "keychain", Keychain: true, Note: "key saved to macOS keychain (service abox)"}, nil + return SaveResult{Source: "keystore", Keystore: true, Note: "key saved to " + OSKeystoreDescription()}, nil } if err := credentials.Save(envName, value); err != nil { return SaveResult{}, err } credentials.SetEnv(envName, value) - return SaveResult{Source: "env", Note: "key saved to " + credentials.Path()}, nil + return SaveResult{Source: "env", Note: "warning: OS keystore unavailable; saved plaintext fallback to " + credentials.Path() + " (mode 0600)"}, nil } diff --git a/internal/guest/tools/command_guest_linux.go b/internal/guest/tools/command_guest_linux.go new file mode 100644 index 0000000..f155777 --- /dev/null +++ b/internal/guest/tools/command_guest_linux.go @@ -0,0 +1,37 @@ +//go:build linux && abox_guest + +package tools + +import ( + "os" + "os/exec" + "syscall" +) + +const ( + guestUID = 1000 + guestGID = 1000 +) + +func configureGuestCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGKILL, + } + if os.Geteuid() == 0 { + cmd.SysProcAttr.Credential = &syscall.Credential{Uid: guestUID, Gid: guestGID} + } +} + +func cleanupGuestCommand(cmd *exec.Cmd) { + if cmd != nil && cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } +} + +func setGuestOwnership(path string, symlink bool) error { + if symlink { + return os.Lchown(path, guestUID, guestGID) + } + return os.Chown(path, guestUID, guestGID) +} diff --git a/internal/guest/tools/command_guest_linux_test.go b/internal/guest/tools/command_guest_linux_test.go new file mode 100644 index 0000000..6d80c07 --- /dev/null +++ b/internal/guest/tools/command_guest_linux_test.go @@ -0,0 +1,52 @@ +//go:build linux && abox_guest + +package tools + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestGuestCommandDropsPrivilegesAndCreatesProcessGroup(t *testing.T) { + cmd := guestCommand("/bin/true") + if cmd.SysProcAttr == nil { + t.Fatal("guest command has no process attributes") + } + if os.Geteuid() == 0 { + if got := cmd.SysProcAttr.Credential; got == nil || got.Uid != guestUID || got.Gid != guestGID { + t.Fatalf("credential = %#v", got) + } + } else if cmd.SysProcAttr.Credential != nil { + t.Fatalf("unprivileged test process cannot apply credential: %#v", cmd.SysProcAttr.Credential) + } + if !cmd.SysProcAttr.Setpgid || cmd.SysProcAttr.Pdeathsig != syscall.SIGKILL { + t.Fatalf("sysprocattr = %#v", cmd.SysProcAttr) + } +} + +func TestGuestCommandBackgroundChildCannotHangWait(t *testing.T) { + repo := Repo{Root: t.TempDir()} + start := time.Now() + _, _, _, _, _, err := repo.RunContext(context.Background(), "sleep 30 &", filepath.Clean("."), 5*time.Second, 1024) + if err == nil { + t.Fatal("background child unexpectedly outlived command without an error") + } + if elapsed := time.Since(start); elapsed < 500*time.Millisecond { + t.Fatalf("command failed before exercising bounded wait: %v", err) + } else if elapsed > 4*time.Second { + t.Fatalf("background child delayed command cleanup for %s", elapsed) + } +} + +func TestConfigureGuestCommandOverridesNoExecutable(t *testing.T) { + cmd := exec.Command("/bin/true") + configureGuestCommand(cmd) + if cmd.Path != "/bin/true" { + t.Fatalf("path = %q", cmd.Path) + } +} diff --git a/internal/guest/tools/command_stub.go b/internal/guest/tools/command_stub.go new file mode 100644 index 0000000..71a58e9 --- /dev/null +++ b/internal/guest/tools/command_stub.go @@ -0,0 +1,9 @@ +//go:build !linux || !abox_guest + +package tools + +import "os/exec" + +func configureGuestCommand(*exec.Cmd) {} +func cleanupGuestCommand(*exec.Cmd) {} +func setGuestOwnership(string, bool) error { return nil } diff --git a/internal/guest/tools/freeze_linux.go b/internal/guest/tools/freeze_linux.go index 040f99a..3004cef 100644 --- a/internal/guest/tools/freeze_linux.go +++ b/internal/guest/tools/freeze_linux.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux && abox_guest package tools diff --git a/internal/guest/tools/freeze_stub.go b/internal/guest/tools/freeze_stub.go index dddb00a..33de15f 100644 --- a/internal/guest/tools/freeze_stub.go +++ b/internal/guest/tools/freeze_stub.go @@ -1,13 +1,12 @@ -//go:build !linux +//go:build !linux || !abox_guest package tools import "fmt" -// Freeze is a GOOS link stub so package tools builds on the host. The ioctl -// is freeze_linux.go (guest). Host code does not call this. Not a placeholder -// for host-side filesystem freeze. -func Freeze() error { return fmt.Errorf("FIFREEZE only available on linux") } +// Freeze is deliberately selected by untagged host builds, including Linux. +// Only an explicit abox_guest build may contain the root-filesystem ioctl. +func Freeze() error { return fmt.Errorf("FIFREEZE only available in a tagged Linux guest build") } // Thaw is the matching GOOS link stub. See Freeze. -func Thaw() error { return fmt.Errorf("FITHAW only available on linux") } +func Thaw() error { return fmt.Errorf("FITHAW only available in a tagged Linux guest build") } diff --git a/internal/guest/tools/freeze_stub_test.go b/internal/guest/tools/freeze_stub_test.go new file mode 100644 index 0000000..4c50969 --- /dev/null +++ b/internal/guest/tools/freeze_stub_test.go @@ -0,0 +1,17 @@ +//go:build !abox_guest + +package tools + +import ( + "strings" + "testing" +) + +func TestHostBuildCannotFreezeFilesystem(t *testing.T) { + if err := Freeze(); err == nil || !strings.Contains(err.Error(), "tagged Linux guest build") { + t.Fatalf("Freeze returned %v; untagged builds must use the non-freezing stub", err) + } + if err := Thaw(); err == nil || !strings.Contains(err.Error(), "tagged Linux guest build") { + t.Fatalf("Thaw returned %v; untagged builds must use the non-freezing stub", err) + } +} diff --git a/internal/guest/tools/tools.go b/internal/guest/tools/tools.go index 457c9ad..6540412 100644 --- a/internal/guest/tools/tools.go +++ b/internal/guest/tools/tools.go @@ -14,6 +14,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/AdminTurnedDevOps/ABox/protocol" ) const ( @@ -177,12 +179,12 @@ func (r Repo) ApplyPatch(patch string) (string, error) { if len(patch) > DefaultMaxOutput { return "", fmt.Errorf("patch too large") } - cmd := exec.Command("git", "apply", "--whitespace=nowarn", "-") + cmd := guestCommand("git", "apply", "--whitespace=nowarn", "-") cmd.Dir = r.Root cmd.Stdin = strings.NewReader(patch) out, err := cmd.CombinedOutput() if err != nil { - cmd = exec.Command("patch", "-p1", "--forward") + cmd = guestCommand("patch", "-p1", "--forward") cmd.Dir = r.Root cmd.Stdin = strings.NewReader(patch) out2, err2 := cmd.CombinedOutput() @@ -213,11 +215,11 @@ func (r Repo) RunContext(ctx context.Context, command, workdir string, timeout t } dir = resolved } - cmd := exec.Command("/bin/sh", "-c", command) + cmd := guestCommand("/bin/sh", "-c", command) cmd.Dir = dir cmd.Env = []string{ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "HOME=/root", + "HOME=/home/abox", "LANG=C", "TERM=dumb", "TMPDIR=/tmp", @@ -247,7 +249,7 @@ func (r Repo) InitBaseline() error { if err := os.MkdirAll(r.Root, 0o755); err != nil { return err } - cmd := exec.Command("git", "init") + cmd := guestCommand("git", "init") cmd.Dir = r.Root cmd.Env = []string{ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", @@ -259,18 +261,18 @@ func (r Repo) InitBaseline() error { if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("git init: %w: %s", err, out) } - cfg := exec.Command("git", "config", "user.email", "abox-guest@abox.local") + cfg := guestCommand("git", "config", "user.email", "abox-guest@abox.local") cfg.Dir = r.Root _ = cfg.Run() - cfg = exec.Command("git", "config", "user.name", "abox-guest") + cfg = guestCommand("git", "config", "user.name", "abox-guest") cfg.Dir = r.Root _ = cfg.Run() - add := exec.Command("git", "add", "-f", "-A") + add := guestCommand("git", "add", "-f", "-A") add.Dir = r.Root if out, err := add.CombinedOutput(); err != nil { return fmt.Errorf("git add baseline: %w: %s", err, out) } - commit := exec.Command("git", "commit", "--allow-empty", "-m", "abox baseline") + commit := guestCommand("git", "commit", "--allow-empty", "-m", "abox baseline") commit.Dir = r.Root commit.Env = []string{ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", @@ -286,16 +288,16 @@ func (r Repo) InitBaseline() error { } func (r Repo) ExportPatch() (string, string, error) { - cmd := exec.Command("git", "diff", "HEAD") + cmd := guestCommand("git", "diff", "HEAD") cmd.Dir = r.Root out, err := cmd.Output() if err != nil { return "", "", err } - stat := exec.Command("git", "diff", "--stat", "HEAD") + stat := guestCommand("git", "diff", "--stat", "HEAD") stat.Dir = r.Root summary, _ := stat.Output() - untracked := exec.Command("git", "ls-files", "--others", "--exclude-standard") + untracked := guestCommand("git", "ls-files", "--others", "--exclude-standard") untracked.Dir = r.Root extra, _ := untracked.Output() if len(bytes.TrimSpace(extra)) > 0 { @@ -309,6 +311,9 @@ func ExtractTar(r io.Reader, dest string) error { if err := os.MkdirAll(dest, 0o755); err != nil { return err } + if err := setGuestOwnership(dest, false); err != nil { + return err + } tr := tar.NewReader(r) var files, bytesN int for { @@ -320,10 +325,10 @@ func ExtractTar(r io.Reader, dest string) error { return err } files++ - if files > 20000 { + if files > protocol.MaxArchiveFiles { return fmt.Errorf("too many files") } - if hdr.Size > 32<<20 { + if hdr.Size > protocol.MaxArchiveFile { return fmt.Errorf("file too large") } name := filepath.Clean(hdr.Name) @@ -343,10 +348,16 @@ func ExtractTar(r io.Reader, dest string) error { if err := os.MkdirAll(target, 0o755); err != nil { return err } + if err := setGuestOwnership(target, false); err != nil { + return err + } case tar.TypeReg, tar.TypeRegA: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } + if err := setGuestParentOwnership(dest, target); err != nil { + return err + } f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm()) if err != nil { return err @@ -359,21 +370,52 @@ func ExtractTar(r io.Reader, dest string) error { if n > hdr.Size { return fmt.Errorf("file size mismatch") } + if err := setGuestOwnership(target, false); err != nil { + return err + } bytesN += int(n) - if bytesN > 256<<20 { + if bytesN > protocol.MaxArchiveBytes { return fmt.Errorf("archive too large") } case tar.TypeSymlink: if filepath.IsAbs(hdr.Linkname) || strings.Contains(filepath.Clean(hdr.Linkname), "..") { return fmt.Errorf("unsafe symlink") } - _ = os.Symlink(hdr.Linkname, target) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := setGuestParentOwnership(dest, target); err != nil { + return err + } + if err := os.Symlink(hdr.Linkname, target); err != nil { + return err + } + if err := setGuestOwnership(target, true); err != nil { + return err + } default: return fmt.Errorf("unsupported tar type %v", hdr.Typeflag) } } } +func guestCommand(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + configureGuestCommand(cmd) + return cmd +} + +func setGuestParentOwnership(dest, target string) error { + for dir := filepath.Dir(target); ; dir = filepath.Dir(dir) { + if err := setGuestOwnership(dir, false); err != nil { + return err + } + if dir == dest { + return nil + } + } +} + type limitedBuffer struct { buf bytes.Buffer limit int @@ -401,9 +443,11 @@ func runWithTimeout(cmd *exec.Cmd, d time.Duration) error { } func runWithContext(cmd *exec.Cmd, ctx context.Context, d time.Duration) error { + cmd.WaitDelay = time.Second if err := cmd.Start(); err != nil { return err } + defer cleanupGuestCommand(cmd) done := make(chan error, 1) go func() { done <- cmd.Wait() }() timer := time.NewTimer(d) @@ -412,12 +456,21 @@ func runWithContext(cmd *exec.Cmd, ctx context.Context, d time.Duration) error { case err := <-done: return err case <-timer.C: + cleanupGuestCommand(cmd) _ = cmd.Process.Kill() - <-done + waitGuestCommand(done) return fmt.Errorf("timeout after %s", d) case <-ctx.Done(): + cleanupGuestCommand(cmd) _ = cmd.Process.Kill() - <-done + waitGuestCommand(done) return ctx.Err() } } + +func waitGuestCommand(done <-chan error) { + select { + case <-done: + case <-time.After(2 * time.Second): + } +} diff --git a/internal/guest/tools/tools_test.go b/internal/guest/tools/tools_test.go index 3096b6a..c46c747 100644 --- a/internal/guest/tools/tools_test.go +++ b/internal/guest/tools/tools_test.go @@ -1,6 +1,8 @@ package tools import ( + "archive/tar" + "bytes" "context" "encoding/json" "os" @@ -12,6 +14,32 @@ import ( "github.com/AdminTurnedDevOps/ABox/protocol" ) +func TestExtractTarAllowsLargeFileWithinArchiveBudget(t *testing.T) { + body := make([]byte, 33<<20) + var archive bytes.Buffer + tw := tar.NewWriter(&archive) + if err := tw.WriteHeader(&tar.Header{Name: "dependency.zip", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := ExtractTar(bytes.NewReader(archive.Bytes()), dest); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dest, "dependency.zip")) + if err != nil { + t.Fatal(err) + } + if info.Size() != int64(len(body)) { + t.Fatalf("size=%d want=%d", info.Size(), len(body)) + } +} + func TestBuiltinSpecsCount(t *testing.T) { if n := len(BuiltinSpecs()); n != 5 { t.Fatalf("want 5 builtins, got %d", n) diff --git a/internal/guestimage/lock_other.go b/internal/guestimage/lock_other.go new file mode 100644 index 0000000..4057eab --- /dev/null +++ b/internal/guestimage/lock_other.go @@ -0,0 +1,18 @@ +//go:build !linux && !darwin + +package guestimage + +import ( + "fmt" + "os" +) + +type Lock struct{} + +func AcquireSharedLock(string) (*Lock, error) { + return nil, fmt.Errorf("image generation locks are unsupported on this platform") +} + +func (l *Lock) Close() error { return nil } + +func openManifest(path string) (*os.File, error) { return os.Open(path) } diff --git a/internal/guestimage/lock_unix.go b/internal/guestimage/lock_unix.go new file mode 100644 index 0000000..fe7bc48 --- /dev/null +++ b/internal/guestimage/lock_unix.go @@ -0,0 +1,49 @@ +//go:build linux || darwin + +package guestimage + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +type Lock struct { + file *os.File +} + +func openManifest(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} + +func AcquireSharedLock(imagePath string) (*Lock, error) { + path := filepath.Join(filepath.Dir(imagePath), ".abox-images.lock") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("open image generation lock: %w", err) + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_SH); err != nil { + f.Close() + return nil, fmt.Errorf("lock image generation: %w", err) + } + return &Lock{file: f}, nil +} + +func (l *Lock) Close() error { + if l == nil || l.file == nil { + return nil + } + err := unix.Flock(int(l.file.Fd()), unix.LOCK_UN) + closeErr := l.file.Close() + l.file = nil + if err != nil { + return err + } + return closeErr +} diff --git a/internal/guestimage/manifest.go b/internal/guestimage/manifest.go new file mode 100644 index 0000000..225a04b --- /dev/null +++ b/internal/guestimage/manifest.go @@ -0,0 +1,114 @@ +package guestimage + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const Schema = 1 + +var ErrManifestMissing = errors.New("guest image manifest missing") + +type Manifest struct { + Schema int `json:"schema"` + Arch string `json:"arch"` + ImageID string `json:"image_id"` + Protocol int `json:"protocol"` + SHA256 string `json:"sha256"` +} + +type Image struct { + Path string + Manifest Manifest +} + +func Load(path string) (Image, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return Image{}, err + } + resolved, err = filepath.Abs(resolved) + if err != nil { + return Image{}, err + } + info, err := os.Stat(resolved) + if err != nil { + return Image{}, err + } + if !info.Mode().IsRegular() { + return Image{}, fmt.Errorf("guest image is not a regular file: %s", resolved) + } + + manifestPath := resolved + ".manifest.json" + f, err := openManifest(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return Image{}, fmt.Errorf("%w: %s", ErrManifestMissing, manifestPath) + } + return Image{}, fmt.Errorf("read guest image manifest: %w", err) + } + defer f.Close() + info, err = f.Stat() + if err != nil { + return Image{}, fmt.Errorf("stat guest image manifest: %w", err) + } + if info.Size() > 64<<10 { + return Image{}, fmt.Errorf("guest image manifest is too large") + } + if !info.Mode().IsRegular() { + return Image{}, fmt.Errorf("guest image manifest is not a regular file") + } + dec := json.NewDecoder(io.LimitReader(f, 64<<10)) + dec.DisallowUnknownFields() + var manifest Manifest + if err := dec.Decode(&manifest); err != nil { + return Image{}, fmt.Errorf("decode guest image manifest: %w", err) + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return Image{}, fmt.Errorf("decode guest image manifest: trailing data") + } + if err := manifest.Validate(); err != nil { + return Image{}, err + } + return Image{Path: resolved, Manifest: manifest}, nil +} + +func (m Manifest) Validate() error { + if m.Schema != Schema { + return fmt.Errorf("unsupported guest image manifest schema %d", m.Schema) + } + if m.Arch != "amd64" && m.Arch != "arm64" { + return fmt.Errorf("unsupported guest image architecture %q", m.Arch) + } + if strings.TrimSpace(m.ImageID) == "" { + return fmt.Errorf("guest image manifest has an empty image_id") + } + if m.Protocol <= 0 { + return fmt.Errorf("guest image manifest has invalid protocol %d", m.Protocol) + } + digest, err := hex.DecodeString(m.SHA256) + if err != nil || len(digest) != sha256.Size || strings.ToLower(m.SHA256) != m.SHA256 { + return fmt.Errorf("guest image manifest has invalid sha256") + } + return nil +} + +func Digest(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/guestimage/manifest_test.go b/internal/guestimage/manifest_test.go new file mode 100644 index 0000000..34adc86 --- /dev/null +++ b/internal/guestimage/manifest_test.go @@ -0,0 +1,54 @@ +package guestimage + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadResolvesPointerAndValidatesManifest(t *testing.T) { + dir := t.TempDir() + image := filepath.Join(dir, "generation.raw") + if err := os.WriteFile(image, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + digest, err := Digest(image) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(Manifest{Schema: Schema, Arch: "amd64", ImageID: "id", Protocol: 4, SHA256: digest}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(image+".manifest.json", data, 0o600); err != nil { + t.Fatal(err) + } + pointer := filepath.Join(dir, "current.raw") + if err := os.Symlink(filepath.Base(image), pointer); err != nil { + t.Fatal(err) + } + got, err := Load(pointer) + if err != nil { + t.Fatal(err) + } + if got.Path != image || got.Manifest.SHA256 != digest { + t.Fatalf("got %#v", got) + } +} + +func TestManifestValidation(t *testing.T) { + validDigest := strings.Repeat("a", 64) + for _, manifest := range []Manifest{ + {Schema: 2, Arch: "amd64", ImageID: "id", Protocol: 4, SHA256: validDigest}, + {Schema: 1, Arch: "386", ImageID: "id", Protocol: 4, SHA256: validDigest}, + {Schema: 1, Arch: "amd64", Protocol: 4, SHA256: validDigest}, + {Schema: 1, Arch: "amd64", ImageID: "id", Protocol: 0, SHA256: validDigest}, + {Schema: 1, Arch: "amd64", ImageID: "id", Protocol: 4, SHA256: "bad"}, + } { + if err := manifest.Validate(); err == nil { + t.Fatalf("accepted %#v", manifest) + } + } +} diff --git a/internal/mcpauth/oauth.go b/internal/mcpauth/oauth.go index b044047..f5e3d83 100644 --- a/internal/mcpauth/oauth.go +++ b/internal/mcpauth/oauth.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "os/exec" + "runtime" "strings" "time" @@ -57,9 +58,18 @@ type tokenResp struct { var savePreferred = credsource.SavePreferred var ( - oauthURLValidator = validatePublicOAuthURL - lookupOAuthHost = net.DefaultResolver.LookupIPAddr - blockedOAuthNets = []*net.IPNet{ + oauthURLValidator = validatePublicOAuthURL + lookupOAuthHost = net.DefaultResolver.LookupIPAddr + browserLookPath = trustedBrowserLauncher + oauthStderr io.Writer = os.Stderr + oauthCallbackWait = 5 * time.Minute + runBrowserCommand = func(ctx context.Context, path string, args ...string) error { + cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = browserEnvironment() + cmd.WaitDelay = time.Second + return cmd.Run() + } + blockedOAuthNets = []*net.IPNet{ mustCIDR("0.0.0.0/8"), mustCIDR("100.64.0.0/10"), mustCIDR("192.0.0.0/24"), @@ -72,6 +82,38 @@ var ( } ) +func trustedBrowserLauncher(name string) (string, error) { + var path string + switch name { + case "open": + path = "/usr/bin/open" + case "xdg-open": + path = "/usr/bin/xdg-open" + default: + return "", fmt.Errorf("unsupported browser launcher %q", name) + } + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("browser launcher is not a regular executable: %s", path) + } + return path, nil +} + +func browserEnvironment() []string { + env := []string{"PATH=/usr/bin:/bin"} + for _, name := range []string{ + "HOME", "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "LANG", "LC_ALL", + } { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + return env +} + func mustCIDR(raw string) *net.IPNet { _, network, err := net.ParseCIDR(raw) if err != nil { @@ -120,7 +162,8 @@ func LoginNamed(ctx context.Context, cfg config.File, name string) error { } func persistCredentialReference(cfg config.File, serverName, credentialName, source string) error { - if source != "env" && source != "keychain" { + source = config.CanonicalCredentialSource(source) + if source != "env" && source != "keystore" { return fmt.Errorf("mcp %s token saved to unknown credential source %q", serverName, source) } for i := range cfg.MCPServers { @@ -226,7 +269,7 @@ func Login(ctx context.Context, srv config.MCPServer, opts Options) (Result, err authURL := authorizeURL(as.AuthorizationEndpoint, clientID, redir, challenge, state, resource, scope) open := opts.OpenURL if open == nil { - open = openBrowser + open = func(raw string) error { return openBrowser(ctx, raw) } } codeCh := make(chan string, 1) errCh := make(chan error, 1) @@ -234,17 +277,24 @@ func Login(ctx context.Context, srv config.MCPServer, opts Options) (Result, err if err := open(authURL); err != nil { return Result{}, fmt.Errorf("open browser: %w", err) } - var code string + code, err := waitForOAuthCallback(ctx, codeCh, errCh) + if err != nil { + return Result{}, err + } + return exchangeCode(ctx, client, as.TokenEndpoint, clientID, redir, code, verifier, resource) +} + +func waitForOAuthCallback(ctx context.Context, codeCh <-chan string, errCh <-chan error) (string, error) { select { case <-ctx.Done(): - return Result{}, ctx.Err() + return "", ctx.Err() case err := <-errCh: - return Result{}, err - case code = <-codeCh: - case <-time.After(5 * time.Minute): - return Result{}, fmt.Errorf("oauth timed out waiting for browser callback") + return "", err + case code := <-codeCh: + return code, nil + case <-time.After(oauthCallbackWait): + return "", fmt.Errorf("oauth timed out waiting for browser callback") } - return exchangeCode(ctx, client, as.TokenEndpoint, clientID, redir, code, verifier, resource) } func withoutRedirects(client *http.Client) *http.Client { @@ -663,6 +713,40 @@ func randomHex(n int) (string, error) { return hex.EncodeToString(b), nil } -func openBrowser(raw string) error { - return exec.Command("open", raw).Start() +func openBrowser(ctx context.Context, raw string) error { + return openBrowserForPlatform(ctx, runtime.GOOS, raw) +} + +func openBrowserForPlatform(ctx context.Context, goos, raw string) error { + launcher := browserLauncher(goos) + if launcher == "" { + printManualURL(raw, "no graphical browser launcher is supported on this platform") + return nil + } + path, err := browserLookPath(launcher) + if err != nil { + printManualURL(raw, launcher+" is unavailable") + return nil + } + launchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := runBrowserCommand(launchCtx, path, raw); err != nil { + printManualURL(raw, launcher+" failed: "+err.Error()) + } + return nil +} + +func browserLauncher(goos string) string { + switch goos { + case "darwin": + return "open" + case "linux": + return "xdg-open" + default: + return "" + } +} + +func printManualURL(raw, reason string) { + fmt.Fprintf(oauthStderr, "abox: %s; open this URL in a browser:\n%s\n", reason, raw) } diff --git a/internal/mcpauth/oauth_test.go b/internal/mcpauth/oauth_test.go index 7bcbfe3..1cc732e 100644 --- a/internal/mcpauth/oauth_test.go +++ b/internal/mcpauth/oauth_test.go @@ -1,8 +1,10 @@ package mcpauth import ( + "bytes" "context" "encoding/json" + "errors" "io" "net" "net/http" @@ -10,6 +12,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/AdminTurnedDevOps/ABox/internal/config" "github.com/AdminTurnedDevOps/ABox/internal/credentials" @@ -232,11 +235,10 @@ func TestLoginNamedPersistsNoRefreshToken(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("ABOX_HOME", "") - // Never touch the real macOS keychain from tests: force the file-store - // fallback path of the keychain-preferred writer. - origKC := credsource.KeychainEnabled - credsource.KeychainEnabled = func() bool { return false } - t.Cleanup(func() { credsource.KeychainEnabled = origKC }) + // Never touch the real OS keystore from tests: force the file-store fallback. + origKS := credsource.KeystoreEnabled + credsource.KeystoreEnabled = func(context.Context) bool { return false } + t.Cleanup(func() { credsource.KeystoreEnabled = origKS }) cfg := config.Defaults() cfg.MCPServers = []config.MCPServer{{ @@ -269,7 +271,7 @@ func TestLoginNamedPersistsNoRefreshToken(t *testing.T) { } } -func TestLoginNamedPersistsKeychainReference(t *testing.T) { +func TestLoginNamedPersistsKeystoreReference(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("ABOX_HOME", "") @@ -279,7 +281,7 @@ func TestLoginNamedPersistsKeychainReference(t *testing.T) { if name != "CUSTOM_MCP_TOKEN" || value != "pat-value" { t.Fatalf("save %q=%q", name, value) } - return credsource.SaveResult{Source: "keychain", Keychain: true, Note: "keychain"}, nil + return credsource.SaveResult{Source: "keystore", Keystore: true, Note: "keystore"}, nil } t.Cleanup(func() { savePreferred = origSave }) @@ -297,7 +299,100 @@ func TestLoginNamedPersistsKeychainReference(t *testing.T) { t.Fatal(err) } server := savedCfg.MCPServers[0] - if server.CredentialEnv != "" || server.Credential == nil || *server.Credential != (config.CredentialRef{Source: "keychain", Name: "CUSTOM_MCP_TOKEN"}) { + if server.CredentialEnv != "" || server.Credential == nil || *server.Credential != (config.CredentialRef{Source: "keystore", Name: "CUSTOM_MCP_TOKEN"}) { t.Fatalf("saved server %#v", server) } } + +func TestPersistCredentialReferenceCanonicalizesAliases(t *testing.T) { + for _, source := range []string{"keystore", "keychain", "secretservice"} { + t.Run(source, func(t *testing.T) { + t.Setenv("ABOX_HOME", t.TempDir()) + cfg := config.Defaults() + cfg.MCPServers = []config.MCPServer{{Name: "gh", URL: "https://mcp.example/api"}} + if err := persistCredentialReference(cfg, "gh", "MCP_TOKEN", source); err != nil { + t.Fatal(err) + } + saved, _, err := config.Load() + if err != nil { + t.Fatal(err) + } + if got := saved.MCPServers[0].CredentialReference().Source; got != "keystore" { + t.Fatalf("source %q", got) + } + }) + } +} + +func TestBrowserLauncherByPlatform(t *testing.T) { + if got := browserLauncher("darwin"); got != "open" { + t.Fatalf("darwin launcher %q", got) + } + if got := browserLauncher("linux"); got != "xdg-open" { + t.Fatalf("linux launcher %q", got) + } + if got := browserLauncher("windows"); got != "" { + t.Fatalf("unsupported launcher %q", got) + } +} + +func TestOpenBrowserLaunchFailurePrintsManualURL(t *testing.T) { + origLookPath := browserLookPath + origRun := runBrowserCommand + origStderr := oauthStderr + var output bytes.Buffer + browserLookPath = func(name string) (string, error) { + if name != "xdg-open" { + t.Fatalf("launcher %q", name) + } + return "/fake/xdg-open", nil + } + runBrowserCommand = func(_ context.Context, path string, args ...string) error { + if path != "/fake/xdg-open" || len(args) != 1 || args[0] != "https://auth.example/authorize" { + t.Fatalf("command %q %v", path, args) + } + return errors.New("no display") + } + oauthStderr = &output + t.Cleanup(func() { + browserLookPath = origLookPath + runBrowserCommand = origRun + oauthStderr = origStderr + }) + + if err := openBrowserForPlatform(context.Background(), "linux", "https://auth.example/authorize"); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, "no display") || !strings.Contains(got, "https://auth.example/authorize") { + t.Fatalf("manual flow output %q", got) + } +} + +func TestOpenBrowserMissingLauncherPrintsManualURL(t *testing.T) { + origLookPath := browserLookPath + origStderr := oauthStderr + var output bytes.Buffer + browserLookPath = func(string) (string, error) { return "", errors.New("missing") } + oauthStderr = &output + t.Cleanup(func() { + browserLookPath = origLookPath + oauthStderr = origStderr + }) + + if err := openBrowserForPlatform(context.Background(), "linux", "https://auth.example/manual"); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "https://auth.example/manual") { + t.Fatalf("manual URL missing: %q", output.String()) + } +} + +func TestOAuthCallbackTimeout(t *testing.T) { + origWait := oauthCallbackWait + oauthCallbackWait = time.Millisecond + t.Cleanup(func() { oauthCallbackWait = origWait }) + _, err := waitForOAuthCallback(context.Background(), make(chan string), make(chan error)) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("got %v", err) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 8aedd51..ce1a92c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -7,114 +7,292 @@ import ( "bytes" "fmt" "io" - "io/fs" "os" + "os/exec" "path/filepath" -) + "strings" -const ( - maxArchiveEntries = 20000 - maxArchiveFile = 32 << 20 - maxArchiveBytes = 256 << 20 + "github.com/AdminTurnedDevOps/ABox/protocol" ) -// ArchiveDirectory creates a bounded tar snapshot of exactly sourceDir. Git -// metadata is excluded because the guest creates its own private baseline. -func ArchiveDirectory(sourceDir string) (string, []byte, error) { - root, err := filepath.Abs(sourceDir) +type Snapshot struct { + Root string + HEAD string + Ephemeral bool + HostSource string +} + +func ValidateClean(start string) (Snapshot, error) { + root, err := gitOutput(start, "rev-parse", "--show-toplevel") if err != nil { - return "", nil, fmt.Errorf("resolve source directory: %w", err) + return Snapshot{}, fmt.Errorf("not a git worktree: %w", err) } - root = filepath.Clean(root) - info, err := os.Stat(root) + head, err := gitOutput(root, "rev-parse", "HEAD") if err != nil { - return "", nil, fmt.Errorf("source directory: %w", err) + return Snapshot{}, fmt.Errorf("repository has no commits; create an initial commit so ABox can snapshot HEAD") + } + status, err := gitOutput(root, "status", "--porcelain") + if err != nil { + return Snapshot{}, fmt.Errorf("git status: %w", err) + } + if strings.TrimSpace(status) != "" { + return Snapshot{}, fmt.Errorf("worktree is not clean; commit or stash before starting ABox") } - if !info.IsDir() { - return "", nil, fmt.Errorf("source path is not a directory: %s", root) + if hasUnsupportedSubmodules(root) { + return Snapshot{}, fmt.Errorf("submodules are not supported in milestone one") } + return Snapshot{Root: root, HEAD: head, HostSource: root}, nil +} - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - entries := 0 - totalBytes := int64(0) - err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr +// OpenForSession uses a clean committed worktree when one exists. Otherwise it +// copies tracked and non-ignored untracked files into a private repository. +func OpenForSession(start, scratchDir string) (Snapshot, error) { + return OpenForSessionExcluding(start, scratchDir) +} + +// OpenForSessionExcluding also omits host-only paths such as ~/.abox. +func OpenForSessionExcluding(start, scratchDir string, excludedPaths ...string) (Snapshot, error) { + root, err := TopLevel(start) + if err != nil { + return Snapshot{}, fmt.Errorf("not a git worktree: %w", err) + } + excluded, err := exclusionsWithin(root, excludedPaths) + if err != nil { + return Snapshot{}, err + } + if len(excluded) == 0 { + if snap, err := ValidateClean(root); err == nil { + return snap, nil } - if path == root { - return nil + } + if hasUnsupportedSubmodules(root) { + return Snapshot{}, fmt.Errorf("submodules are not supported in milestone one") + } + if err := copyWorktreeExcluding(root, scratchDir, excluded); err != nil { + return Snapshot{}, fmt.Errorf("ephemeral snapshot: %w", err) + } + if err := initScratchRepo(scratchDir); err != nil { + return Snapshot{}, err + } + snap, err := ValidateClean(scratchDir) + if err != nil { + return Snapshot{}, fmt.Errorf("ephemeral snapshot: %w", err) + } + snap.Ephemeral = true + snap.HostSource = root + return snap, nil +} + +func StillClean(s Snapshot) error { + cur, err := ValidateClean(s.Root) + if err != nil { + return err + } + if cur.HEAD != s.HEAD { + return fmt.Errorf("host HEAD moved from %s to %s", s.HEAD, cur.HEAD) + } + return nil +} + +func ArchiveHEAD(root string) ([]byte, error) { + cmd := exec.Command("git", "archive", "--format=tar", "HEAD") + cmd.Dir = root + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git archive: %w: %s", err, strings.TrimSpace(stderr.String())) + } + archive := stdout.Bytes() + if err := validateArchive(archive); err != nil { + return nil, err + } + return archive, nil +} + +func TopLevel(start string) (string, error) { + return gitOutput(start, "rev-parse", "--show-toplevel") +} + +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return "", fmt.Errorf("%w: %s", err, msg) + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func hasUnsupportedSubmodules(root string) bool { + _, err := os.Stat(filepath.Join(root, ".gitmodules")) + return err == nil +} + +func exclusionsWithin(root string, paths []string) ([]string, error) { + var excluded []string + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue } - if entry.Name() == ".git" { - if entry.IsDir() { - return fs.SkipDir + absolute, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve excluded path: %w", err) + } + absolute = filepath.Clean(absolute) + if absolute == filepath.Clean(root) { + return nil, fmt.Errorf("source directory %q is host-only ABox state; run abox from a Git worktree", root) + } + if pathWithin(root, absolute) { + excluded = append(excluded, absolute) + } + } + return excluded, nil +} + +func pathWithin(root, path string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + return err == nil && rel != ".." && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func copyWorktreeExcluding(src, dst string, excluded []string) error { + if err := os.MkdirAll(dst, 0o700); err != nil { + return err + } + cmd := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z") + cmd.Dir = src + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("git ls-files: %w: %s", err, strings.TrimSpace(stderr.String())) + } + for _, name := range bytes.Split(out, []byte{0}) { + if len(name) == 0 { + continue + } + rel := filepath.FromSlash(string(name)) + clean := filepath.Clean(rel) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("unsafe repository path %q", rel) + } + path := filepath.Join(src, clean) + skip := false + for _, excludedPath := range excluded { + if pathWithin(excludedPath, path) { + skip = true + break } - return nil + } + if skip { + continue } - entries++ - if entries > maxArchiveEntries { - return fmt.Errorf("source directory has more than %d entries", maxArchiveEntries) + var info os.FileInfo + current := src + parts := strings.Split(clean, string(filepath.Separator)) + for i, part := range parts { + current = filepath.Join(current, part) + info, err = os.Lstat(current) + if err != nil { + break + } + if info.Mode()&os.ModeSymlink != 0 || i < len(parts)-1 && !info.IsDir() { + return fmt.Errorf("unsupported file type %q", rel) + } } - info, err := entry.Info() if err != nil { + if os.IsNotExist(err) { + continue + } return err } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() && !info.Mode().IsRegular() { - return fmt.Errorf("unsupported file type %q", path) + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type %q", rel) } - if info.Size() > maxArchiveFile { - return fmt.Errorf("file %q exceeds %d bytes", path, maxArchiveFile) + if info.Size() > protocol.MaxArchiveFile { + return fmt.Errorf("file %q exceeds %d bytes", path, protocol.MaxArchiveFile) } - - rel, err := filepath.Rel(root, path) - if err != nil { + target := filepath.Join(dst, clean) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } - header, err := tar.FileInfoHeader(info, "") + data, err := os.ReadFile(path) if err != nil { return err } - header.Name = filepath.ToSlash(rel) - if info.IsDir() { - header.Name += "/" + if err := os.WriteFile(target, data, info.Mode().Perm()); err != nil { + return err } - if err := tw.WriteHeader(header); err != nil { + if err := os.Chmod(target, info.Mode().Perm()); err != nil { return err } - if info.IsDir() { - return nil + } + return nil +} + +func initScratchRepo(dir string) error { + cmds := [][]string{ + {"git", "init", "-b", "main"}, + {"git", "add", "-f", "-A"}, + {"git", "commit", "--allow-empty", "-m", "abox ephemeral snapshot"}, + } + env := append(os.Environ(), + "GIT_AUTHOR_NAME=abox", + "GIT_AUTHOR_EMAIL=abox@local", + "GIT_COMMITTER_NAME=abox", + "GIT_COMMITTER_EMAIL=abox@local", + ) + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, out) } + } + return nil +} - totalBytes += info.Size() - if totalBytes > maxArchiveBytes { - return fmt.Errorf("source directory exceeds %d bytes", maxArchiveBytes) +func validateArchive(data []byte) error { + tr := tar.NewReader(bytes.NewReader(data)) + entries := 0 + totalBytes := int64(0) + for { + header, err := tr.Next() + if err == io.EOF { + return nil } - file, err := os.Open(path) if err != nil { - return err + return fmt.Errorf("validate repository archive: %w", err) } - openedInfo, statErr := file.Stat() - if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { - file.Close() - if statErr != nil { - return statErr - } - return fmt.Errorf("source file changed while snapshotting: %q", path) + switch header.Typeflag { + case tar.TypeXHeader, tar.TypeXGlobalHeader, tar.TypeGNULongName, tar.TypeGNULongLink: + continue + case tar.TypeDir, tar.TypeReg, tar.TypeRegA: + default: + return fmt.Errorf("unsupported archive file type %q", header.Name) } - _, copyErr := io.CopyN(tw, file, info.Size()) - closeErr := file.Close() - if copyErr != nil { - return fmt.Errorf("snapshot %q: %w", path, copyErr) + clean := filepath.Clean(filepath.FromSlash(header.Name)) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("unsafe repository archive path %q", header.Name) + } + entries++ + if entries > protocol.MaxArchiveFiles { + return fmt.Errorf("source directory has more than %d entries", protocol.MaxArchiveFiles) + } + if header.Size > protocol.MaxArchiveFile { + return fmt.Errorf("file %q exceeds %d bytes", header.Name, protocol.MaxArchiveFile) + } + totalBytes += header.Size + if totalBytes > protocol.MaxArchiveBytes { + return fmt.Errorf("source directory exceeds %d bytes", protocol.MaxArchiveBytes) } - return closeErr - }) - if err != nil { - _ = tw.Close() - return "", nil, err - } - if err := tw.Close(); err != nil { - return "", nil, err } - return root, buf.Bytes(), nil } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index ecaa88b..6a47a27 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -5,6 +5,7 @@ import ( "bytes" "io" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -13,140 +14,197 @@ import ( type archiveEntry struct { body string mode int64 - dir bool } -func readArchive(t *testing.T, data []byte) map[string]archiveEntry { +func runGit(t *testing.T, dir string, args ...string) string { t.Helper() - out := map[string]archiveEntry{} - tr := tar.NewReader(bytes.NewReader(data)) - for { - header, err := tr.Next() - if err == io.EOF { - return out - } - if err != nil { - t.Fatal(err) - } - body, err := io.ReadAll(tr) - if err != nil { - t.Fatal(err) - } - out[strings.TrimSuffix(header.Name, "/")] = archiveEntry{ - body: string(body), mode: header.Mode, dir: header.FileInfo().IsDir(), - } + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=abox-test", + "GIT_AUTHOR_EMAIL=abox-test@example.invalid", + "GIT_COMMITTER_NAME=abox-test", + "GIT_COMMITTER_EMAIL=abox-test@example.invalid", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) } + return strings.TrimSpace(string(out)) } -func TestArchiveDirectorySnapshotsPlainDirectory(t *testing.T) { - t.Setenv("PATH", "") +func TestOpenForSessionDiscoversCleanGitRoot(t *testing.T) { root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "nested", "empty"), 0o755); err != nil { + runGit(t, root, "init", "-b", "main") + if err := os.MkdirAll(filepath.Join(root, "nested", "project"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".env"), []byte("local=value"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(root, "tracked.txt"), []byte("tracked"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored.txt\n"), 0o644); err != nil { + runGit(t, root, "add", "tracked.txt") + runGit(t, root, "commit", "-m", "initial") + + snap, err := OpenForSession(filepath.Join(root, "nested", "project"), filepath.Join(t.TempDir(), "host-tree")) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "ignored.txt"), []byte("included"), 0o644); err != nil { + if snap.Ephemeral || snap.Root != root || snap.HostSource != root || snap.HEAD == "" { + t.Fatalf("snapshot=%+v", snap) + } + archive, err := ArchiveHEAD(snap.Root) + if err != nil { + t.Fatal(err) + } + if got := readArchive(t, archive)["tracked.txt"].body; got != "tracked" { + t.Fatalf("tracked.txt=%q", got) + } +} + +func TestOpenForSessionSnapshotsDirtyWorktree(t *testing.T) { + root := t.TempDir() + runGit(t, root, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(root, "script.sh"), []byte("#!/bin/sh\necho clean\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "tracked.log"), []byte("clean"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "nested", "script.sh"), []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(root, "deleted.txt"), []byte("delete"), 0o644); err != nil { t.Fatal(err) } + runGit(t, root, "add", "script.sh", "tracked.log", "deleted.txt") + runGit(t, root, "commit", "-m", "initial") - source, data, err := ArchiveDirectory(root) - if err != nil { + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.env\n*.log\n"), 0o644); err != nil { t.Fatal(err) } - wantSource, _ := filepath.Abs(root) - if source != wantSource { - t.Fatalf("source=%q want %q", source, wantSource) + if err := os.WriteFile(filepath.Join(root, "script.sh"), []byte("#!/bin/sh\necho dirty\n"), 0o755); err != nil { + t.Fatal(err) } - entries := readArchive(t, data) - if got := entries[".env"].body; got != "local=value" { - t.Fatalf(".env=%q", got) + if err := os.WriteFile(filepath.Join(root, "tracked.log"), []byte("dirty"), 0o644); err != nil { + t.Fatal(err) } - if got := entries["ignored.txt"].body; got != "included" { - t.Fatalf("ignored.txt=%q", got) + if err := os.Remove(filepath.Join(root, "deleted.txt")); err != nil { + t.Fatal(err) } - if got := entries["nested/script.sh"]; got.body != "#!/bin/sh\necho ok\n" || got.mode&0o111 == 0 { - t.Fatalf("script=%+v", got) + if err := os.WriteFile(filepath.Join(root, "ignored.env"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) } - if got := entries["nested/empty"]; !got.dir { - t.Fatalf("empty directory=%+v", got) + if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatal(err) + } + oddName := "untracked\nfile.txt" + if err := os.WriteFile(filepath.Join(root, "nested", oddName), []byte("included"), 0o644); err != nil { + t.Fatal(err) } -} -func TestArchiveDirectoryUsesExactDirectoryAndExcludesGitMetadata(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "chosen") - if err := os.MkdirAll(filepath.Join(source, ".git", "objects"), 0o755); err != nil { + snap, err := OpenForSession(filepath.Join(root, "nested"), filepath.Join(t.TempDir(), "host-tree")) + if err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(source, "nested", ".git"), 0o755); err != nil { + if !snap.Ephemeral || snap.HostSource != root || snap.Root == root { + t.Fatalf("snapshot=%+v", snap) + } + archive, err := ArchiveHEAD(snap.Root) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "outside.txt"), []byte("outside"), 0o644); err != nil { + entries := readArchive(t, archive) + for name, want := range map[string]string{ + "script.sh": "#!/bin/sh\necho dirty\n", + "tracked.log": "dirty", + ".gitignore": "*.env\n*.log\n", + filepath.ToSlash(filepath.Join("nested", oddName)): "included", + } { + if got := entries[name].body; got != want { + t.Fatalf("%q=%q want %q", name, got, want) + } + } + for _, name := range []string{"deleted.txt", "ignored.env"} { + if _, ok := entries[name]; ok { + t.Fatalf("excluded file %q entered archive", name) + } + } + if entries["script.sh"].mode&0o111 == 0 { + t.Fatalf("script mode=%o", entries["script.sh"].mode) + } +} + +func TestOpenForSessionExcludesHostState(t *testing.T) { + root := t.TempDir() + runGit(t, root, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(root, "project.txt"), []byte("project"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(source, "inside.txt"), []byte("inside"), 0o644); err != nil { + runGit(t, root, "add", "project.txt") + runGit(t, root, "commit", "-m", "initial") + state := filepath.Join(root, ".abox") + if err := os.MkdirAll(state, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(source, ".git", "HEAD"), []byte("secret metadata"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(state, "credentials.env"), []byte("SECRET=value"), 0o600); err != nil { t.Fatal(err) } - _, data, err := ArchiveDirectory(source) + snap, err := OpenForSessionExcluding(root, filepath.Join(state, "sessions", "test", "host-tree"), state) + if err != nil { + t.Fatal(err) + } + archive, err := ArchiveHEAD(snap.Root) if err != nil { t.Fatal(err) } - entries := readArchive(t, data) - if _, ok := entries["inside.txt"]; !ok { - t.Fatal("selected directory file missing") + entries := readArchive(t, archive) + if _, ok := entries["project.txt"]; !ok { + t.Fatal("project file missing") } for name := range entries { - if name == "outside.txt" || name == ".git" || strings.Contains(name, "/.git") { - t.Fatalf("unexpected archive entry %q", name) + if name == ".abox" || strings.HasPrefix(name, ".abox/") { + t.Fatalf("host state included in archive: %q", name) } } } -func TestArchiveDirectoryRejectsSymlink(t *testing.T) { +func TestOpenForSessionRejectsSelectedSymlink(t *testing.T) { root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "target"), []byte("data"), 0o644); err != nil { + runGit(t, root, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(root, "target"), []byte("target"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink("target", filepath.Join(root, "link")); err != nil { t.Skipf("symlinks unavailable: %v", err) } - _, _, err := ArchiveDirectory(root) - if err == nil || !strings.Contains(err.Error(), "unsupported file type") { - t.Fatalf("got %v", err) + if _, err := OpenForSession(root, filepath.Join(t.TempDir(), "host-tree")); err == nil || !strings.Contains(err.Error(), "unsupported file type") { + t.Fatalf("error=%v", err) } } -func TestArchiveDirectoryRejectsInvalidSource(t *testing.T) { - file := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(file, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - if _, _, err := ArchiveDirectory(file); err == nil || !strings.Contains(err.Error(), "not a directory") { - t.Fatalf("file error=%v", err) - } - if _, _, err := ArchiveDirectory(filepath.Join(t.TempDir(), "missing")); err == nil { - t.Fatal("expected missing-directory error") +func TestOpenForSessionRequiresGitWorktree(t *testing.T) { + _, err := OpenForSession(t.TempDir(), filepath.Join(t.TempDir(), "host-tree")) + if err == nil || !strings.Contains(err.Error(), "not a git worktree") { + t.Fatalf("error=%v", err) } } -func TestArchiveDirectorySupportsEmptyDirectory(t *testing.T) { - _, data, err := ArchiveDirectory(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if entries := readArchive(t, data); len(entries) != 0 { - t.Fatalf("entries=%v", entries) +func readArchive(t *testing.T, data []byte) map[string]archiveEntry { + t.Helper() + out := map[string]archiveEntry{} + tr := tar.NewReader(bytes.NewReader(data)) + for { + header, err := tr.Next() + if err == io.EOF { + return out + } + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + out[strings.TrimSuffix(header.Name, "/")] = archiveEntry{ + body: string(body), mode: header.Mode, + } } } diff --git a/internal/runtime/clone.go b/internal/runtime/clone.go new file mode 100644 index 0000000..aca6385 --- /dev/null +++ b/internal/runtime/clone.go @@ -0,0 +1,47 @@ +package runtime + +import ( + "errors" + "io" + "os" +) + +func cloneFile(src, dst string) (retErr error) { + _ = os.Remove(dst) + if err := platformCloneFile(src, dst); err == nil { + if err := os.Chmod(dst, 0o600); err != nil { + _ = os.Remove(dst) + return err + } + return nil + } + + // A failed fast path may have created or partially populated dst. + _ = os.Remove(dst) + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, in.Close()) + if retErr != nil { + _ = os.Remove(dst) + } + }() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + retErr = errors.Join(retErr, out.Close()) + if retErr != nil { + _ = os.Remove(dst) + } + }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return os.Chmod(dst, 0o600) +} diff --git a/internal/runtime/clone_darwin.go b/internal/runtime/clone_darwin.go new file mode 100644 index 0000000..c1770d1 --- /dev/null +++ b/internal/runtime/clone_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package runtime + +import "golang.org/x/sys/unix" + +func platformCloneFile(src, dst string) error { + return unix.Clonefile(src, dst, 0) +} diff --git a/internal/runtime/clone_linux.go b/internal/runtime/clone_linux.go new file mode 100644 index 0000000..83c13d2 --- /dev/null +++ b/internal/runtime/clone_linux.go @@ -0,0 +1,62 @@ +//go:build linux + +package runtime + +import ( + "errors" + "io" + "os" + + "golang.org/x/sys/unix" +) + +var ( + ioctlFileClone = unix.IoctlFileClone + copyFileRange = unix.CopyFileRange +) + +func platformCloneFile(src, dst string) (retErr error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, in.Close()) }() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, out.Close()) }() + + if err := ioctlFileClone(int(out.Fd()), int(in.Fd())); err == nil { + return nil + } + if err := resetCloneFiles(in, out); err != nil { + return err + } + + for { + n, err := copyFileRange(int(in.Fd()), nil, int(out.Fd()), nil, 1<<30, 0) + if err != nil { + resetErr := resetCloneFiles(in, out) + if resetErr != nil { + return errors.Join(err, resetErr) + } + return err + } + if n == 0 { + return nil + } + } +} + +func resetCloneFiles(in, out *os.File) error { + if err := out.Truncate(0); err != nil { + return err + } + if _, err := in.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := out.Seek(0, io.SeekStart) + return err +} diff --git a/internal/runtime/clone_linux_test.go b/internal/runtime/clone_linux_test.go new file mode 100644 index 0000000..462aadd --- /dev/null +++ b/internal/runtime/clone_linux_test.go @@ -0,0 +1,125 @@ +//go:build linux + +package runtime + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCloneFileFallsBackAfterUnsupportedFastPaths(t *testing.T) { + originalClone := ioctlFileClone + originalCopy := copyFileRange + t.Cleanup(func() { + ioctlFileClone = originalClone + copyFileRange = originalCopy + }) + + ioctlFileClone = func(_, _ int) error { return syscall.EOPNOTSUPP } + copyFileRange = func(_ int, _ *int64, _ int, _ *int64, _ int, _ int) (int, error) { + return 0, syscall.EXDEV + } + + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + want := []byte("fallback copy") + if err := os.WriteFile(src, want, 0o644); err != nil { + t.Fatal(err) + } + if err := cloneFile(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestCloneFileResetsAfterPartialCopyFileRange(t *testing.T) { + originalClone := ioctlFileClone + originalCopy := copyFileRange + t.Cleanup(func() { + ioctlFileClone = originalClone + copyFileRange = originalCopy + }) + + ioctlFileClone = func(_, _ int) error { return syscall.EOPNOTSUPP } + calls := 0 + copyFileRange = func(srcFD int, _ *int64, dstFD int, _ *int64, _ int, _ int) (int, error) { + calls++ + if calls == 1 { + buf := make([]byte, 4) + n, err := syscall.Read(srcFD, buf) + if err != nil { + return n, err + } + written, err := syscall.Write(dstFD, buf[:n]) + return written, err + } + return 0, syscall.EXDEV + } + + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + want := []byte("complete contents after partial fast copy") + if err := os.WriteFile(src, want, 0o600); err != nil { + t.Fatal(err) + } + if err := cloneFile(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestPlatformCloneResetsDestinationOnCopyFileRangeFailure(t *testing.T) { + originalClone := ioctlFileClone + originalCopy := copyFileRange + t.Cleanup(func() { + ioctlFileClone = originalClone + copyFileRange = originalCopy + }) + + ioctlFileClone = func(_, _ int) error { return syscall.EOPNOTSUPP } + copyFileRange = func(srcFD int, _ *int64, dstFD int, _ *int64, _ int, _ int) (int, error) { + buf := []byte("partial") + if _, err := syscall.Read(srcFD, buf); err != nil { + return 0, err + } + if _, err := syscall.Write(dstFD, buf); err != nil { + return 0, err + } + return 0, syscall.EXDEV + } + + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + if err := os.WriteFile(src, []byte("source"), 0o600); err != nil { + t.Fatal(err) + } + err := platformCloneFile(src, dst) + if !errors.Is(err, syscall.EXDEV) { + t.Fatalf("error = %v, want EXDEV", err) + } + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if info.Size() != 0 { + t.Fatalf("destination size = %d, want 0", info.Size()) + } +} diff --git a/internal/runtime/clone_other.go b/internal/runtime/clone_other.go new file mode 100644 index 0000000..077015e --- /dev/null +++ b/internal/runtime/clone_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux + +package runtime + +import "errors" + +func platformCloneFile(src, dst string) error { + return errors.New("no platform clone fast path") +} diff --git a/internal/runtime/environment_darwin.go b/internal/runtime/environment_darwin.go new file mode 100644 index 0000000..7ea6e10 --- /dev/null +++ b/internal/runtime/environment_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package runtime + +import "os" + +func vmmEnvironment() []string { + return []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + os.Getenv("HOME"), + "DYLD_LIBRARY_PATH=/opt/homebrew/lib", + } +} diff --git a/internal/runtime/environment_other.go b/internal/runtime/environment_other.go new file mode 100644 index 0000000..b93be08 --- /dev/null +++ b/internal/runtime/environment_other.go @@ -0,0 +1,12 @@ +//go:build !darwin + +package runtime + +import "os" + +func vmmEnvironment() []string { + return []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + os.Getenv("HOME"), + } +} diff --git a/internal/runtime/lifecycle_linux_test.go b/internal/runtime/lifecycle_linux_test.go new file mode 100644 index 0000000..174a622 --- /dev/null +++ b/internal/runtime/lifecycle_linux_test.go @@ -0,0 +1,172 @@ +//go:build linux + +package runtime + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/AdminTurnedDevOps/ABox/internal/session" +) + +func TestStopSignalsTERMReapsAndRemovesSocket(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + terminated := filepath.Join(dir, "terminated") + script := filepath.Join(dir, "helper.sh") + body := "#!/bin/sh\ntrap 'touch \"" + terminated + "\"; exit 0' TERM\ntouch \"" + ready + "\"\nwhile :; do sleep 1; done\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command(script) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waiter := newProcessWaiter(cmd) + waitForFile(t, ready) + + sock := filepath.Join(dir, "rpc.sock") + if err := os.WriteFile(sock, nil, 0o600); err != nil { + t.Fatal(err) + } + sb := &Sandbox{Sess: &session.Session{Dir: dir}, cmd: cmd, process: waiter} + if err := sb.Stop(); err != nil { + t.Fatal(err) + } + if err := sb.Stop(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(terminated); err != nil { + t.Fatalf("helper did not observe SIGTERM: %v", err) + } + if _, err := os.Stat(sock); !os.IsNotExist(err) { + t.Fatalf("socket remains after Stop: %v", err) + } + if err := syscall.Kill(cmd.Process.Pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("helper still exists after Wait: %v", err) + } +} + +func TestStopHelperKillsAndReapsAfterTimeout(t *testing.T) { + ready := filepath.Join(t.TempDir(), "ready") + cmd := exec.Command("/bin/sh", "-c", "trap '' TERM; : > \""+ready+"\"; exec sleep 30") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waiter := newProcessWaiter(cmd) + waitForFile(t, ready) + sb := &Sandbox{cmd: cmd, process: waiter} + sb.stopHelper(25 * time.Millisecond) + if err := syscall.Kill(cmd.Process.Pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("helper still exists after forced kill and Wait: %v", err) + } +} + +func TestStartReportsEarlyHelperExitAndReaps(t *testing.T) { + sess := shortTestSession(t) + script := filepath.Join(t.TempDir(), "vmm") + body := "#!/bin/sh\nprintf '%s\\n' $$ > helper.pid\nprintf 'bounded helper diagnostic\\n' >&2\nexit 7\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Start(ctx, sess, script, 1, 128) + if err == nil || !strings.Contains(err.Error(), "bounded helper diagnostic") { + t.Fatalf("Start error = %v", err) + } + pidData, readErr := os.ReadFile(filepath.Join(sess.Dir, "helper.pid")) + if readErr != nil { + t.Fatal(readErr) + } + pid, convErr := strconv.Atoi(strings.TrimSpace(string(pidData))) + if convErr != nil { + t.Fatal(convErr) + } + if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("helper still exists after failed Start: %v", err) + } + if _, err := os.Stat(sess.RPCSocket()); !os.IsNotExist(err) { + t.Fatalf("socket remains after failed Start: %v", err) + } +} + +func TestStartPassesOnlyLivenessExtraFile(t *testing.T) { + sess := shortTestSession(t) + var err error + sentinelPath := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(sentinelPath, nil, 0o600); err != nil { + t.Fatal(err) + } + sentinel, err := os.Open(sentinelPath) + if err != nil { + t.Fatal(err) + } + defer sentinel.Close() + sentinelFD := int(sentinel.Fd()) + if sentinelFD == 3 { + t.Fatal("test sentinel unexpectedly occupies fd 3") + } + + script := filepath.Join(t.TempDir(), "vmm") + body := "#!/bin/sh\nprintf '%s\\n' $$ > helper.pid\nif [ -e /proc/self/fd/3 ]; then : > fd3-present; fi\nif [ \"$(readlink /proc/self/fd/" + strconv.Itoa(sentinelFD) + ")\" = \"" + sentinelPath + "\" ]; then : > sentinel-present; fi\nIFS= read -r _ <&3\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + if _, err := Start(ctx, sess, script, 1, 128); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Start error = %v, want deadline exceeded", err) + } + if _, err := os.Stat(filepath.Join(sess.Dir, "fd3-present")); err != nil { + t.Fatalf("liveness fd 3 was not inherited: %v", err) + } + if _, err := os.Stat(filepath.Join(sess.Dir, "sentinel-present")); !os.IsNotExist(err) { + t.Fatalf("unrelated parent fd %d was inherited: %v", sentinelFD, err) + } + pidData, err := os.ReadFile(filepath.Join(sess.Dir, "helper.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + t.Fatal(err) + } + if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("helper still exists after canceled boot: %v", err) + } + if _, err := os.Stat(sess.RPCSocket()); !os.IsNotExist(err) { + t.Fatalf("socket remains after canceled boot: %v", err) + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", path) +} + +func shortTestSession(t *testing.T) *session.Session { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "abox-runtime-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return &session.Session{ID: "test-session", Capability: "test-capability", Dir: dir} +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c14612a..52288ec 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -10,16 +11,17 @@ import ( "os" "os/exec" "path/filepath" + goruntime "runtime" "strconv" "strings" "sync" "time" "github.com/AdminTurnedDevOps/ABox/internal/config" + "github.com/AdminTurnedDevOps/ABox/internal/guestimage" "github.com/AdminTurnedDevOps/ABox/internal/session" "github.com/AdminTurnedDevOps/ABox/internal/vmmconfig" "github.com/AdminTurnedDevOps/ABox/protocol" - "golang.org/x/sys/unix" ) // ErrGuestTooOld is returned when a v2-only operation is used against a v1 guest. @@ -34,8 +36,78 @@ const ( turnQueueBytes = 8 << 20 writeTimeout = 30 * time.Second shutdownTimeout = 3 * time.Second + diagnosticLimit = 32 << 10 ) +type processWaiter struct { + done chan struct{} + err error +} + +func newProcessWaiter(cmd *exec.Cmd) *processWaiter { + w := &processWaiter{done: make(chan struct{})} + go func() { + w.err = cmd.Wait() + close(w.done) + }() + return w +} + +func (w *processWaiter) wait() error { + if w == nil { + return nil + } + <-w.done + return w.err +} + +type boundedDiagnostics struct { + mu sync.Mutex + buf []byte + truncated bool +} + +func (w *boundedDiagnostics) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + remaining := diagnosticLimit - len(w.buf) + if remaining > 0 { + n := len(p) + if n > remaining { + n = remaining + } + w.buf = append(w.buf, p[:n]...) + } + if len(p) > remaining { + w.truncated = true + } + return len(p), nil +} + +func (w *boundedDiagnostics) String() string { + w.mu.Lock() + defer w.mu.Unlock() + text := strings.TrimSpace(string(w.buf)) + if w.truncated { + text += " [diagnostics truncated]" + } + return text +} + +func mapHelperDiagnostic(message string) string { + lower := strings.ToLower(message) + switch { + case strings.Contains(lower, "undefined symbol"): + return "incompatible libkrun package or ABI: " + message + case strings.Contains(lower, "error while loading shared libraries") && strings.Contains(lower, "libkrun"): + return "libkrun is linked but not loadable; verify the runtime loader path and run ldconfig: " + message + case strings.Contains(lower, "libkrunfw") && (strings.Contains(lower, "not found") || strings.Contains(lower, "cannot open")): + return "the firmware library required by the installed libkrun package is not loadable; verify the matching libkrunfw package and loader cache: " + message + default: + return message + } +} + type TurnOptions struct { MaxTurns int TimeoutSec int @@ -74,6 +146,8 @@ type Sandbox struct { GuestProtocol int cmd *exec.Cmd conn net.Conn + liveness *os.File + process *processWaiter OnGuestCall GuestCallHandler writeOnce sync.Once @@ -96,6 +170,7 @@ type Sandbox struct { readOnce sync.Once failOnce sync.Once + stopOnce sync.Once readDone chan struct{} } @@ -221,20 +296,142 @@ func (q *frameQueue) pop(ctx context.Context) (protocol.Frame, bool, error) { } } +type prepareOptions struct { + resume bool + allowOlderProtocol bool + allowLegacyDarwinImg bool +} + func Prepare(sess *session.Session, imagePath string, model config.Model, resume bool) error { + return prepare(sess, imagePath, model, prepareOptions{resume: resume, allowLegacyDarwinImg: true}) +} + +func PrepareProbe(sess *session.Session, imagePath string, model config.Model) error { + return prepare(sess, imagePath, model, prepareOptions{allowOlderProtocol: true, allowLegacyDarwinImg: true}) +} + +func prepare(sess *session.Session, imagePath string, model config.Model, opts prepareOptions) error { + acquired, err := sess.AcquireRuntimeLock() + if err != nil { + return err + } + success := false + defer func() { + if acquired && !success { + _ = sess.ReleaseRuntimeLock() + } + }() + sess.DiagnosticProbe = opts.allowOlderProtocol + defaultImagePath := config.GuestImagePath() if imagePath == "" { - imagePath = config.GuestImagePath() + imagePath = defaultImagePath } - if resume { + backend, err := hostVMMBackend() + if err != nil { + return err + } + if opts.resume { if _, err := os.Stat(sess.RootDisk()); err != nil { return fmt.Errorf("resume: session disk missing at %s", sess.RootDisk()) } + if sess.GuestArch == "" { + if goruntime.GOOS != "darwin" || goruntime.GOARCH != "arm64" { + return fmt.Errorf("resume: session %s predates image compatibility metadata; start a new session on %s/%s", sess.ID, goruntime.GOOS, goruntime.GOARCH) + } + sess.GuestArch = "arm64" + sess.VMMBackend = "hvf" + digest, err := guestimage.Digest(sess.RootDisk()) + if err != nil { + return fmt.Errorf("hash legacy session disk: %w", err) + } + sess.ImageSHA256 = digest + if err := sess.WriteMeta(); err != nil { + return fmt.Errorf("backfill legacy session metadata: %w", err) + } + } + if sess.GuestArch != goruntime.GOARCH { + return fmt.Errorf("resume: session guest architecture is %s, host requires %s", sess.GuestArch, goruntime.GOARCH) + } + if sess.VMMBackend != backend { + return fmt.Errorf("resume: session VMM backend is %s, host requires %s", sess.VMMBackend, backend) + } + legacyDarwin := goruntime.GOOS == "darwin" && goruntime.GOARCH == "arm64" && sess.ManifestSchema == 0 + if !legacyDarwin { + if sess.ManifestSchema != guestimage.Schema || strings.TrimSpace(sess.ImageID) == "" || !validSHA256(sess.ImageSHA256) { + return fmt.Errorf("resume: session %s has incomplete or unsupported image compatibility metadata; start a new session", sess.ID) + } + } else if !validSHA256(sess.ImageSHA256) { + return fmt.Errorf("resume: legacy session %s has no verifiable disk identity; start a new session", sess.ID) + } + if sess.GuestProtocol > protocol.Version { + return fmt.Errorf("resume: session guest protocol %d is newer than host protocol %d", sess.GuestProtocol, protocol.Version) + } + if sess.GuestProtocol != 0 && !opts.allowOlderProtocol && sess.GuestProtocol != protocol.Version { + return fmt.Errorf("resume: session guest protocol %d is incompatible with required protocol %d", sess.GuestProtocol, protocol.Version) + } + if sess.GuestProtocol == 0 && !legacyDarwin { + return fmt.Errorf("resume: session %s does not record a guest protocol; start a new session", sess.ID) + } } else { - if _, err := os.Stat(imagePath); err != nil { - return fmt.Errorf("guest image missing at %s (run: make image)", imagePath) + if filepath.Clean(imagePath) == filepath.Clean(defaultImagePath) { + lock, err := guestimage.AcquireSharedLock(imagePath) + if err != nil { + return err + } + defer lock.Close() } - if err := cloneFile(imagePath, sess.RootDisk()); err != nil { - return fmt.Errorf("clone session disk: %w", err) + image, err := guestimage.Load(imagePath) + if err != nil { + if !opts.allowLegacyDarwinImg || !isLegacyDarwinImage(imagePath) || !errors.Is(err, guestimage.ErrManifestMissing) { + return fmt.Errorf("guest image %s is unusable: %w (run: make image)", imagePath, err) + } + resolved, resolveErr := filepath.EvalSymlinks(imagePath) + if resolveErr != nil { + return fmt.Errorf("guest image missing at %s (run: make image)", imagePath) + } + if err := cloneFile(resolved, sess.RootDisk()); err != nil { + return fmt.Errorf("clone legacy session disk: %w", err) + } + digest, err := guestimage.Digest(sess.RootDisk()) + if err != nil { + _ = os.Remove(sess.RootDisk()) + return fmt.Errorf("hash legacy session disk: %w", err) + } + sess.GuestArch = "arm64" + sess.ImageSHA256 = digest + sess.VMMBackend = backend + } else { + if image.Manifest.Arch != goruntime.GOARCH { + return fmt.Errorf("guest image architecture is %s, host requires %s", image.Manifest.Arch, goruntime.GOARCH) + } + if image.Manifest.Protocol > protocol.Version { + return fmt.Errorf("guest image protocol %d is newer than host protocol %d", image.Manifest.Protocol, protocol.Version) + } + if !opts.allowOlderProtocol && image.Manifest.Protocol != protocol.Version { + return fmt.Errorf("guest image protocol %d is incompatible with required protocol %d", image.Manifest.Protocol, protocol.Version) + } + if err := cloneFile(image.Path, sess.RootDisk()); err != nil { + return fmt.Errorf("clone session disk: %w", err) + } + digest, err := guestimage.Digest(sess.RootDisk()) + if err != nil { + _ = os.Remove(sess.RootDisk()) + return fmt.Errorf("hash session disk: %w", err) + } + if digest != image.Manifest.SHA256 { + _ = os.Remove(sess.RootDisk()) + return fmt.Errorf("guest image digest mismatch: manifest has %s, cloned image has %s", image.Manifest.SHA256, digest) + } + sess.ManifestSchema = image.Manifest.Schema + sess.GuestArch = image.Manifest.Arch + sess.ImageID = image.Manifest.ImageID + sess.ImageSHA256 = image.Manifest.SHA256 + sess.GuestProtocol = image.Manifest.Protocol + sess.VMMBackend = backend + } + if err := sess.WriteMeta(); err != nil { + _ = os.Remove(sess.RootDisk()) + return fmt.Errorf("write session image metadata: %w", err) } } if err := sess.WriteGuestConfig(model); err != nil { @@ -244,16 +441,57 @@ func Prepare(sess *session.Session, imagePath string, model config.Model, resume if err != nil { return err } - return session.WritePaddedConfig(sess.ConfigDisk(), data) + if err := session.WritePaddedConfig(sess.ConfigDisk(), data); err != nil { + return err + } + success = true + return nil +} + +func validSHA256(value string) bool { + if len(value) != 64 || strings.ToLower(value) != value { + return false + } + digest, err := hex.DecodeString(value) + return err == nil && len(digest) == 32 +} + +func hostVMMBackend() (string, error) { + switch goruntime.GOOS { + case "darwin": + return "hvf", nil + case "linux": + return "kvm", nil + default: + return "", fmt.Errorf("unsupported VMM host %s/%s", goruntime.GOOS, goruntime.GOARCH) + } +} + +func isLegacyDarwinImage(path string) bool { + return goruntime.GOOS == "darwin" && goruntime.GOARCH == "arm64" && filepath.Base(path) == config.LegacyGuestImageName } func Start(ctx context.Context, sess *session.Session, vmmPath string, vcpu int, ram int) (*Sandbox, error) { + acquired, err := sess.AcquireRuntimeLock() + if err != nil { + return nil, err + } + lockTransferred := false + defer func() { + if acquired && !lockTransferred { + _ = sess.ReleaseRuntimeLock() + } + }() if vmmPath == "" { vmmPath = lookPath("abox-vmm") } if vmmPath == "" { return nil, fmt.Errorf("abox-vmm not found; build with make build") } + resolvedVMM := exec.Command(vmmPath).Path + if err := cleanupStaleHelper(sess, resolvedVMM); err != nil { + return nil, err + } _ = os.Remove(sess.RPCSocket()) ln, err := net.Listen("unix", sess.RPCSocket()) if err != nil { @@ -261,8 +499,13 @@ func Start(ctx context.Context, sess *session.Session, vmmPath string, vcpu int, } if err := os.Chmod(sess.RPCSocket(), 0o600); err != nil { ln.Close() + _ = os.Remove(sess.RPCSocket()) return nil, err } + defer func() { + _ = ln.Close() + _ = os.Remove(sess.RPCSocket()) + }() cfg := vmmconfig.Config{ VCPU: uint8(vcpu), @@ -276,34 +519,82 @@ func Start(ctx context.Context, sess *session.Session, vmmPath string, vcpu int, } payload, err := json.Marshal(cfg) if err != nil { - ln.Close() return nil, err } - cmd := exec.Command(vmmPath) + cmd := exec.Command(resolvedVMM) cmd.Dir = sess.Dir - cmd.Env = []string{ - "PATH=" + os.Getenv("PATH"), - "HOME=" + os.Getenv("HOME"), - "DYLD_LIBRARY_PATH=/opt/homebrew/lib", + cmd.Env = vmmEnvironment() + livenessRead, livenessWrite, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("create VMM liveness pipe: %w", err) } + defer livenessRead.Close() + livenessOwned := true + defer func() { + if livenessOwned { + _ = livenessWrite.Close() + } + }() + cmd.ExtraFiles = []*os.File{livenessRead} stdin, err := cmd.StdinPipe() if err != nil { - ln.Close() return nil, err } - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + diagnostics := &boundedDiagnostics{} + output := io.MultiWriter(os.Stderr, diagnostics) + cmd.Stdout = output + cmd.Stderr = output if err := cmd.Start(); err != nil { - ln.Close() + _ = stdin.Close() return nil, fmt.Errorf("start abox-vmm: %w", err) } + if err := recordHelper(sess, cmd.Process.Pid, cmd.Path); err != nil { + _ = stdin.Close() + _ = livenessWrite.Close() + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, fmt.Errorf("record VMM helper identity: %w", err) + } + _ = livenessRead.Close() + process := newProcessWaiter(cmd) + reapFailure := func() { + _ = stdin.Close() + _ = livenessWrite.Close() + _ = cmd.Process.Kill() + _ = process.wait() + _ = clearHelper(sess) + } + helperError := func(prefix string) error { + waitErr := process.wait() + _ = clearHelper(sess) + status := "unknown status" + if cmd.ProcessState != nil { + status = cmd.ProcessState.String() + } + if waitErr != nil { + status = waitErr.Error() + } + message := diagnostics.String() + if message != "" { + return fmt.Errorf("%s (%s): %s", prefix, status, message) + } + return fmt.Errorf("%s (%s)", prefix, status) + } if _, err := stdin.Write(payload); err != nil { - cmd.Process.Kill() - ln.Close() - return nil, err + reapFailure() + if message := mapHelperDiagnostic(diagnostics.String()); message != "" { + return nil, fmt.Errorf("write abox-vmm config: %w: %s", err, message) + } + return nil, fmt.Errorf("write abox-vmm config: %w", err) + } + if err := stdin.Close(); err != nil { + reapFailure() + if message := mapHelperDiagnostic(diagnostics.String()); message != "" { + return nil, fmt.Errorf("close abox-vmm config: %w: %s", err, message) + } + return nil, fmt.Errorf("close abox-vmm config: %w", err) } - stdin.Close() type acc struct { c net.Conn @@ -319,30 +610,63 @@ func Start(ctx context.Context, sess *session.Session, vmmPath string, vcpu int, var conn net.Conn select { case <-ctx.Done(): - cmd.Process.Kill() - ln.Close() + reapFailure() return nil, ctx.Err() + case <-process.done: + return nil, helperError("abox-vmm exited before guest RPC connected") case a := <-ch: if a.err != nil { - cmd.Process.Kill() - ln.Close() + select { + case <-process.done: + return nil, helperError("abox-vmm exited before guest RPC connected") + default: + } + reapFailure() return nil, fmt.Errorf("guest rpc accept: %w", a.err) } conn = a.c } + select { + case <-process.done: + _ = conn.Close() + return nil, helperError("abox-vmm exited during guest RPC connect") + default: + } - sb := &Sandbox{Sess: sess, cmd: cmd, conn: conn, calls: map[string]*frameQueue{}} + livenessOwned = false + sb := &Sandbox{ + Sess: sess, cmd: cmd, conn: conn, liveness: livenessWrite, process: process, + calls: map[string]*frameQueue{}, + } if err := sb.waitHello(ctx); err != nil { - sb.Stop() - return nil, err + result := err + select { + case <-process.done: + result = helperError("abox-vmm exited before guest hello") + default: + } + _ = sb.Stop() + return nil, result } + lockTransferred = true return sb, nil } func (s *Sandbox) waitHello(ctx context.Context) error { - _ = s.conn.SetDeadline(time.Now().Add(15 * time.Second)) + deadline := time.Now().Add(15 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + _ = s.conn.SetDeadline(deadline) + stop := context.AfterFunc(ctx, func() { + _ = s.conn.SetDeadline(time.Now()) + }) + defer stop() frame, err := protocol.ReadFrame(s.conn) if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } return fmt.Errorf("guest hello: %w", err) } if frame.Method != "hello" { @@ -355,17 +679,42 @@ func (s *Sandbox) waitHello(ctx context.Context) error { if hello.SessionID != s.Sess.ID || hello.Capability != s.Sess.Capability { return fmt.Errorf("guest capability mismatch") } + guestProtocol := hello.Protocol + if guestProtocol == 0 { + guestProtocol = 1 + } + reject := func(message string) error { + result, _ := protocol.EncodeParams(protocol.HelloResult{Accepted: false, Message: message, Protocol: protocol.Version}) + _ = protocol.WriteFrame(s.conn, protocol.Frame{ID: frame.ID, Result: result}) + return errors.New(message) + } + if guestProtocol > protocol.Version { + return reject(fmt.Sprintf("guest protocol %d is newer than host protocol %d", guestProtocol, protocol.Version)) + } + if guestProtocol != protocol.Version && !s.Sess.DiagnosticProbe { + return reject(fmt.Sprintf("guest protocol %d is incompatible with required protocol %d", guestProtocol, protocol.Version)) + } + if s.Sess.GuestProtocol != 0 && guestProtocol != s.Sess.GuestProtocol { + return reject(fmt.Sprintf("guest protocol %d does not match session metadata %d", guestProtocol, s.Sess.GuestProtocol)) + } + if s.Sess.ImageID != "" && hello.ImageID != s.Sess.ImageID { + return reject(fmt.Sprintf("guest image id %q does not match session metadata %q", hello.ImageID, s.Sess.ImageID)) + } + if guestProtocol == protocol.Version && strings.TrimSpace(hello.ImageID) == "" { + return reject("guest did not report an image id") + } + if s.Sess.GuestProtocol == 0 { + s.Sess.GuestProtocol = guestProtocol + s.Sess.ImageID = hello.ImageID + if err := s.Sess.WriteMeta(); err != nil { + return reject(fmt.Sprintf("record guest compatibility metadata: %v", err)) + } + } ok, _ := protocol.EncodeParams(protocol.HelloResult{Accepted: true, Protocol: protocol.Version}) if err := protocol.WriteFrame(s.conn, protocol.Frame{ID: frame.ID, Result: ok}); err != nil { return err } - if hello.Protocol == 0 { - s.GuestProtocol = 1 - } else if hello.Protocol > protocol.Version { - s.GuestProtocol = protocol.Version - } else { - s.GuestProtocol = hello.Protocol - } + s.GuestProtocol = guestProtocol s.History = hello.History _ = s.conn.SetDeadline(time.Time{}) return nil @@ -900,48 +1249,43 @@ func (s *Sandbox) TransferArchive(ctx context.Context, archive []byte) error { } func (s *Sandbox) Stop() error { - if s.conn != nil { - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - _ = s.Call(ctx, "shutdown", map[string]bool{"ok": true}, nil) - cancel() - if s.lifeCancel != nil { - s.lifeCancel() + s.stopOnce.Do(func() { + if s.conn != nil { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + _ = s.Call(ctx, "shutdown", map[string]bool{"ok": true}, nil) + cancel() + if s.lifeCancel != nil { + s.lifeCancel() + } + _ = s.conn.Close() } - _ = s.conn.Close() - } - if s.cmd != nil && s.cmd.Process != nil { - _ = s.cmd.Process.Signal(os.Interrupt) - done := make(chan struct{}) - go func() { - s.cmd.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(3 * time.Second): - _ = s.cmd.Process.Kill() + s.stopHelper(shutdownTimeout) + if s.Sess != nil { + _ = os.Remove(s.Sess.RPCSocket()) + _ = clearHelper(s.Sess) + _ = s.Sess.ReleaseRuntimeLock() } - } + }) return nil } -func cloneFile(src, dst string) error { - _ = os.Remove(dst) - if err := unix.Clonefile(src, dst, 0); err == nil { - return os.Chmod(dst, 0o600) - } - in, err := os.Open(src) - if err != nil { - return err +func (s *Sandbox) stopHelper(timeout time.Duration) { + if s.cmd != nil && s.cmd.Process != nil && s.process != nil { + select { + case <-s.process.done: + default: + _ = s.cmd.Process.Signal(helperStopSignal()) + select { + case <-s.process.done: + case <-time.After(timeout): + _ = s.cmd.Process.Kill() + } + } + _ = s.process.wait() } - defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return err + if s.liveness != nil { + _ = s.liveness.Close() } - defer out.Close() - _, err = io.Copy(out, in) - return err } func lookPath(name string) string { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f0b242c..8be5cbd 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1,14 +1,60 @@ package runtime import ( + "encoding/json" "os" "path/filepath" + goruntime "runtime" + "strings" "testing" "github.com/AdminTurnedDevOps/ABox/internal/config" + "github.com/AdminTurnedDevOps/ABox/internal/guestimage" "github.com/AdminTurnedDevOps/ABox/internal/session" + "github.com/AdminTurnedDevOps/ABox/protocol" ) +func writeTestImage(t *testing.T, protocolVersion int, arch string, corruptDigest bool) string { + t.Helper() + path := filepath.Join(t.TempDir(), "guest.raw") + if err := os.WriteFile(path, []byte("GOLDEN"), 0o600); err != nil { + t.Fatal(err) + } + digest, err := guestimage.Digest(path) + if err != nil { + t.Fatal(err) + } + if corruptDigest { + digest = strings.Repeat("0", 64) + } + manifest := guestimage.Manifest{Schema: guestimage.Schema, Arch: arch, ImageID: "test-image", Protocol: protocolVersion, SHA256: digest} + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".manifest.json", data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func compatibleSessionMetadata(t *testing.T, s *session.Session) { + t.Helper() + backend, err := hostVMMBackend() + if err != nil { + t.Fatal(err) + } + s.GuestArch = goruntime.GOARCH + s.ManifestSchema = guestimage.Schema + s.ImageID = "test-image" + s.ImageSHA256 = strings.Repeat("a", 64) + s.GuestProtocol = protocol.Version + s.VMMBackend = backend + if err := s.WriteMeta(); err != nil { + t.Fatal(err) + } +} + func TestCloneFileCopiesContents(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "src") @@ -26,6 +72,31 @@ func TestCloneFileCopiesContents(t *testing.T) { if string(got) != "hello-abox" { t.Fatalf("got %q", got) } + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("mode = %o, want 600", got) + } +} + +func TestCloneFileRemovesPartialDestination(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "source-dir") + dst := filepath.Join(dir, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old contents"), 0o600); err != nil { + t.Fatal(err) + } + if err := cloneFile(src, dst); err == nil { + t.Fatal("cloneFile succeeded for a directory") + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Fatalf("partial destination remains: %v", err) + } } func TestPrepareResumeDoesNotClobberRoot(t *testing.T) { @@ -38,10 +109,8 @@ func TestPrepareResumeDoesNotClobberRoot(t *testing.T) { if err := os.WriteFile(s.RootDisk(), original, 0o600); err != nil { t.Fatal(err) } + compatibleSessionMetadata(t, s) golden := filepath.Join(t.TempDir(), "golden.raw") - if err := os.WriteFile(golden, []byte("GOLDEN"), 0o600); err != nil { - t.Fatal(err) - } err = Prepare(s, golden, config.Model{Name: "grok", Provider: "xai", Model: "grok-4"}, true) if err != nil { t.Fatal(err) @@ -70,6 +139,7 @@ func TestPrepareResumeRewritesReadOnlyConfig(t *testing.T) { if err := os.WriteFile(s.ConfigDisk(), make([]byte, 1<<20), 0o400); err != nil { t.Fatal(err) } + compatibleSessionMetadata(t, s) err = Prepare(s, "", config.Model{Name: "grok", Provider: "xai", Model: "grok-4"}, true) if err != nil { t.Fatal(err) @@ -82,3 +152,151 @@ func TestPrepareResumeRewritesReadOnlyConfig(t *testing.T) { t.Fatalf("perm %o", st.Mode().Perm()) } } + +func TestPreparePersistsVerifiedImageMetadata(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + image := writeTestImage(t, protocol.Version, goruntime.GOARCH, false) + if err := Prepare(s, image, config.Model{Name: "grok"}, false); err != nil { + t.Fatal(err) + } + loaded, err := session.Load(s.ID) + if err != nil { + t.Fatal(err) + } + if loaded.ManifestSchema != guestimage.Schema || loaded.GuestArch != goruntime.GOARCH || loaded.ImageID != "test-image" || loaded.GuestProtocol != protocol.Version { + t.Fatalf("metadata = %#v", loaded) + } + digest, err := guestimage.Digest(loaded.RootDisk()) + if err != nil { + t.Fatal(err) + } + if digest != loaded.ImageSHA256 { + t.Fatalf("root digest %s, metadata %s", digest, loaded.ImageSHA256) + } +} + +func TestPrepareRejectsImageArchitecture(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + other := "arm64" + if goruntime.GOARCH == other { + other = "amd64" + } + err = Prepare(s, writeTestImage(t, protocol.Version, other, false), config.Model{}, false) + if err == nil || !strings.Contains(err.Error(), "architecture") { + t.Fatalf("got %v", err) + } +} + +func TestPrepareRejectsDigestMismatchAndRemovesClone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + err = Prepare(s, writeTestImage(t, protocol.Version, goruntime.GOARCH, true), config.Model{}, false) + if err == nil || !strings.Contains(err.Error(), "digest mismatch") { + t.Fatalf("got %v", err) + } + if _, err := os.Stat(s.RootDisk()); !os.IsNotExist(err) { + t.Fatalf("failed clone remains: %v", err) + } +} + +func TestPrepareProtocolPolicy(t *testing.T) { + for _, tc := range []struct { + name string + version int + probe bool + wantErr bool + contains string + }{ + {name: "normal older", version: protocol.Version - 1, wantErr: true, contains: "incompatible"}, + {name: "probe older", version: protocol.Version - 1, probe: true}, + {name: "future", version: protocol.Version + 1, probe: true, wantErr: true, contains: "newer"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + image := writeTestImage(t, tc.version, goruntime.GOARCH, false) + if tc.probe { + err = PrepareProbe(s, image, config.Model{}) + } else { + err = Prepare(s, image, config.Model{}, false) + } + if tc.wantErr && (err == nil || !strings.Contains(err.Error(), tc.contains)) { + t.Fatalf("got %v", err) + } + if !tc.wantErr && err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPrepareResumeRejectsMetadataFreeLinuxSession(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("Linux compatibility rule") + } + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.RootDisk(), []byte("disk"), 0o600); err != nil { + t.Fatal(err) + } + err = Prepare(s, "", config.Model{}, true) + if err == nil || !strings.Contains(err.Error(), "predates image compatibility metadata") { + t.Fatalf("got %v", err) + } +} + +func TestPrepareResumeRejectsPartialLinuxMetadata(t *testing.T) { + if goruntime.GOOS != "linux" { + t.Skip("Linux compatibility rule") + } + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/source") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.RootDisk(), []byte("disk"), 0o600); err != nil { + t.Fatal(err) + } + s.GuestArch = goruntime.GOARCH + s.GuestProtocol = protocol.Version + s.VMMBackend = "kvm" + if err := s.WriteMeta(); err != nil { + t.Fatal(err) + } + err = Prepare(s, "", config.Model{}, true) + if err == nil || !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("got %v", err) + } +} + +func TestMapHelperDiagnostic(t *testing.T) { + for _, tc := range []struct { + input string + want string + }{ + {"symbol lookup error: undefined symbol: krun_disable_implicit_vsock", "incompatible libkrun package or ABI"}, + {"error while loading shared libraries: libkrun.so.1: cannot open shared object file", "libkrun is linked but not loadable"}, + {"libkrunfw.so.5 not found", "firmware library required"}, + } { + if got := mapHelperDiagnostic(tc.input); !strings.Contains(got, tc.want) { + t.Fatalf("mapHelperDiagnostic(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} diff --git a/internal/runtime/runtime_turn_test.go b/internal/runtime/runtime_turn_test.go index d9f4202..2ed1e82 100644 --- a/internal/runtime/runtime_turn_test.go +++ b/internal/runtime/runtime_turn_test.go @@ -395,7 +395,7 @@ func TestCallSkipsLateCancelResponse(t *testing.T) { func TestWaitHelloStoresProtocol(t *testing.T) { host, guest := net.Pipe() t.Cleanup(func() { host.Close(); guest.Close() }) - s := &Sandbox{conn: host, Sess: &session.Session{ID: "sid", Capability: "cap"}} + s := &Sandbox{conn: host, Sess: &session.Session{ID: "sid", Capability: "cap", Dir: t.TempDir(), DiagnosticProbe: true}} go func() { params, _ := protocol.EncodeParams(protocol.HelloParams{ SessionID: "sid", Capability: "cap", Protocol: 2, GuestReady: true, @@ -415,7 +415,7 @@ func TestWaitHelloStoresProtocol(t *testing.T) { func TestWaitHelloDefaultsV1(t *testing.T) { host, guest := net.Pipe() t.Cleanup(func() { host.Close(); guest.Close() }) - s := &Sandbox{conn: host, Sess: &session.Session{ID: "sid", Capability: "cap"}} + s := &Sandbox{conn: host, Sess: &session.Session{ID: "sid", Capability: "cap", Dir: t.TempDir(), DiagnosticProbe: true}} go func() { params, _ := protocol.EncodeParams(protocol.HelloParams{ SessionID: "sid", Capability: "cap", GuestReady: true, @@ -431,3 +431,43 @@ func TestWaitHelloDefaultsV1(t *testing.T) { t.Fatalf("protocol %d", s.GuestProtocol) } } + +func TestWaitHelloRejectsFutureProtocol(t *testing.T) { + host, guest := net.Pipe() + t.Cleanup(func() { host.Close(); guest.Close() }) + s := &Sandbox{conn: host, Sess: &session.Session{ID: "sid", Capability: "cap", Dir: t.TempDir()}} + response := make(chan protocol.HelloResult, 1) + go func() { + params, _ := protocol.EncodeParams(protocol.HelloParams{ + SessionID: "sid", Capability: "cap", Protocol: protocol.Version + 1, GuestReady: true, + }) + _ = protocol.WriteFrame(guest, protocol.Frame{ID: "hello", Method: "hello", Params: params}) + frame, _ := protocol.ReadFrame(guest) + result, _ := protocol.DecodeParams[protocol.HelloResult](frame.Result) + response <- result + }() + if err := s.waitHello(context.Background()); err == nil || !strings.Contains(err.Error(), "newer") { + t.Fatalf("got %v", err) + } + if result := <-response; result.Accepted || result.Protocol != protocol.Version { + t.Fatalf("response = %#v", result) + } +} + +func TestWaitHelloRejectsRecordedImageMismatch(t *testing.T) { + host, guest := net.Pipe() + t.Cleanup(func() { host.Close(); guest.Close() }) + s := &Sandbox{conn: host, Sess: &session.Session{ + ID: "sid", Capability: "cap", Dir: t.TempDir(), GuestProtocol: protocol.Version, ImageID: "expected", + }} + go func() { + params, _ := protocol.EncodeParams(protocol.HelloParams{ + SessionID: "sid", Capability: "cap", Protocol: protocol.Version, ImageID: "other", GuestReady: true, + }) + _ = protocol.WriteFrame(guest, protocol.Frame{ID: "hello", Method: "hello", Params: params}) + _, _ = protocol.ReadFrame(guest) + }() + if err := s.waitHello(context.Background()); err == nil || !strings.Contains(err.Error(), "image id") { + t.Fatalf("got %v", err) + } +} diff --git a/internal/runtime/stale_linux.go b/internal/runtime/stale_linux.go new file mode 100644 index 0000000..f3825d1 --- /dev/null +++ b/internal/runtime/stale_linux.go @@ -0,0 +1,145 @@ +//go:build linux + +package runtime + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/AdminTurnedDevOps/ABox/internal/session" + "golang.org/x/sys/unix" +) + +type helperIdentity struct { + executable string + startID string + cwd string + uid int +} + +func recordHelper(sess *session.Session, pid int, executable string) error { + _ = executable + identity, err := readHelperIdentity(pid) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read helper identity: %w", err) + } + if identity.uid != os.Getuid() || filepath.Clean(identity.cwd) != filepath.Clean(sess.Dir) { + return fmt.Errorf("started helper identity does not match the session") + } + sess.HelperPID = pid + sess.HelperStartID = identity.startID + sess.HelperExecutable = identity.executable + return sess.WriteMeta() +} + +func clearHelper(sess *session.Session) error { + if sess == nil || (sess.HelperPID == 0 && sess.HelperStartID == "" && sess.HelperExecutable == "") { + return nil + } + sess.HelperPID = 0 + sess.HelperStartID = "" + sess.HelperExecutable = "" + return sess.WriteMeta() +} + +func cleanupStaleHelper(sess *session.Session, executable string) error { + _ = executable + if sess.HelperPID <= 0 { + return clearHelper(sess) + } + identity, err := readHelperIdentity(sess.HelperPID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return clearHelper(sess) + } + return fmt.Errorf("inspect stale VMM helper: %w", err) + } + valid := identity.uid == os.Getuid() && + identity.executable == sess.HelperExecutable && + identity.startID == sess.HelperStartID && filepath.Clean(identity.cwd) == filepath.Clean(sess.Dir) + if !valid { + return clearHelper(sess) + } + pidfd, err := unix.PidfdOpen(sess.HelperPID, 0) + if err != nil { + if errors.Is(err, syscall.ESRCH) { + return clearHelper(sess) + } + return fmt.Errorf("open stale VMM pidfd: %w", err) + } + defer unix.Close(pidfd) + identity, err = readHelperIdentity(sess.HelperPID) + if err != nil || identity.uid != os.Getuid() || identity.executable != sess.HelperExecutable || + identity.startID != sess.HelperStartID || filepath.Clean(identity.cwd) != filepath.Clean(sess.Dir) { + return clearHelper(sess) + } + if err := unix.PidfdSendSignal(pidfd, unix.SIGTERM, nil, 0); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("stop stale VMM helper: %w", err) + } + poll := []unix.PollFd{{Fd: int32(pidfd), Events: unix.POLLIN}} + if n, err := unix.Poll(poll, 2000); err != nil { + return fmt.Errorf("wait for stale VMM helper: %w", err) + } else if n > 0 { + return clearHelper(sess) + } + if err := unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("kill stale VMM helper: %w", err) + } + if n, err := unix.Poll(poll, 2000); err != nil { + return fmt.Errorf("wait for killed stale VMM helper: %w", err) + } else if n == 0 { + return fmt.Errorf("stale VMM helper did not exit after SIGKILL") + } + return clearHelper(sess) +} + +func readHelperIdentity(pid int) (helperIdentity, error) { + base := filepath.Join("/proc", strconv.Itoa(pid)) + executable, err := os.Readlink(filepath.Join(base, "exe")) + if err != nil { + return helperIdentity{}, err + } + cwd, err := os.Readlink(filepath.Join(base, "cwd")) + if err != nil { + return helperIdentity{}, err + } + status, err := os.ReadFile(filepath.Join(base, "status")) + if err != nil { + return helperIdentity{}, err + } + uid := -1 + for _, line := range strings.Split(string(status), "\n") { + if strings.HasPrefix(line, "Uid:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + uid, err = strconv.Atoi(fields[1]) + } + break + } + } + if err != nil || uid < 0 { + return helperIdentity{}, fmt.Errorf("parse process uid") + } + stat, err := os.ReadFile(filepath.Join(base, "stat")) + if err != nil { + return helperIdentity{}, err + } + closeParen := strings.LastIndexByte(string(stat), ')') + if closeParen < 0 { + return helperIdentity{}, fmt.Errorf("parse process stat") + } + fields := strings.Fields(string(stat)[closeParen+1:]) + if len(fields) <= 19 { + return helperIdentity{}, fmt.Errorf("parse process start identity") + } + executable = strings.TrimSuffix(executable, " (deleted)") + return helperIdentity{executable: executable, startID: fields[19], cwd: cwd, uid: uid}, nil +} diff --git a/internal/runtime/stale_linux_test.go b/internal/runtime/stale_linux_test.go new file mode 100644 index 0000000..a5c6633 --- /dev/null +++ b/internal/runtime/stale_linux_test.go @@ -0,0 +1,81 @@ +//go:build linux + +package runtime + +import ( + "errors" + "os" + "os/exec" + "syscall" + "testing" +) + +func TestCleanupStaleHelperRequiresExactIdentity(t *testing.T) { + sess := shortTestSession(t) + cmd := exec.Command("sleep", "30") + cmd.Dir = sess.Dir + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + t.Cleanup(func() { + _ = cmd.Process.Kill() + <-waited + }) + if err := recordHelper(sess, cmd.Process.Pid, cmd.Path); err != nil { + t.Fatal(err) + } + sess.HelperStartID = "not-the-process-start-id" + if err := sess.WriteMeta(); err != nil { + t.Fatal(err) + } + if err := cleanupStaleHelper(sess, cmd.Path); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(cmd.Process.Pid, 0); err != nil { + t.Fatalf("identity mismatch signaled unrelated process: %v", err) + } +} + +func TestCleanupStaleHelperStopsValidatedProcess(t *testing.T) { + sess := shortTestSession(t) + cmd := exec.Command("sleep", "30") + cmd.Dir = sess.Dir + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + if err := recordHelper(sess, cmd.Process.Pid, cmd.Path); err != nil { + t.Fatal(err) + } + if err := cleanupStaleHelper(sess, cmd.Path); err != nil { + t.Fatal(err) + } + if err := <-waited; err == nil { + t.Fatal("stale helper exited successfully after termination signal") + } + if err := syscall.Kill(cmd.Process.Pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("validated stale helper remains: %v", err) + } + if sess.HelperPID != 0 || sess.HelperStartID != "" || sess.HelperExecutable != "" { + t.Fatalf("helper metadata was not cleared: %#v", sess) + } +} + +func TestCleanupStaleHelperClearsMissingProcess(t *testing.T) { + sess := shortTestSession(t) + sess.HelperPID = 1 << 30 + sess.HelperStartID = "missing" + sess.HelperExecutable = "/missing" + if err := cleanupStaleHelper(sess, "/missing"); err != nil { + t.Fatal(err) + } + if sess.HelperPID != 0 { + t.Fatalf("helper pid = %d", sess.HelperPID) + } + if _, err := os.Stat(sess.Dir + "/session.json"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/runtime/stale_other.go b/internal/runtime/stale_other.go new file mode 100644 index 0000000..e8227fe --- /dev/null +++ b/internal/runtime/stale_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package runtime + +import "github.com/AdminTurnedDevOps/ABox/internal/session" + +func recordHelper(*session.Session, int, string) error { return nil } +func clearHelper(*session.Session) error { return nil } +func cleanupStaleHelper(*session.Session, string) error { return nil } diff --git a/internal/runtime/stop_linux.go b/internal/runtime/stop_linux.go new file mode 100644 index 0000000..1371f30 --- /dev/null +++ b/internal/runtime/stop_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package runtime + +import ( + "os" + "syscall" +) + +func helperStopSignal() os.Signal { + return syscall.SIGTERM +} diff --git a/internal/runtime/stop_other.go b/internal/runtime/stop_other.go new file mode 100644 index 0000000..230db32 --- /dev/null +++ b/internal/runtime/stop_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package runtime + +import "os" + +func helperStopSignal() os.Signal { + return os.Interrupt +} diff --git a/internal/session/lock_other.go b/internal/session/lock_other.go new file mode 100644 index 0000000..f64e1db --- /dev/null +++ b/internal/session/lock_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin + +package session + +import "fmt" + +func (s *Session) AcquireRuntimeLock() (bool, error) { + return false, fmt.Errorf("session runtime locks are unsupported on this platform") +} + +func (s *Session) ReleaseRuntimeLock() error { return nil } diff --git a/internal/session/lock_unix.go b/internal/session/lock_unix.go new file mode 100644 index 0000000..c5a2be5 --- /dev/null +++ b/internal/session/lock_unix.go @@ -0,0 +1,46 @@ +//go:build linux || darwin + +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func (s *Session) AcquireRuntimeLock() (bool, error) { + if s.runtimeLock != nil { + return false, nil + } + path := filepath.Join(s.Dir, "runtime.lock") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return false, fmt.Errorf("open session runtime lock: %w", err) + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + f.Close() + if errors.Is(err, unix.EWOULDBLOCK) { + return false, fmt.Errorf("session %s is already active", s.ID) + } + return false, fmt.Errorf("lock session runtime: %w", err) + } + s.runtimeLock = f + return true, nil +} + +func (s *Session) ReleaseRuntimeLock() error { + if s.runtimeLock == nil { + return nil + } + f := s.runtimeLock + s.runtimeLock = nil + err := unix.Flock(int(f.Fd()), unix.LOCK_UN) + closeErr := f.Close() + if err != nil { + return err + } + return closeErr +} diff --git a/internal/session/session.go b/internal/session/session.go index b4ebeb5..8b4ef8d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -14,11 +14,24 @@ import ( ) type Session struct { - ID string `json:"id"` - Capability string `json:"capability"` - Created time.Time `json:"created"` - SourceDir string `json:"source_dir,omitempty"` - Dir string `json:"dir"` + ID string `json:"id"` + Capability string `json:"capability"` + Created time.Time `json:"created"` + SourceDir string `json:"source_dir,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + HEAD string `json:"head,omitempty"` + Dir string `json:"dir"` + ManifestSchema int `json:"manifest_schema,omitempty"` + GuestArch string `json:"guest_arch,omitempty"` + ImageID string `json:"image_id,omitempty"` + ImageSHA256 string `json:"image_sha256,omitempty"` + GuestProtocol int `json:"guest_protocol,omitempty"` + VMMBackend string `json:"vmm_backend,omitempty"` + HelperPID int `json:"helper_pid,omitempty"` + HelperStartID string `json:"helper_start_id,omitempty"` + HelperExecutable string `json:"helper_executable,omitempty"` + DiagnosticProbe bool `json:"-"` + runtimeLock *os.File } func Create(sourceDir string) (*Session, error) { @@ -76,7 +89,16 @@ func (s *Session) WriteMeta() error { if err != nil { return err } - return os.WriteFile(filepath.Join(s.Dir, "session.json"), data, 0o600) + path := filepath.Join(s.Dir, "session.json") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Chmod(path, 0o600) } func (s *Session) RPCSocket() string { return filepath.Join(s.Dir, "rpc.sock") } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 2bd8b08..cd61e80 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -126,3 +126,56 @@ func TestLoadRejectsInvalidSessionID(t *testing.T) { } } } + +func TestCompatibilityMetadataRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := Create("/source") + if err != nil { + t.Fatal(err) + } + s.ManifestSchema = 1 + s.GuestArch = "amd64" + s.ImageID = "image-id" + s.ImageSHA256 = strings.Repeat("a", 64) + s.GuestProtocol = 4 + s.VMMBackend = "kvm" + s.RepoRoot = "/source" + s.HEAD = strings.Repeat("b", 40) + if err := os.WriteFile(s.RootDisk(), []byte("disk"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.WriteMeta(); err != nil { + t.Fatal(err) + } + got, err := Load(s.ID) + if err != nil { + t.Fatal(err) + } + if got.ManifestSchema != 1 || got.GuestArch != "amd64" || got.ImageID != "image-id" || got.ImageSHA256 != s.ImageSHA256 || got.GuestProtocol != 4 || got.VMMBackend != "kvm" || got.RepoRoot != "/source" || got.HEAD != s.HEAD { + t.Fatalf("metadata = %#v", got) + } +} + +func TestRuntimeLockRejectsConcurrentSession(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := Create("/source") + if err != nil { + t.Fatal(err) + } + other := *s + if acquired, err := s.AcquireRuntimeLock(); err != nil || !acquired { + t.Fatalf("first lock: acquired=%v err=%v", acquired, err) + } + if _, err := other.AcquireRuntimeLock(); err == nil || !strings.Contains(err.Error(), "already active") { + t.Fatalf("second lock: %v", err) + } + if err := s.ReleaseRuntimeLock(); err != nil { + t.Fatal(err) + } + if acquired, err := other.AcquireRuntimeLock(); err != nil || !acquired { + t.Fatalf("lock after release: acquired=%v err=%v", acquired, err) + } + if err := other.ReleaseRuntimeLock(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 23b12f3..6b2a05a 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -15,9 +15,9 @@ type slashCmd struct { } var slashCommands = []slashCmd{ - {Name: "/provider", Help: "Connect Grok, OpenAI, or Anthropic and set an API key"}, - {Name: "/credential", Help: "Point a model at Vault, Azure Key Vault, or AWS Secrets Manager"}, - {Name: "/mcp", Help: "List MCP servers and paste a Bearer token (OAuth: abox mcp login)"}, + {Name: "/provider", Help: "Connect a provider and save an API key in the OS keystore"}, + {Name: "/credential", Help: "Point a model at a cloud credential store"}, + {Name: "/mcp", Help: "Save an MCP Bearer token in the OS keystore (OAuth: abox mcp login)"}, {Name: "/help", Help: "List slash commands"}, } diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 80e09bf..8637a8e 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -21,7 +21,7 @@ func TestApplyProviderKeyAddsMissingProfileWithCurrentReference(t *testing.T) { if envName != "OPENAI_API_KEY" || value != "secret" { t.Fatalf("save %q %q", envName, value) } - return credsource.SaveResult{Source: "keychain", Keychain: true, Note: "test"}, nil + return credsource.SaveResult{Source: "keystore", Keystore: true, Note: "test"}, nil } cfg := config.Defaults() @@ -31,7 +31,7 @@ func TestApplyProviderKeyAddsMissingProfileWithCurrentReference(t *testing.T) { if err != nil { t.Fatal(err) } - if sel.Name != choice.Name || sel.Credential == nil || sel.Credential.Source != "keychain" || sel.Credential.Name != choice.Env { + if sel.Name != choice.Name || sel.Credential == nil || sel.Credential.Source != "keystore" || sel.Credential.Name != choice.Env { t.Fatalf("selected model %+v", sel) } persisted, _, err := config.Load() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 16d17c2..701ce47 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -81,7 +81,7 @@ type runCommandApprovalRequest struct { settled chan struct{} } -// Presence is cached: the render path must not shell out to keychain or HTTP. +// Presence is cached: the render path must not access an OS keystore or HTTP. type credStatusMsg struct { sel string prov map[string]string @@ -977,7 +977,7 @@ func max(a, b int) int { return b } -func Run(cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbroker.Broker, vmState string, log []string, resolver *credsource.Resolver, transcriptPath string) error { +func Run(ctx context.Context, cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbroker.Broker, vmState string, log []string, resolver *credsource.Resolver, transcriptPath string) error { if resolver == nil { resolver = credsource.NewResolver() } @@ -992,7 +992,10 @@ func Run(cfg config.File, sel config.Model, sb *runtime.Sandbox, broker *hostbro return fmt.Errorf("configure run_command approval: %w", err) } } - p := tea.NewProgram(m) + p := tea.NewProgram(m, tea.WithContext(ctx)) _, err := p.Run() + if ctx.Err() != nil { + return ctx.Err() + } return err } diff --git a/packaging/libkrun-required-symbols.txt b/packaging/libkrun-required-symbols.txt new file mode 100644 index 0000000..416db05 --- /dev/null +++ b/packaging/libkrun-required-symbols.txt @@ -0,0 +1,12 @@ +krun_add_disk3 +krun_add_vsock +krun_add_vsock_port +krun_create_ctx +krun_disable_implicit_vsock +krun_free_ctx +krun_has_feature +krun_set_console_output +krun_set_exec +krun_set_root_disk_remount +krun_set_vm_config +krun_start_enter diff --git a/pkg/abox/abox.go b/pkg/abox/abox.go index 3c0a2cc..7195a07 100644 --- a/pkg/abox/abox.go +++ b/pkg/abox/abox.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sync" "time" @@ -95,19 +96,39 @@ func open(ctx context.Context, opts Options, resume bool, resumeID string) (*Ses } sess = loaded } else { - sourceDir, data, err := repository.ArchiveDirectory(opts.RepoPath) + created, err := session.Create(opts.RepoPath) if err != nil { resolver.Close() - return nil, fmt.Errorf("snapshot source directory: %w", err) + return nil, fmt.Errorf("create session: %w", err) } - archive = data - created, err := session.Create(sourceDir) + snap, err := repository.OpenForSessionExcluding(opts.RepoPath, filepath.Join(created.Dir, "host-tree"), config.Dir()) if err != nil { + _ = os.RemoveAll(created.Dir) resolver.Close() - return nil, fmt.Errorf("create session: %w", err) + return nil, fmt.Errorf("snapshot repository: %w", err) + } + archive, err = repository.ArchiveHEAD(snap.Root) + if err != nil { + _ = os.RemoveAll(created.Dir) + resolver.Close() + return nil, fmt.Errorf("archive repository: %w", err) + } + created.SourceDir = snap.HostSource + created.RepoRoot = snap.HostSource + created.HEAD = snap.HEAD + if err := created.WriteMeta(); err != nil { + _ = os.RemoveAll(created.Dir) + resolver.Close() + return nil, fmt.Errorf("write session metadata: %w", err) } sess = created } + releaseRuntimeLock := true + defer func() { + if releaseRuntimeLock { + _ = sess.ReleaseRuntimeLock() + } + }() image := opts.Image if image == "" { @@ -166,6 +187,7 @@ func open(ctx context.Context, opts Options, resume bool, resumeID string) (*Ses return nil, fmt.Errorf("transfer source directory: %w", err) } } + releaseRuntimeLock = false return &Session{cfg: cfg, sess: sess, sb: sb, sel: sel, resolver: resolver, broker: broker}, nil } diff --git a/pkg/abox/doc.go b/pkg/abox/doc.go index 89f799c..af84479 100644 --- a/pkg/abox/doc.go +++ b/pkg/abox/doc.go @@ -1,7 +1,9 @@ // Package abox is the public Go SDK for embedding an ABox microVM agent session. // -// Runtime requirements: Apple Silicon, libkrun/libkrunfw, and a golden guest -// image (`make image`). Open and Resume require protocol 4 (host LLM/MCP +// Runtime requirements: macOS/arm64 with HVF or a supported Linux/amd64 KVM +// baseline, libkrun/libkrunfw, and a matching golden guest image (`make image`). +// Linux runtime support remains Planned until the documented hardware gates +// pass. Open and Resume require protocol 4 (host LLM/MCP // brokers and run_command approval). Older guests return ErrGuestTooOld. // Protocol 2 was the secret-push path; protocol 3 added the host provider // broker. Credentials and MCP tokens stay on the host. diff --git a/protocol/protocol.go b/protocol/protocol.go index ee19bec..a678fc4 100644 --- a/protocol/protocol.go +++ b/protocol/protocol.go @@ -14,6 +14,9 @@ const ( MaxFrameBytes = 1 << 20 MaxArchiveChunk = 256 << 10 + MaxArchiveFiles = 20000 + MaxArchiveFile = 256 << 20 + MaxArchiveBytes = 256 << 20 MaxHistoryBytes = 256 << 10 RPCPort = 1024 GuestRepoDir = "/work/repo" diff --git a/scripts/__pycache__/validate-hardware-report.cpython-314.pyc b/scripts/__pycache__/validate-hardware-report.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b71f32d0a01c3a7c53ced907dac13e8d4d9e206f GIT binary patch literal 3564 zcmb7G-EZ606~7cIQPh`ZTb92qI&$pDiQ_Iy{2@EZf~0BItd7QRS%#Jo7>Tl(>BAte zV@C^CWhquSFHy1qkx>+NFbuQNVac$U{t4Zi9J;a04A_c3<;_7l413$Tlqk84+jRtV z?>)Td{LaVy-9sJpd0hye_0Ru&wag;)1re;rQ(L_FjUAx^x{L(shX_|^3R9+R!!~oK zF)i5IkwCYjv>!8~y&s8m%tEjbVLnP#h0E;3B0@XG}u5rg~A7|kMz4K(~%D>IHEOKQwVYfCGJSfsIMtt=hfCO9us0w=lzm*^JUqDSzE zZGupjk=vXVU!-_)eBpjKg2}jQNp(;;& zVn@{GK|<62vNpB+Vkeo8I4sN%I)54=4)r0?zn6ER+0w+_dwURC>KZ*_FI2Q!>^lk> zk;f`+jo00lJlo)Tt7Q#5C)PbLTkp~iG_*$%4v9Tb!39y z3KDuV)FJbxe=SaL{rbpehwj{G_0EcQ_qf;D!-LGhvQr&uY))oe^r>kyP*ZeBV-gZZJcPa2J zbhksK-_Cf8`<8dSU)*o;A_*O`mPwDeA9%XE`h$>Jz1AVLb_$dejC;%rnASgU!gACe6x*LzVG@ znw^PI2CZQgCPyl#W+bc;Kl_ZZmQa;>tc_li(n?ao^2nT|CKn}D9#Q4_oQe<6UpGAQ zxRlN2Fl@);Dj}Q`ej4e|1@v!3QGL(J@VoF1nMwnu^a6H~nGso#hh$@B1Y`!ox2;Se zH>-+sRD>e2y5795VL9{al7b_Y3Lh$D#yhCOjY9^Ll9aScJ|<=yz*2zwyDL!qneOT> zGrg)C-sF=Dqyka?TQcLPGg4Mb$r?VvPgnDWMN)xEWikrcN=VscwL03IbmZ6= z(O$@AW%We#8Odm7CU0NBsgVf?-6>}ixulYvH5^)EPR>Y%qY7v^s(6OK4i~S&8C_B$ zAvHMAn|8>M8HH4u-D=%I6tg(fGW3ikFw>sU=jf!By%i65>`Q8h?pdpaj2HG@-SDH+F5eZy(tkQPIZ64+th4Z~$BUAYcLGq^c|Xd6!{avIR>3S4>3pnoA>hg($Rv_a3x*zj2*I0u5Oq=OhP1^57zgj-`U zCP^7hhIAny!bO8gDhUj2PsS?wlLi4om@?3qv=5l=^Qw}?YA@`W9iw`p&go1pxsaAm zsHb6r^eFAO&{KD4ilV+izNg6jl#H%_IY{4gB8Dw!?=9V2x^=vGYLjl;W}G`ViW~is zrtOX$7i#Xf@44qGz4_6ll}oGAhi~crNWok2wcSbIPM5y_(ZtHc>ZuQ>bl*_HT^Shq zh2#DoE6S{r|WiC z@%&aGvJr?p8ZQSTdSFWDrVEZrOXvN=_YSZ2ZuL)Y^iS&jQ+mtvR?FFqma}@xxkA&9 z1KGXHQibs>r)op6#8nu6b<`Ahylkrt)ch#8f2(bHqiy&}+o79p6izR9R9NqO`J4G- z_|`k615em}+fGlpDYQzjEUqTk#vVr3qmL3FpWGNe{>fXPcKpp#_WtyV^Wt+m3XbeJ zQA^kTp1VDzS-p9%;H$X&cZS~|F8*A1g$nF8+gv=nIN$hsgV( z($OuJf6Vgc+wW~MNB+rj1?H)*t#EeR-BwJMl3VWZV|Vz0wl?45`?JIQY%m>_q(`!C`VEnU=alLt>?4R7R+qkJOX~yn&?m{mAMpJAJ rKg_S^|8zrdis@Xe;P?m4k&N9VYpJzMWo~Sf8UGyF3%MsKXp;87^WPTk literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/verify-kvm-attestation.cpython-314.pyc b/scripts/__pycache__/verify-kvm-attestation.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01378331c92148a1c7d7fb3fe997b848d42c2f94 GIT binary patch literal 6148 zcmbtW+jA4w89&mlb+>$x@5XrLy9}~ykcltY#Do|y0i1;-w8`4Dme!WAZrNQKTb-mL zNr%f!#)39u(V0xlq@BP^!y|t}`zCu;q-;7(=DPbEc7N2ziYN9Y73eM>MJKv?E443%2Zi_LDfLIowjD zRi%iASCpbHE5-PVQi!rrOs^<~EGxxaKB99KrPQKjA1d80s>)ZiNMFibh_Q7aU4~L> zv1%VG<1VUdMl;({%%eJjtQ%&#)miaNSJ1lG@>nX^V`J)o-p16swK>Y(JcAeT1USQ} zw5il{nmq{At2=-|+Z-yjZq`}Utd3}d3#od_?i*EkVxwYLBh#ePt9C6F@o!S8LHmA@ zFurson)j7qdTALgOk-KU%&MidWlsbv)gADmJg-%yV4C;vR;fF{i)N3hQ7(QVcjnpA z;#?iM^8=NCc3j;lepMRr;68R$tK4F{lB@Qg#8A6R{Q`z+)E)4m{UtC|`!g6Spp`ke zCth_Wn)ZvA_5Y3+wJ(mWFO4HRT~9aAjdYVsD|RpzrehT9)>~QV{LPGIOlv_$v_+5T zmN^aG%2? zS9tqh>}1|1C7*{ta=*$ui0Gq1&0*z+k;{l`S4s8{G5ve9&-9n|-=WGC4>N}u2W|x= za$xNx=)v=?a5s7diK<>Rfa& zu35Hdr#hl3TBDv=z*C;azKf=XaVe1kFY@=gBdQjNjHXlhJKR5F6&F1C{KkL$kno3! zL(lu*6-V!)Iqk@2kYS|?-`4mvwB`fY$vw1|CS2N1a)Q7vak7r*#3&!S z;Lyl~AoB3hEr$6ZD+*Xohin%-ygwoet`&~=FRnS4SAtGf6gfd;MSnOn5LuHg^Yd&d z6ov(EexAqRHNaoMcg#5Y57KB3KEtoyb?)F#vP+pghh3!jg9rG*z9{}cVF2R?s-NJQ z8WEqyp~04#F0Bb77kqQoFFG`Q1<)(j_V7^6NG2Cqe}KmqV#N;Vn;`CQrnlbx=$>&sd{T{_v=T#vbnxu|Lc+QKLI2iL>B+QF2@US7T-f|7U5zieWDl<#$2u?zvzoD3_t{2Q{JFIbQwU#c~5aG7bAje>STD;Rbrgr!mz@i za;*%-9MMq0**#uR1#OfF>SSYK9-HunuZ03()(d79Rw-|o!}g05All=jL?1^9{w1)4 zI(6yNneb8yr-^qO;Z{hSO%;(hX#g52i(pfbz$+E zb9i8QV9+@PMay}^JR7^1`#Rn3vdlm_u;_u!yvU-2+_Jz0{GsTolM6u;@L=4vMMFX~ zf3%cTqkIfRc<;I_xd1z8*hjCom?gS=c_@NGU97`dfb1M_MPsC&9xCCG*a2n$%i z$<};HvG5Tl9;A^W+aQ{kMW=-Mqb&rc}{ zU*IT~3V>6x*6#&3p-`eitRct-aC?LhVBQ?Z7!_;qMk4{GC+uDy->m_wr36ld_$@AS zyg)7T;h+M_GPb_Z96OEse*QI9zZ1B=3Obck#XrUO$40l+JxWn4C^D zJkd9P9sI9A!ulsQHqGVf*zj)83U%fQheXyNqFDF^0#2+vA13U0Y$6g}fT;Q)fRJJD zHI~QafZ_EMrCP}QBt9Q_90vd#2YLdEKaL?;4}Ho9pq5G8q-A}C6_qI=tO?3dAQJ&D zgzIc+MJ8uqSFR~-i1V01wiYzy=H{Gf`OPAvF)@$NH@+3yQmMi#M_8p|ay}31>cT=E z)p7-HRDx{eIo3Nba;u_jDYZs6`2~LnFb4;QOe!cOmN=2G!P-bTd_*3n2p`@!bbJGz zm=s|PYXCCY=;K!Njj5y(^DBmYOA_R&g5m0xx*!|XQWW42%Ga1&;fK>gkcnk(O|DpB z15u8*W7Srb0(^nelSzStV1sqaDMLWwWj)@Rhf*Tg(3-4Mm<72q*No?Le#k^59O3ar zOgW-+_%s|3hP}}MH_5*P8~9QntN;#o2#rSb1G0XP%-`d;>EAkh(J&xV9~W+{USIum zB5^WJG<-)IcC;Gf70tg0qC&T0LiGnW-n#qNrtO=i`%Mq*e{7ZNoGDb3t!dggaQ8sc z^Of(OZ}Y=1m!+EixHW67Uk}_4B#(YIa&KgF>dSG-+8a0TLu<6GH{EJV*gtQL>$1Bh z)r}jLyO!jouddv?vdMn=zEtgqS7vMLH~Q}MC5OH^6tBwK8a77mj3lSN7*C<azL0iLFe zGimZ%*3y)$`TSzW(!OPBPf_ki3zB6tW0}~pOgvteEHiN24WZalFR<5_)Zb_WW)H$~59FJa0)j6cPamjcb zoFyzbCazB;+9krC>h`Azzar9|CWqCnR*7iaY)%vX%FdZIdG@KTWn=R0F(DQ zc_V3ZG)tIoPW@*5#)<1E5+g}kBB%_}wMBGoUXh4_)DeKfNrm=Antc6fZS%&!-GR-v zjD2*=J}TMArP||}+Uc#@X{q)MWW!vwK6ZO75zI6=w;G&}uB95BQo{+!{CeE*lr*I* zr?aFbWu4!tAS$(S;|_q%lmPEqum*GAgNBEul<{bqbU!66H(8*)xtM=7q?-D&B(>?n z^Kh25CXC6RzgF9`WL>JEXQ#$!(Z&rsb;#DbF>q(#No~jVnfMzw>$Cdm8y{W&C~-;B zHz(hD0y#5SZhoBf+51X;vkGME+|eNj zr1O?DX_QPIaecO?dE>yH1IZ<+rYCO2VIA1)%IJHy^}XMItVHNgnmmlxdz0>rp4!$^ z%Bm|(4rcYnIQhM`K0f`OxjwO&^k&SR+vd*u!h>U9|7v^qVy5?Ey7wa3ma6LBT)h9w zROMjWFqE~{tzW%$HCZQF55`Yt_0~k!XTQ!9E!}@VRXLP49Ld(TY|Px5Nv=wDeeqK_ z8$Uh&tP)iK2(`OHCw1A(Vz6DQD " >&2 + exit 2 +fi +EXPECTED_VERSION=$1 +FIRMWARE_SONAME=$2 + +for tool in pkg-config cc nm strings cmp; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "required compatibility-check tool is missing: $tool" >&2 + exit 1 + fi +done + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/abox-libkrun-check.XXXXXX") +trap 'rm -rf "$WORK"' EXIT HUP INT TERM + +sh "$ROOT/scripts/generate-libkrun-symbols.sh" > "$WORK/generated-symbols.txt" +if ! cmp -s "$MANIFEST" "$WORK/generated-symbols.txt"; then + echo "libkrun symbol manifest is stale; regenerate it with:" >&2 + echo " sh scripts/generate-libkrun-symbols.sh > packaging/libkrun-required-symbols.txt" >&2 + diff -u "$MANIFEST" "$WORK/generated-symbols.txt" >&2 || true + exit 1 +fi + +ACTUAL_VERSION=$(pkg-config --modversion libkrun) +if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "libkrun pkg-config version is $ACTUAL_VERSION; expected $EXPECTED_VERSION" >&2 + exit 1 +fi + +{ + echo '#include ' + echo 'static void abox_check_header(void) {' + while IFS= read -r symbol; do + [ -n "$symbol" ] || continue + printf ' (void)&%s;\n' "$symbol" + done < "$MANIFEST" + echo '}' +} > "$WORK/header-check.c" +# shellcheck disable=SC2046 +cc -Werror $(pkg-config --cflags libkrun) -c "$WORK/header-check.c" -o "$WORK/header-check.o" + +LIBDIR=$(pkg-config --variable=libdir libkrun) +LIBRARY= +for candidate in "$LIBDIR/libkrun.so" "$LIBDIR"/libkrun.so.*; do + if [ -f "$candidate" ]; then + LIBRARY=$(readlink -f "$candidate") + break + fi +done +if [ -z "$LIBRARY" ]; then + echo "pkg-config libdir contains no libkrun shared library: $LIBDIR" >&2 + exit 1 +fi + +nm -D --defined-only "$LIBRARY" | while IFS= read -r line; do + set -- $line + symbol=${3:-} + printf '%s\n' "${symbol%%@*}" +done | LC_ALL=C sort -u > "$WORK/exported-symbols.txt" + +while IFS= read -r symbol; do + [ -n "$symbol" ] || continue + if ! grep -Fxq "$symbol" "$WORK/exported-symbols.txt"; then + echo "$LIBRARY does not export required symbol $symbol" >&2 + exit 1 + fi +done < "$MANIFEST" + +if ! strings "$LIBRARY" | grep -Fq "$FIRMWARE_SONAME"; then + echo "$LIBRARY does not reference expected firmware SONAME $FIRMWARE_SONAME" >&2 + exit 1 +fi + +echo "verified libkrun $ACTUAL_VERSION, $FIRMWARE_SONAME, and all required binding symbols" diff --git a/scripts/generate-libkrun-symbols.sh b/scripts/generate-libkrun-symbols.sh new file mode 100644 index 0000000..436af12 --- /dev/null +++ b/scripts/generate-libkrun-symbols.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +BINDING=${1:-"$ROOT/cmd/abox-vmm/start_libkrun.go"} + +if [ ! -f "$BINDING" ]; then + echo "libkrun binding not found: $BINDING" >&2 + exit 1 +fi + +SYMBOLS=$(sed -n 's/.*C\.\(krun_[[:alnum:]_]*\).*/\1/p' "$BINDING" | LC_ALL=C sort -u) +if [ -z "$SYMBOLS" ]; then + echo "no C.krun_* calls found in $BINDING" >&2 + exit 1 +fi + +printf '%s\n' "$SYMBOLS" diff --git a/scripts/install-arch-libkrun.sh b/scripts/install-arch-libkrun.sh new file mode 100644 index 0000000..e8e5605 --- /dev/null +++ b/scripts/install-arch-libkrun.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Arch package installation must run as root" >&2 + exit 1 +fi + +BASE=https://archive.archlinux.org/packages/l +KRUN=libkrun-1.19.4-1-x86_64.pkg.tar.zst +KRUNFW=libkrunfw-5.5.0-1-x86_64.pkg.tar.zst +KRUN_SHA256=cdda6e0006f69d9d45fa3d54b83e360c6779c67d3fa04cfd97eab31689504732 +KRUN_SIG_SHA256=18cd1fd25f1472b5fab5e39ab88c9297a787e906aec77cea16c561d010c05cba +KRUNFW_SHA256=6c6414e4f8c5fc2f74ed4f330b3e2c0872e07a87d2c35b389ad6105ba7c16338 +KRUNFW_SIG_SHA256=14c9e94acd5450b7fe6c3a5b6d4e72009dcf352628586905edd9607ca6dd01e2 + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/abox-arch-libkrun.XXXXXX") +trap 'rm -rf "$WORK"' EXIT HUP INT TERM + +download() { + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --output "$WORK/$2" "$1/$2" +} + +download "$BASE/libkrun" "$KRUN" +download "$BASE/libkrun" "$KRUN.sig" +download "$BASE/libkrunfw" "$KRUNFW" +download "$BASE/libkrunfw" "$KRUNFW.sig" + +printf '%s %s\n' "$KRUN_SHA256" "$WORK/$KRUN" \ + "$KRUN_SIG_SHA256" "$WORK/$KRUN.sig" \ + "$KRUNFW_SHA256" "$WORK/$KRUNFW" \ + "$KRUNFW_SIG_SHA256" "$WORK/$KRUNFW.sig" | sha256sum -c - +pacman-key --verify "$WORK/$KRUN.sig" "$WORK/$KRUN" +pacman-key --verify "$WORK/$KRUNFW.sig" "$WORK/$KRUNFW" +pacman -U --needed --noconfirm "$WORK/$KRUNFW" "$WORK/$KRUN" + +test "$(pacman -Q libkrun)" = 'libkrun 1.19.4-1' +test "$(pacman -Q libkrunfw)" = 'libkrunfw 5.5.0-1' diff --git a/scripts/install-fedora-libkrun.sh b/scripts/install-fedora-libkrun.sh new file mode 100644 index 0000000..e46e9d7 --- /dev/null +++ b/scripts/install-fedora-libkrun.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Fedora package installation must run as root" >&2 + exit 1 +fi + +BASE=https://dl.fedoraproject.org/pub/fedora/linux/updates/44/Everything/x86_64/Packages/l +KRUN=libkrun-1.19.0-1.fc44.x86_64.rpm +KRUN_DEVEL=libkrun-devel-1.19.0-1.fc44.x86_64.rpm +KRUNFW=libkrunfw-5.5.0-1.fc44.x86_64.rpm +KRUN_SHA256=2433513a051847a0ce6d35b932804168a70d512f846416a5a31d14d58632e243 +KRUN_DEVEL_SHA256=d1458f3fcd2075fd4107e4a94f65f59de31fae8b9b7e204c65801bac27a3ba33 +KRUNFW_SHA256=d006902bd255d13c74854c38fe4e8786b831c4004e056b49c3e2e88fadffd35e + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/abox-fedora-libkrun.XXXXXX") +trap 'rm -rf "$WORK"' EXIT HUP INT TERM + +for package in "$KRUN" "$KRUN_DEVEL" "$KRUNFW"; do + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --output "$WORK/$package" "$BASE/$package" +done +printf '%s %s\n' \ + "$KRUN_SHA256" "$WORK/$KRUN" \ + "$KRUN_DEVEL_SHA256" "$WORK/$KRUN_DEVEL" \ + "$KRUNFW_SHA256" "$WORK/$KRUNFW" | sha256sum -c - +rpmkeys --checksig "$WORK/$KRUN" "$WORK/$KRUN_DEVEL" "$WORK/$KRUNFW" +dnf -y install "$WORK/$KRUNFW" "$WORK/$KRUN" "$WORK/$KRUN_DEVEL" + +test "$(rpm -q libkrun)" = 'libkrun-1.19.0-1.fc44.x86_64' +test "$(rpm -q libkrun-devel)" = 'libkrun-devel-1.19.0-1.fc44.x86_64' +test "$(rpm -q libkrunfw)" = 'libkrunfw-5.5.0-1.fc44.x86_64' diff --git a/scripts/package-linux-release.sh b/scripts/package-linux-release.sh new file mode 100644 index 0000000..470b3fc --- /dev/null +++ b/scripts/package-linux-release.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +TAG=$1 +IMAGE_POINTER=$2 +OUT=$3 + +case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "release tag must look like v1.2.3" >&2; exit 1 ;; +esac + +for tool in sha256sum tar zstd readlink; do + command -v "$tool" >/dev/null 2>&1 || { + echo "release packaging requires $tool" >&2 + exit 1 + } +done +for file in "$ROOT/bin/abox" "$ROOT/bin/abox-vmm" "$ROOT/bin/abox-guest-linux-amd64"; do + if [ ! -x "$file" ]; then + echo "release binary missing or not executable: $file" >&2 + exit 1 + fi +done + +IMAGE=$(readlink -f "$IMAGE_POINTER") +MANIFEST="$IMAGE.manifest.json" +if [ ! -f "$IMAGE" ] || [ ! -f "$MANIFEST" ]; then + echo "release image or adjacent manifest is missing: $IMAGE" >&2 + exit 1 +fi + +mkdir -p "$OUT" +STAGE=$(mktemp -d "${TMPDIR:-/tmp}/abox-linux-package.XXXXXX") +trap 'rm -rf "$STAGE"' EXIT HUP INT TERM +NAME="abox_${TAG}_linux_amd64" +PAYLOAD="$STAGE/$NAME" +mkdir "$PAYLOAD" + +install -m 0755 "$ROOT/bin/abox" "$PAYLOAD/abox" +install -m 0755 "$ROOT/bin/abox-vmm" "$PAYLOAD/abox-vmm" +install -m 0755 "$ROOT/bin/abox-guest-linux-amd64" "$PAYLOAD/abox-guest-linux-amd64" +install -m 0444 "$MANIFEST" "$PAYLOAD/abox-guest-linux-amd64.raw.manifest.json" +zstd -q -19 -T0 "$IMAGE" -o "$PAYLOAD/abox-guest-linux-amd64.raw.zst" +cat > "$PAYLOAD/INSTALL" <<'EOF' +Install abox and abox-vmm on PATH. Decompress abox-guest-linux-amd64.raw.zst +to ~/.abox/images/abox-guest-linux-amd64.raw and place its adjacent manifest at +~/.abox/images/abox-guest-linux-amd64.raw.manifest.json. Install the exact +supported distro libkrun/libkrunfw package pair documented in docs/platforms.md. +Verify SHA256SUMS before installation. +EOF + +( + cd "$PAYLOAD" + sha256sum INSTALL abox abox-vmm abox-guest-linux-amd64 \ + abox-guest-linux-amd64.raw.manifest.json abox-guest-linux-amd64.raw.zst > SHA256SUMS +) + +SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-0} +ARCHIVE="$OUT/$NAME.tar.gz" +tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \ + -C "$STAGE" -czf "$ARCHIVE" "$NAME" +( + cd "$OUT" + sha256sum "$(basename "$ARCHIVE")" > "$(basename "$ARCHIVE").sha256" +) + +echo "$ARCHIVE" diff --git a/scripts/run-linux-hardware-gate.sh b/scripts/run-linux-hardware-gate.sh new file mode 100644 index 0000000..07d3aa9 --- /dev/null +++ b/scripts/run-linux-hardware-gate.sh @@ -0,0 +1,144 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +MANIFEST="$ROOT/.github/acceptance/linux-kvm-v1.json" + +if [ "$#" -ne 7 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +DISTRO=$1 +ARCHIVE=$2 +CANDIDATE_SHA256=$3 +COMMIT=$4 +EXPECTED_RUNNER=$5 +HARNESS=$6 +HARNESS_SHA256=$7 + +case "$ARCHIVE" in + */abox_v[0-9]*.[0-9]*.[0-9]*_linux_amd64.tar.gz) ;; + *) echo "invalid candidate archive name: $ARCHIVE" >&2; exit 1 ;; +esac +case "$CANDIDATE_SHA256" in + *[!0-9a-f]*|'') echo "candidate SHA-256 must be lowercase hexadecimal" >&2; exit 1 ;; +esac +[ "${#CANDIDATE_SHA256}" -eq 64 ] || { echo "candidate SHA-256 must have 64 digits" >&2; exit 1; } +case "$COMMIT" in + *[!0-9a-f]*|'') echo "commit must be lowercase hexadecimal" >&2; exit 1 ;; +esac +[ "${#COMMIT}" -eq 40 ] || { echo "commit must have 40 digits" >&2; exit 1; } + +if [ -z "$EXPECTED_RUNNER" ] || [ "${RUNNER_NAME:-}" != "$EXPECTED_RUNNER" ]; then + echo "protected runner identity mismatch: got ${RUNNER_NAME:-unset}, expected ${EXPECTED_RUNNER:-unset}" >&2 + exit 1 +fi +if [ ! -c /dev/kvm ] || [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + echo "protected hardware gate requires readable/writable /dev/kvm" >&2 + exit 1 +fi +if grep -Eqi '(microsoft|wsl)' /proc/sys/kernel/osrelease; then + echo "WSL is not an accepted KVM hardware runner" >&2 + exit 1 +fi +if command -v systemd-detect-virt >/dev/null 2>&1; then + if systemd-detect-virt --container >/dev/null 2>&1; then + echo "containerized VMM execution is not accepted as hardware evidence" >&2 + exit 1 + fi + VIRT=$(systemd-detect-virt 2>/dev/null || true) + if [ -n "$VIRT" ] && [ "$VIRT" != none ]; then + echo "hardware gate requires a named physical host; detected virtualization: $VIRT" >&2 + exit 1 + fi +fi + +. /etc/os-release +case "$DISTRO" in + arch) + [ "${ID:-}" = arch ] || { echo "Arch gate ran on ${ID:-unknown}" >&2; exit 1; } + [ "$(pacman -Q libkrun)" = "libkrun 1.19.4-1" ] || { pacman -Q libkrun >&2; exit 1; } + [ "$(pacman -Q libkrunfw)" = "libkrunfw 5.5.0-1" ] || { pacman -Q libkrunfw >&2; exit 1; } + LIBKRUN_VERSION=1.19.4 + LIBKRUN_PACKAGE=$(pacman -Q libkrun) + LIBKRUNFW_PACKAGE=$(pacman -Q libkrunfw) + ;; + fedora) + [ "${ID:-}" = fedora ] && [ "${VERSION_ID:-}" = 44 ] || { + echo "Fedora 44 gate ran on ${PRETTY_NAME:-unknown}" >&2 + exit 1 + } + [ "$(rpm -q libkrun)" = "libkrun-1.19.0-1.fc44.x86_64" ] || { rpm -q libkrun >&2; exit 1; } + [ "$(rpm -q libkrun-devel)" = "libkrun-devel-1.19.0-1.fc44.x86_64" ] || { rpm -q libkrun-devel >&2; exit 1; } + [ "$(rpm -q libkrunfw)" = "libkrunfw-5.5.0-1.fc44.x86_64" ] || { rpm -q libkrunfw >&2; exit 1; } + if command -v getenforce >/dev/null 2>&1; then + [ "$(getenforce)" = Enforcing ] || { echo "Fedora gate requires SELinux enforcing" >&2; exit 1; } + else + echo "Fedora gate cannot verify SELinux enforcing mode" >&2 + exit 1 + fi + LIBKRUN_VERSION=1.19.0 + LIBKRUN_PACKAGE=$(rpm -q libkrun) + LIBKRUNFW_PACKAGE=$(rpm -q libkrunfw) + ;; + *) echo "unknown hardware-gate distro: $DISTRO" >&2; exit 2 ;; +esac + +if [ ! -x "$HARNESS" ]; then + echo "protected runner harness is missing or not executable: $HARNESS" >&2 + exit 1 +fi +OWNER_MODE=$(stat -c '%u %a' "$HARNESS") +set -- $OWNER_MODE +if [ "$1" -ne 0 ] || [ $((0$2 & 0022)) -ne 0 ]; then + echo "hardware harness must be root-owned and not group/other writable: $OWNER_MODE" >&2 + exit 1 +fi +ACTUAL_HARNESS_SHA256=$(sha256sum "$HARNESS") +ACTUAL_HARNESS_SHA256=${ACTUAL_HARNESS_SHA256%% *} +if [ -z "$HARNESS_SHA256" ] || [ "$ACTUAL_HARNESS_SHA256" != "$HARNESS_SHA256" ]; then + echo "protected hardware harness digest mismatch" >&2 + exit 1 +fi + +sh "$ROOT/scripts/verify-linux-release.sh" "$ARCHIVE" "$CANDIDATE_SHA256" "$LIBKRUN_VERSION" + +EVIDENCE=${ABOX_EVIDENCE_DIR:?ABOX_EVIDENCE_DIR must name an empty evidence directory} +mkdir -p "$EVIDENCE" +[ -z "$(ls -A "$EVIDENCE")" ] || { echo "evidence directory is not empty" >&2; exit 1; } +REPORT="$EVIDENCE/report.json" + +"$HARNESS" \ + --acceptance-manifest "$MANIFEST" \ + --candidate "$ARCHIVE" \ + --candidate-sha256 "$CANDIDATE_SHA256" \ + --commit "$COMMIT" \ + --distro "$DISTRO" \ + --runner "$RUNNER_NAME" \ + --output "$REPORT" + +MANIFEST_SHA256=$(python3 "$ROOT/scripts/validate-hardware-report.py" \ + "$MANIFEST" "$REPORT" "$DISTRO" "$COMMIT" "$CANDIDATE_SHA256" "$RUNNER_NAME") + +{ + echo "runner=$RUNNER_NAME" + echo "distro=$PRETTY_NAME" + echo "kernel=$(uname -srvo)" + echo "architecture=$(uname -m)" + if command -v lscpu >/dev/null 2>&1; then + lscpu | grep -E '^(Model name|Vendor ID|Virtualization):' || true + fi + echo "commit=$COMMIT" + echo "candidate_sha256=$CANDIDATE_SHA256" + echo "acceptance_manifest_sha256=$MANIFEST_SHA256" + echo "harness_sha256=$ACTUAL_HARNESS_SHA256" + echo "libkrun_version=$LIBKRUN_VERSION" + echo "libkrun_package=$LIBKRUN_PACKAGE" + echo "libkrunfw_package=$LIBKRUNFW_PACKAGE" + echo "libkrun_soname=libkrun.so.1" + echo "firmware_soname=libkrunfw.so.5" + if command -v getenforce >/dev/null 2>&1; then echo "selinux=$(getenforce)"; fi + if command -v lsmod >/dev/null 2>&1; then lsmod | grep '^kvm' || true; fi +} > "$EVIDENCE/host.txt" + +echo "$MANIFEST_SHA256" diff --git a/scripts/test-rootless-image-build.sh b/scripts/test-rootless-image-build.sh new file mode 100644 index 0000000..401154c --- /dev/null +++ b/scripts/test-rootless-image-build.sh @@ -0,0 +1,76 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +ARCH=${1:-amd64} +WORK=$(mktemp -d "${TMPDIR:-/tmp}/abox-rootless-image.XXXXXX") +trap 'chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"' EXIT HUP INT TERM +IMAGE="$WORK/abox-guest-linux-$ARCH.raw" + +if [ "$(id -u)" -eq 0 ]; then + echo "rootless image test must run as an unprivileged user" >&2 + exit 1 +fi +if [ -e /dev/kvm ]; then + echo "build-only image test requires a runner/container without /dev/kvm" >&2 + exit 1 +fi +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + echo "build-only image test requires no reachable Docker daemon" >&2 + exit 1 +fi + +make -C "$ROOT" guest GUEST_ARCH="$ARCH" +make -C "$ROOT" image GUEST_ARCH="$ARCH" IMAGE="$IMAGE" +FIRST_TARGET=$(readlink "$IMAGE") +if [ -z "$FIRST_TARGET" ]; then + echo "image builder did not publish an immutable-generation pointer" >&2 + exit 1 +fi + +if ABOX_IMAGE_FAIL_BEFORE_PUBLISH=1 make -C "$ROOT" image GUEST_ARCH="$ARCH" IMAGE="$IMAGE"; then + echo "injected image publication failure unexpectedly succeeded" >&2 + exit 1 +fi +SECOND_TARGET=$(readlink "$IMAGE") +if [ "$FIRST_TARGET" != "$SECOND_TARGET" ]; then + echo "failed image build changed the current-generation pointer" >&2 + exit 1 +fi + +RESOLVED=$(readlink -f "$IMAGE") +MANIFEST="$RESOLVED.manifest.json" +python3 - "$RESOLVED" "$MANIFEST" "$ARCH" <<'PY' +import hashlib +import json +import pathlib +import sys + +image = pathlib.Path(sys.argv[1]) +manifest_path = pathlib.Path(sys.argv[2]) +arch = sys.argv[3] +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +digest = hashlib.sha256(image.read_bytes()).hexdigest() +if manifest != { + "schema": 1, + "arch": arch, + "image_id": "abox-guest-dev", + "protocol": 4, + "sha256": digest, +}: + raise SystemExit(f"unexpected image manifest: {manifest!r}") +PY + +e2fsck -fn "$RESOLVED" +ROOT_STAT=$(debugfs -R 'stat /' "$RESOLVED" 2>&1) +GUEST_STAT=$(debugfs -R 'stat /usr/local/bin/abox-guest' "$RESOLVED" 2>&1) +REPO_STAT=$(debugfs -R 'stat /work/repo' "$RESOLVED" 2>&1) +BUSYBOX_STAT=$(debugfs -R 'stat /bin/busybox' "$RESOLVED" 2>&1) +printf '%s\n' "$ROOT_STAT" | grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0' +printf '%s\n' "$ROOT_STAT" | grep -Eq 'Mode:[[:space:]]+0755' +printf '%s\n' "$GUEST_STAT" | grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0' +printf '%s\n' "$GUEST_STAT" | grep -Eq 'Mode:[[:space:]]+0755' +printf '%s\n' "$REPO_STAT" | grep -Eq 'User:[[:space:]]+1000[[:space:]]+Group:[[:space:]]+1000' +printf '%s\n' "$BUSYBOX_STAT" | grep -Eq 'Mode:[[:space:]]+0755' + +echo "rootless $ARCH image build and atomic publication checks passed (build-only; no KVM evidence)" diff --git a/scripts/validate-hardware-report.py b/scripts/validate-hardware-report.py new file mode 100644 index 0000000..ea9b33f --- /dev/null +++ b/scripts/validate-hardware-report.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +import hashlib +import json +import pathlib +import sys + + +def fail(message: str) -> None: + raise SystemExit(message) + + +if len(sys.argv) != 7: + fail( + "usage: validate-hardware-report.py " + " " + ) + +manifest_path = pathlib.Path(sys.argv[1]) +report_path = pathlib.Path(sys.argv[2]) +distro, commit, candidate, runner = sys.argv[3:] +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +report = json.loads(report_path.read_text(encoding="utf-8")) +manifest_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + +expected_fields = { + "schema": 1, + "distro": distro, + "commit": commit, + "candidate_sha256": candidate, + "acceptance_manifest_sha256": manifest_digest, + "runner": runner, +} +if set(report) != set(expected_fields) | {"results"}: + fail(f"hardware report has an unexpected top-level schema: {sorted(report)!r}") +for key, expected in expected_fields.items(): + if report.get(key) != expected: + fail(f"hardware report {key!r} is {report.get(key)!r}; expected {expected!r}") + +expected_tests = manifest.get("tests") +results = report.get("results") +if not isinstance(expected_tests, list) or not expected_tests: + fail("acceptance manifest has no tests") +if not isinstance(results, list): + fail("hardware report results must be a list") + +seen = {} +for result in results: + if not isinstance(result, dict) or set(result) - {"id", "status", "detail"}: + fail(f"malformed hardware result: {result!r}") + if "id" not in result or "status" not in result: + fail(f"incomplete hardware result: {result!r}") + test_id = result.get("id") + if test_id in seen: + fail(f"duplicate hardware result: {test_id!r}") + seen[test_id] = result.get("status") + +if set(seen) != set(expected_tests): + fail( + "hardware report test set differs from acceptance manifest: " + f"missing={sorted(set(expected_tests) - set(seen))}, " + f"extra={sorted(set(seen) - set(expected_tests))}" + ) +failed = sorted(test_id for test_id, status in seen.items() if status != "pass") +if failed: + fail(f"hardware acceptance did not pass: {failed}") + +print(manifest_digest) diff --git a/scripts/verify-kvm-attestation.py b/scripts/verify-kvm-attestation.py new file mode 100644 index 0000000..ae56b7f --- /dev/null +++ b/scripts/verify-kvm-attestation.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import hashlib +import json +import pathlib +import sys + + +def fail(message: str) -> None: + raise SystemExit(message) + + +if len(sys.argv) != 10: + fail( + "usage: verify-kvm-attestation.py " + " " + ) + +verification_path = pathlib.Path(sys.argv[1]) +predicate_path = pathlib.Path(sys.argv[2]) +report_path = pathlib.Path(sys.argv[3]) +candidate_path = pathlib.Path(sys.argv[4]) +distro = sys.argv[5] +commit = sys.argv[6] +manifest_path = pathlib.Path(sys.argv[7]) +expected_runner = sys.argv[8] +expected_harness = sys.argv[9] + +verification = json.loads(verification_path.read_text(encoding="utf-8")) +if not isinstance(verification, list) or len(verification) != 1: + fail("expected exactly one cryptographically verified KVM attestation") +statement = verification[0].get("verificationResult", {}).get("statement") +if not isinstance(statement, dict): + fail("gh verification output has no parsed in-toto statement") + +predicate = json.loads(predicate_path.read_text(encoding="utf-8")) +report = json.loads(report_path.read_text(encoding="utf-8")) +if statement.get("predicateType") != "https://github.com/AdminTurnedDevOps/ABox/attestations/kvm-test/v1": + fail("unexpected KVM attestation predicate type") +if statement.get("predicate") != predicate: + fail("downloaded predicate does not match the signed DSSE statement") + +candidate_digest = hashlib.sha256(candidate_path.read_bytes()).hexdigest() +subjects = statement.get("subject") +if not isinstance(subjects, list) or len(subjects) != 1: + fail("KVM attestation must have exactly one subject") +if subjects[0].get("digest", {}).get("sha256") != candidate_digest: + fail("KVM attestation subject is not the release candidate") + +manifest_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +baselines = { + "arch": "arch-2026-09-17-x86_64-libkrun-1.19.4-1-libkrunfw-5.5.0-1", + "fedora": "fedora-44-x86_64-libkrun-1.19.0-1.fc44-libkrunfw-5.5.0-1.fc44-selinux-enforcing", +} +if distro not in baselines: + fail(f"unsupported attested distro: {distro!r}") +expected = { + "schema": 1, + "distro": distro, + "commit": commit, + "candidate_sha256": candidate_digest, + "acceptance_manifest_sha256": manifest_digest, + "runner": expected_runner, + "baseline": baselines[distro], + "harness_sha256": expected_harness, +} +if set(predicate) != set(expected) | {"results"}: + fail(f"KVM predicate has an unexpected top-level schema: {sorted(predicate)!r}") +for key, value in expected.items(): + if predicate.get(key) != value: + fail(f"KVM predicate {key!r} is {predicate.get(key)!r}; expected {value!r}") +results = predicate.get("results") +expected_tests = manifest.get("tests") +if not isinstance(expected_tests, list) or not expected_tests: + fail("acceptance manifest has no tests") +if not isinstance(results, list): + fail("KVM predicate results must be a list") +seen = {} +for result in results: + if not isinstance(result, dict) or set(result) - {"id", "status", "detail"}: + fail(f"malformed KVM result: {result!r}") + if "id" not in result or "status" not in result: + fail(f"incomplete KVM result: {result!r}") + test_id = result.get("id") + if test_id in seen: + fail(f"duplicate KVM result: {test_id!r}") + seen[test_id] = result.get("status") +if set(seen) != set(expected_tests): + fail("KVM predicate test set differs from the acceptance manifest") +if any(status != "pass" for status in seen.values()): + fail("KVM predicate does not contain an all-pass result set") + +signed_report = dict(predicate) +signed_report.pop("baseline", None) +signed_report.pop("harness_sha256", None) +if report != signed_report: + fail("published hardware report does not match the signed predicate") diff --git a/scripts/verify-linux-release.sh b/scripts/verify-linux-release.sh new file mode 100644 index 0000000..42e1e19 --- /dev/null +++ b/scripts/verify-linux-release.sh @@ -0,0 +1,133 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +ARCHIVE=$1 +EXPECTED=$2 +LIBKRUN_VERSION=$3 + +ACTUAL=$(sha256sum "$ARCHIVE") +ACTUAL=${ACTUAL%% *} +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "candidate digest mismatch: got $ACTUAL, expected $EXPECTED" >&2 + exit 1 +fi + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/abox-release-verify.XXXXXX") +trap 'rm -rf "$WORK"' EXIT HUP INT TERM +PAYLOAD_NAME=$(python3 - "$ARCHIVE" <<'PY' +import pathlib +import re +import sys +import tarfile + +with tarfile.open(sys.argv[1], "r:gz") as archive: + members = archive.getmembers() + files = set() + names = set() + roots = set() + for member in members: + path = pathlib.PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise SystemExit(f"unsafe archive member: {member.name!r}") + if member.name in names: + raise SystemExit(f"duplicate archive member: {member.name!r}") + names.add(member.name) + if not (member.isdir() or member.isfile()): + raise SystemExit(f"unsupported archive member type: {member.name!r}") + if member.size > 1024 * 1024 * 1024: + raise SystemExit(f"archive member is too large: {member.name!r}") + roots.add(path.parts[0]) + if member.isfile(): + files.add(member.name) + +if len(roots) != 1: + raise SystemExit(f"candidate archive has unexpected roots: {sorted(roots)!r}") +root = roots.pop() +if not re.fullmatch(r"abox_v[0-9]+\.[0-9]+\.[0-9]+_linux_amd64", root): + raise SystemExit(f"candidate archive has unexpected root: {root!r}") +expected = { + f"{root}/SHA256SUMS", + f"{root}/INSTALL", + f"{root}/abox", + f"{root}/abox-vmm", + f"{root}/abox-guest-linux-amd64", + f"{root}/abox-guest-linux-amd64.raw.manifest.json", + f"{root}/abox-guest-linux-amd64.raw.zst", +} +if files != expected: + raise SystemExit( + f"candidate archive file set differs: missing={sorted(expected - files)!r}, " + f"extra={sorted(files - expected)!r}" + ) +directories = {member.name.rstrip("/") for member in members if member.isdir()} +if directories != {root}: + raise SystemExit(f"candidate archive has unexpected directories: {sorted(directories)!r}") +expected_modes = { + f"{root}/INSTALL": 0o644, + f"{root}/SHA256SUMS": 0o644, + f"{root}/abox": 0o755, + f"{root}/abox-vmm": 0o755, + f"{root}/abox-guest-linux-amd64": 0o755, + f"{root}/abox-guest-linux-amd64.raw.manifest.json": 0o444, + f"{root}/abox-guest-linux-amd64.raw.zst": 0o644, +} +for member in members: + name = member.name.rstrip("/") + expected_mode = 0o755 if member.isdir() else expected_modes.get(name) + if expected_mode is None or member.mode & 0o777 != expected_mode: + raise SystemExit(f"candidate archive mode is invalid: {member.name!r} {member.mode & 0o777:o}") +print(root) +PY +) +tar -xzf "$ARCHIVE" -C "$WORK" +PAYLOAD="$WORK/$PAYLOAD_NAME" + +( + cd "$PAYLOAD" + sha256sum -c SHA256SUMS +) + +for binary in abox abox-vmm abox-guest-linux-amd64; do + readelf -h "$PAYLOAD/$binary" | grep -Eq 'Machine:[[:space:]]+Advanced Micro Devices X86-64' +done +readelf -d "$PAYLOAD/abox-vmm" | grep -Eq 'NEEDED.*\[libkrun\.so\.1\]' + +(ulimit -f 2097152; zstd -q -d "$PAYLOAD/abox-guest-linux-amd64.raw.zst" -o "$WORK/guest.raw") +[ "$(stat -c %s "$WORK/guest.raw")" -eq 805306368 ] || { + echo "release guest image has an unexpected uncompressed size" >&2 + exit 1 +} +python3 - "$WORK/guest.raw" "$PAYLOAD/abox-guest-linux-amd64.raw.manifest.json" "$PAYLOAD_NAME" <<'PY' +import hashlib +import json +import pathlib +import sys + +image = pathlib.Path(sys.argv[1]) +manifest = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) +payload_name = sys.argv[3] +hasher = hashlib.sha256() +with image.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(chunk) +digest = hasher.hexdigest() +if manifest.get("schema") != 1 or manifest.get("arch") != "amd64": + raise SystemExit(f"invalid release image manifest: {manifest!r}") +if manifest.get("protocol") != 4 or manifest.get("sha256") != digest: + raise SystemExit("release image digest/protocol does not match its manifest") +expected_image_id = payload_name.removeprefix("abox_").removesuffix("_linux_amd64") +if manifest.get("image_id") != expected_image_id: + raise SystemExit("release image ID does not match the release tag") +PY +e2fsck -fn "$WORK/guest.raw" +debugfs -R "dump /usr/local/bin/abox-guest $WORK/image-guest" "$WORK/guest.raw" >/dev/null 2>&1 +cmp "$WORK/image-guest" "$PAYLOAD/abox-guest-linux-amd64" + +sh "$ROOT/scripts/check-libkrun.sh" "$LIBKRUN_VERSION" libkrunfw.so.5 +echo "verified exact Linux candidate $ACTUAL (packaging/link checks only; no isolation claim)" From 2611b75f9f30dee7cfaeb4b6bd6c306b49861cde Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Sun, 20 Sep 2026 08:40:24 -0400 Subject: [PATCH 4/5] test pass --- .../guest/tools/command_guest_linux_test.go | 14 +++++++++-- internal/guestimage/manifest_test.go | 10 +++++++- internal/repository/repository.go | 5 ++++ internal/repository/repository_test.go | 24 ++++++++++++++++--- internal/runtime/clone.go | 8 +++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/guest/tools/command_guest_linux_test.go b/internal/guest/tools/command_guest_linux_test.go index 6d80c07..3b66fd8 100644 --- a/internal/guest/tools/command_guest_linux_test.go +++ b/internal/guest/tools/command_guest_linux_test.go @@ -30,9 +30,19 @@ func TestGuestCommandDropsPrivilegesAndCreatesProcessGroup(t *testing.T) { } func TestGuestCommandBackgroundChildCannotHangWait(t *testing.T) { - repo := Repo{Root: t.TempDir()} + root, err := os.MkdirTemp("", "abox-guest-command-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + if os.Geteuid() == 0 { + if err := os.Chown(root, guestUID, guestGID); err != nil { + t.Fatal(err) + } + } + repo := Repo{Root: root} start := time.Now() - _, _, _, _, _, err := repo.RunContext(context.Background(), "sleep 30 &", filepath.Clean("."), 5*time.Second, 1024) + _, _, _, _, _, err = repo.RunContext(context.Background(), "sleep 30 &", filepath.Clean("."), 5*time.Second, 1024) if err == nil { t.Fatal("background child unexpectedly outlived command without an error") } diff --git a/internal/guestimage/manifest_test.go b/internal/guestimage/manifest_test.go index 34adc86..1042a4a 100644 --- a/internal/guestimage/manifest_test.go +++ b/internal/guestimage/manifest_test.go @@ -33,7 +33,15 @@ func TestLoadResolvesPointerAndValidatesManifest(t *testing.T) { if err != nil { t.Fatal(err) } - if got.Path != image || got.Manifest.SHA256 != digest { + expectedPath, err := filepath.EvalSymlinks(image) + if err != nil { + t.Fatal(err) + } + expectedPath, err = filepath.Abs(expectedPath) + if err != nil { + t.Fatal(err) + } + if got.Path != expectedPath || got.Manifest.SHA256 != digest { t.Fatalf("got %#v", got) } } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index ce1a92c..792f778 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -146,6 +146,11 @@ func exclusionsWithin(root string, paths []string) ([]string, error) { return nil, fmt.Errorf("resolve excluded path: %w", err) } absolute = filepath.Clean(absolute) + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + absolute = resolved + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("resolve excluded path symlinks: %w", err) + } if absolute == filepath.Clean(root) { return nil, fmt.Errorf("source directory %q is host-only ABox state; run abox from a Git worktree", root) } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 6a47a27..531ff36 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -33,6 +33,19 @@ func runGit(t *testing.T, dir string, args ...string) string { return strings.TrimSpace(string(out)) } +func sameFile(t *testing.T, left, right string) bool { + t.Helper() + leftInfo, err := os.Stat(left) + if err != nil { + t.Fatal(err) + } + rightInfo, err := os.Stat(right) + if err != nil { + t.Fatal(err) + } + return os.SameFile(leftInfo, rightInfo) +} + func TestOpenForSessionDiscoversCleanGitRoot(t *testing.T) { root := t.TempDir() runGit(t, root, "init", "-b", "main") @@ -49,7 +62,7 @@ func TestOpenForSessionDiscoversCleanGitRoot(t *testing.T) { if err != nil { t.Fatal(err) } - if snap.Ephemeral || snap.Root != root || snap.HostSource != root || snap.HEAD == "" { + if snap.Ephemeral || !sameFile(t, snap.Root, root) || !sameFile(t, snap.HostSource, root) || snap.HEAD == "" { t.Fatalf("snapshot=%+v", snap) } archive, err := ArchiveHEAD(snap.Root) @@ -103,7 +116,7 @@ func TestOpenForSessionSnapshotsDirtyWorktree(t *testing.T) { if err != nil { t.Fatal(err) } - if !snap.Ephemeral || snap.HostSource != root || snap.Root == root { + if !snap.Ephemeral || !sameFile(t, snap.HostSource, root) || sameFile(t, snap.Root, root) { t.Fatalf("snapshot=%+v", snap) } archive, err := ArchiveHEAD(snap.Root) @@ -146,8 +159,13 @@ func TestOpenForSessionExcludesHostState(t *testing.T) { if err := os.WriteFile(filepath.Join(state, "credentials.env"), []byte("SECRET=value"), 0o600); err != nil { t.Fatal(err) } + alias := filepath.Join(t.TempDir(), "repo-alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + stateAlias := filepath.Join(alias, ".abox") - snap, err := OpenForSessionExcluding(root, filepath.Join(state, "sessions", "test", "host-tree"), state) + snap, err := OpenForSessionExcluding(alias, filepath.Join(state, "sessions", "test", "host-tree"), stateAlias) if err != nil { t.Fatal(err) } diff --git a/internal/runtime/clone.go b/internal/runtime/clone.go index aca6385..0a4c8c9 100644 --- a/internal/runtime/clone.go +++ b/internal/runtime/clone.go @@ -2,12 +2,20 @@ package runtime import ( "errors" + "fmt" "io" "os" ) func cloneFile(src, dst string) (retErr error) { _ = os.Remove(dst) + info, err := os.Stat(src) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("clone source is not a regular file: %s", src) + } if err := platformCloneFile(src, dst); err == nil { if err := os.Chmod(dst, 0o600); err != nil { _ = os.Remove(dst) From baffb9545bd97355622737fb14f71cfd5df2f012 Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Sun, 20 Sep 2026 08:44:27 -0400 Subject: [PATCH 5/5] test pass --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33c038a..0c3d956 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: cache: true - run: make test - run: CGO_ENABLED=0 go test ./cmd/... - - run: go vet ./... + - run: CGO_ENABLED=0 go vet ./... - run: go build -o /tmp/abox ./cmd/abox - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags abox_guest -o /tmp/abox-guest ./cmd/abox-guest @@ -88,6 +88,8 @@ jobs: go-version-file: go.mod cache: true - name: Run real pkg-config/cgo compatibility checks + env: + GOFLAGS: -buildvcs=false run: | set -euo pipefail sh scripts/check-libkrun.sh 1.19.4 libkrunfw.so.5 @@ -129,6 +131,8 @@ jobs: go-version-file: go.mod cache: true - name: Run real pkg-config/cgo compatibility checks + env: + GOFLAGS: -buildvcs=false run: | set -euo pipefail sh scripts/check-libkrun.sh 1.19.0 libkrunfw.so.5