plan: claim Plan 3 rev 2, no ownership changes, descriptor-relative I/O
Answers the review's two P1s: the library never chowns or chmods by path; tmpfiles provisions setgid carlos directories; every open, create, rename and fsync goes through a pinned O_NOFOLLOW directory descriptor.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+460
−223
docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md+460 −223
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md| index 90c016636..9bc3e081b 100644 |
| --- a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md |
| +++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-3-box-guard.md |
| @@ -1,5 +1,7 @@ |
| # Host Lifecycle Claim, Plan 3: box-local guard and stop evidence |
| +Rev 2 answers the independent review's two P1s (symlink-following ownership changes). |
| + |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| **Goal:** provide the box-local libraries that fence host writes by installation and reconstruct stop evidence from the cgroup and open database descriptors. |
| @@ -8,7 +10,7 @@ |
| - `internal/hostguard` owns the local record and an exclusive, context-aware flock. A writer captures its installation when selecting work, then rechecks the fresh claim and local record under the lock before writing. |
| - `internal/cgroup.OpenCleanup` reopens an existing host cgroup without creating it or granting spawn authority. `internal/stopevidence` combines the activator's fence proof with fresh cgroup and `/proc` observations and explicit replicator closure. |
| -- This branch contains libraries, tests and one tmpfiles line only. No agent, adopt, config-sync, activator spawn or timer path calls the new code. Plan 6 owns those call sites, lifecycle use of `PrepareStrict`, and the process/privilege boundary at which evidence is collected. |
| +- This branch contains libraries, tests and two tmpfiles lines only (R9 replaces R7). No agent, adopt, config-sync, activator spawn or timer path calls the new code. Plan 6 owns those call sites, lifecycle use of `PrepareStrict`, and the process/privilege boundary at which evidence is collected. |
| - Plan 4 composes installation retirement with artifact parking. Inventory paths are already tagged by installation stamp; this plan neither changes that inventory nor parks artifacts. |
| **Tech Stack:** Go 1.26; standard library; existing `golang.org/x/sys/unix`; `internal/hostclaim`, `internal/release`, `internal/controlname`, `internal/cgroup`, `internal/activator`. No new dependencies. |
| @@ -20,20 +22,21 @@ |
| ## Global Constraints |
| - **C4:** “Admission reads the claim directly, never from a listing snapshot.” Every guarded operation reads under the flock. `With` requires the caller's operation/phase and held-config/held-release check; the captured stamp comes from work selection, never a replacement capture inside `With`. |
| -- **C6:** “A failed or malformed read of a claim, record or listing means wait, or refuse. It never means absent.” Only filesystem not-found becomes `ErrNoRecord`; only `Observed.Absent` means no claim. Decode unknown fields and trailing data as errors. |
| +- **C6:** “A failed or malformed read of a claim, record or listing means wait, or refuse. It never means absent.” Only a missing record within a provisioned directory becomes `ErrNoRecord`; a missing directory is `ErrNotProvisioned`; only `Observed.Absent` means no claim. Decode unknown fields and trailing data as errors. |
| - **Paths and schema:** `/run/carlos/hostguard/<host>.lock` by default, and `<DataDir>/hostguard/<host>.json`. Local record `v` is **1**; the claim library's schema remains **2**. Host names pass `controlname.ValidHostShape`. |
| - **Installation identity:** `{incarnation, active_stamp, retired_stamps}`; `active_stamp == 0` means none. Retain the newest **32** retired stamps, oldest to newest. The active nonzero stamp never appears among retired stamps. `holder.stamp` is the installing CAS's resulting `gen`, not today's claim `Gen`, `holder.session` or `holder.generation` (the last is a string). |
| - **Only activation and installation retirement change stamps.** An equal active stamp is an activation no-op. A retired or older stamp cannot reactivate. A different incarnation replaces the record only on authorized activation. `RetireInstallation` changes stamps only; Plan 4 parks all artifacts in the same higher-level operation. `retire-artifacts(S)` belongs wholly to Plan 4 and never changes stamps. |
| - **Record durability:** same-directory temporary file, file fsync, rename, directory fsync. Captures use an unlocked read of the atomically replaced file. Failed rename removes the temporary file and preserves the previous record. An error after rename may mean the complete new record is visible; report that error and allow an idempotent retry. |
| -- **Lock:** exclusive `LOCK_EX|LOCK_NB`, polled every **10 ms** with cancellation. Open `O_RDWR|O_CREATE`, **0660**. Never unlink lock files: waiters must keep referring to the same inode. Tmpfiles creates the directory with **0770 carlos carlos**; do not use `carlos-sockets`. Root-created files must also be usable by `carlos`. |
| +- **Lock:** exclusive `LOCK_EX|LOCK_NB`, polled every **10 ms** with cancellation. Open descriptor-relative with `O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC`, **0660**, and require a regular file before changing its mode through `Fchmod`. Never unlink lock files: waiters must keep referring to the same inode. |
| +- **R9 filesystem boundary:** the library never calls `Chown`, `Lchown`, `Chmod` or any pathname-based metadata change, and never creates directories. Tmpfiles provisions `/run/carlos/hostguard` and `/data/hostguard` with **2770 carlos carlos**, using `serviceUser` for both owner and group. Setgid supplies group `carlos` to root-created files. Open each directory once with `O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC`, then `Fstat` and require a directory. A missing directory is distinctly “not provisioned.” Use that pinned descriptor for `Openat` reads and file creation, `Renameat`, `Unlinkat` temporary cleanup and directory `Fsync`; file mode and durability use `Fchmod(fd, 0660)` and `Fsync(fd)`. Temporary files use `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC`. A guarded operation holds the same record-directory descriptor across its read and write. Non-default directories must be provisioned equivalently by the operator. |
| - **Stop evidence (§6.1):** closed admission fence, drained spawn attempts, `populated 0`, no member processes, closed replicators, no DB/WAL/SHM descriptor holders. Missing cgroup, missing delegation, unreadable state or incomplete `/proc` visibility produces no evidence. An absent cgroup cannot establish that a legacy child did not escape containment. |
| - **Platform boundary:** flock on Unix, refusing stub on `!unix` (including Windows); cleanup and `/proc` scanning on Linux, refusing stubs on `!linux`. Darwin and Windows must still build the whole repository. |
| - **Gate (AGENTS.md):** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`, plus Darwin and Windows builds. Use the default Go cache. If it is read-only, use `GOCACHE=$TMPDIR/gocache` only with an on-disk `$TMPDIR`, never a path under `/tmp`. |
| ## Review Focus |
| -1. A callback panics or returns an error: the flock must still be released (Tasks 2–3). |
| -2. The claim changes after capture, including while waiting for the lock: admission must read the new claim (Tasks 2–3). |
| +1. A lock, record or temporary-file symlink, or a directory replaced after open, must never redirect metadata changes or writes. Pin directories, refuse symlinks and non-regular locks, and keep reads and writes descriptor-relative (R9; Tasks 1–3). |
| +2. The claim changes after capture, including while waiting for the lock: admission must read the new claim. Errors and panics must release the flock and directory descriptors (Tasks 2–3). |
| 3. UK→US→UK, or a stamp trimmed from the retired list: a stale writer must never become current again (Tasks 2–3). |
| 4. `/proc/<pid>/fd` returns EACCES for a live process: return `ErrIncomplete`, never an empty holder list (Task 5). |
| 5. Capture races an atomic rename, or rename fails: readers see a complete old or new record, and failed writes leave no partial file (Task 1). |
| @@ -45,9 +48,9 @@ |
| | File | Responsibility | |
| |---|---| |
| | `internal/hostguard/record.go`, `record_test.go` | Strict local schema, capture, atomic writes and failure seam | |
| -| `internal/hostguard/owner_unix.go`, `owner_other.go` | Preserve service ownership on root-created records and locks | |
| +| `internal/hostguard/record_unix.go`, `record_other.go` | Pinned directories, descriptor-relative record I/O, refusing non-Unix stubs | |
| | `internal/hostguard/guard.go`, `guard_test.go` | Fresh claim adapter, admission, activation and retirement | |
| -| `internal/hostguard/flock_unix.go`, `flock_other.go` | Context-aware flock and shared-file ownership; refusing non-Unix stub | |
| +| `internal/hostguard/flock_unix.go`, `flock_other.go` | Context-aware descriptor-relative flock; refusing non-Unix stub | |
| | `internal/hostguard/flock_unix_test.go`, `flock_other_test.go` | Cross-process exclusion, cancellation, fresh read and platform refusal | |
| | `internal/cgroup/cleanup.go` | Cleanup errors | |
| | `internal/cgroup/cleanup_linux.go`, `cleanup_other.go` | Reopen-only cgroup handle; no `Start` method | |
| @@ -55,19 +58,19 @@ |
| | `internal/stopevidence/fds.go`, `fds_linux.go`, `fds_other.go` | Descriptor holder scan and errors | |
| | `internal/stopevidence/fds_linux_test.go`, `fds_other_test.go` | Fake proc tree, injected read failures and real descriptor witness | |
| | `internal/stopevidence/evidence.go`, `evidence_linux_test.go` | Composite evidence and cleanup-only entry point | |
| -| `internal/host/bootstrap.go` | One tmpfiles line | |
| -| `internal/host/units_isolation_test.go` | Add the guard directory to the existing tmpfiles contract | |
| +| `internal/host/bootstrap.go` | Two setgid tmpfiles directory entries | |
| +| `internal/host/units_isolation_test.go` | Add both guard directories to the existing tmpfiles contract | |
| --- |
| ### Task 1: The local record and capture |
| **Files:** |
| -- Create: `internal/hostguard/record.go`, `internal/hostguard/record_test.go`, `internal/hostguard/owner_unix.go`, `internal/hostguard/owner_other.go` |
| +- Create: `internal/hostguard/record.go`, `internal/hostguard/record_test.go`, `internal/hostguard/record_unix.go`, `internal/hostguard/record_other.go` |
| **Interfaces:** |
| - Produces: `Captured`, `Capture(dataDir, host)`, strict internal `record`, record errors. |
| -- Consumes: `controlname.ValidHostShape`; produces the platform ownership helper needed by Task 2. |
| +- Consumes: `controlname.ValidHostShape`; produces the pinned directory helper needed by Task 2. |
| - [ ] **Step 1: Create the implementation branch** |
| @@ -80,21 +83,34 @@ Expected: the new branch is based on the claim library. Do this in the implement |
| - [ ] **Step 2: Write `record_test.go`** |
| ```go |
| +//go:build unix |
| + |
| package hostguard |
| import ( |
| "errors" |
| "fmt" |
| + "io" |
| "os" |
| "path/filepath" |
| - "runtime" |
| "strings" |
| "sync" |
| "testing" |
| + |
| + "golang.org/x/sys/unix" |
| ) |
| -func TestCaptureStrict(t *testing.T) { |
| +func recordFixture(t *testing.T) string { |
| + t.Helper() |
| dir := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + return dir |
| +} |
| + |
| +func TestCaptureStrict(t *testing.T) { |
| + dir := recordFixture(t) |
| host := "shop.example" |
| if _, err := Capture(dir, host); !errors.Is(err, ErrNoRecord) { |
| t.Fatal(err) |
| @@ -102,9 +118,6 @@ func TestCaptureStrict(t *testing.T) { |
| if _, err := Capture(dir, "../outside"); !errors.Is(err, ErrInvalidHost) { |
| t.Fatal(err) |
| } |
| - if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil { |
| - t.Fatal(err) |
| - } |
| good := `{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":3,"retired_stamps":[1,2]}` |
| for _, tc := range []struct { |
| name, body string |
| @@ -142,21 +155,30 @@ func TestCaptureStrict(t *testing.T) { |
| } |
| func TestRecordAtomicRename(t *testing.T) { |
| - if runtime.GOOS == "windows" { |
| - t.Skip("directory fsync and owner helper are Unix") |
| - } |
| - dir, host := t.TempDir(), "shop.example" |
| + dir, host := recordFixture(t), "shop.example" |
| r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 1, RetiredStamps: []uint64{}} |
| if err := writeRecord(dir, r); err != nil { |
| t.Fatal(err) |
| } |
| want := errors.New("rename failed") |
| r.ActiveStamp = 2 |
| - err := writeRecordUsing(dir, r, func(src, dst string) error { |
| - if filepath.Dir(src) != filepath.Dir(dst) { |
| - t.Fatal("cross-directory temporary file") |
| + d, err := openRecordDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + err = d.writeUsing(r, ".rename-test", func(srcFD int, src string, dstFD int, dst string) error { |
| + if srcFD != d.fd || dstFD != d.fd || src != ".rename-test" || dst != host+".json" { |
| + t.Fatal("rename escaped pinned directory") |
| + } |
| + fd, e := unix.Openat(srcFD, src, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + t.Fatal(e) |
| } |
| - if b, e := os.ReadFile(src); e != nil || !strings.Contains(string(b), `"active_stamp":2`) { |
| + f := os.NewFile(uintptr(fd), src) |
| + b, e := io.ReadAll(f) |
| + f.Close() |
| + if e != nil || !strings.Contains(string(b), `"active_stamp":2`) { |
| t.Fatalf("temporary file: %s %v", b, e) |
| } |
| return want |
| @@ -228,6 +250,95 @@ func TestRecordRejectsOversizedHistoryAndReadError(t *testing.T) { |
| t.Fatalf("directory became absence: %v", err) |
| } |
| } |
| + |
| +func unchangedFile(t *testing.T, path string) func() { |
| + t.Helper() |
| + before, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return func() { |
| + t.Helper() |
| + after, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil || string(got) != string(body) || before.Mode() != after.Mode() || !os.SameFile(before, after) { |
| + t.Fatalf("target changed: before=%v after=%v body=%q err=%v", before, after, got, err) |
| + } |
| + } |
| +} |
| + |
| +func TestRecordNotProvisioned(t *testing.T) { |
| + dir := t.TempDir() |
| + r := record{V: 1, Host: "shop.example", Incarnation: "inc", ActiveStamp: 1} |
| + if _, err := Capture(dir, r.Host); !errors.Is(err, ErrNotProvisioned) || errors.Is(err, ErrNoRecord) { |
| + t.Fatal(err) |
| + } |
| + if err := writeRecord(dir, r); !errors.Is(err, ErrNotProvisioned) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Lstat(filepath.Join(dir, "hostguard")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("library created directory: %v", err) |
| + } |
| +} |
| + |
| +func TestRecordSymlinksRefused(t *testing.T) { |
| + for _, kind := range []string{"directory", "record", "temp"} { |
| + t.Run(kind, func(t *testing.T) { |
| + dir := recordFixture(t) |
| + host := "shop.example" |
| + r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 2} |
| + outside := t.TempDir() |
| + target := filepath.Join(outside, host+".json") |
| + if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unchangedFile(t, target)() |
| + guardDir := filepath.Join(dir, "hostguard") |
| + link, destination := filepath.Join(guardDir, host+".json"), target |
| + if kind == "directory" { |
| + if err := os.Remove(guardDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + link, destination = guardDir, outside |
| + } else if kind == "temp" { |
| + link = filepath.Join(guardDir, ".fixed-temp") |
| + } |
| + if err := os.Symlink(destination, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + if kind == "temp" { |
| + d, err := openRecordDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if err := d.writeUsing(r, ".fixed-temp", unix.Renameat); err == nil { |
| + t.Fatal("accepted temporary symlink") |
| + } |
| + if _, err := d.read(host); !errors.Is(err, ErrNoRecord) { |
| + t.Fatal("published record despite refused temporary file", err) |
| + } |
| + } else { |
| + if _, err := Capture(dir, host); err == nil || errors.Is(err, ErrNoRecord) { |
| + t.Fatal("capture followed symlink", err) |
| + } |
| + if err := writeRecord(dir, r); err == nil { |
| + t.Fatal("write accepted symlink") |
| + } |
| + } |
| + if got, err := os.Readlink(link); err != nil || got != destination { |
| + t.Fatalf("symlink removed or replaced: %q %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| ``` |
| - [ ] **Step 3: Run the failing tests** |
| @@ -247,14 +358,14 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| - "os" |
| - "path/filepath" |
| "github.com/carlosframework/platform/internal/controlname" |
| ) |
| var ( |
| - ErrUnsupported = errors.New("hostguard: locking unsupported") |
| + ErrUnsupported = errors.New("hostguard: unsupported platform") |
| + ErrNotProvisioned = errors.New("hostguard: directory not provisioned") |
| + ErrUnsafeFile = errors.New("hostguard: expected regular file") |
| ErrInvalidHost = errors.New("hostguard: invalid host") |
| ErrNoRecord = errors.New("hostguard: no local record") |
| ErrMalformedRecord = errors.New("hostguard: malformed local record") |
| @@ -291,13 +402,15 @@ func readRecord(dataDir, host string) (record, error) { |
| if !controlname.ValidHostShape(host) { |
| return record{}, ErrInvalidHost |
| } |
| - b, err := os.ReadFile(filepath.Join(dataDir, "hostguard", host+".json")) |
| - if errors.Is(err, os.ErrNotExist) { |
| - return record{}, ErrNoRecord |
| - } |
| + d, err := openRecordDir(dataDir) |
| if err != nil { |
| return record{}, err |
| } |
| + defer d.close() |
| + return d.read(host) |
| +} |
| + |
| +func decodeRecord(b []byte, host string) (record, error) { |
| d := json.NewDecoder(bytes.NewReader(b)) |
| d.DisallowUnknownFields() |
| var r record |
| @@ -321,114 +434,170 @@ func Capture(dataDir, host string) (Captured, error) { |
| return Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}, nil |
| } |
| -func writeRecord(dataDir string, r record) error { return writeRecordUsing(dataDir, r, os.Rename) } |
| - |
| -func writeRecordUsing(dataDir string, r record, rename func(string, string) error) error { |
| +func writeRecord(dataDir string, r record) error { |
| if err := r.validate(r.Host); err != nil { |
| return err |
| } |
| - b, err := json.Marshal(r) |
| + d, err := openRecordDir(dataDir) |
| if err != nil { |
| return err |
| } |
| - dir := filepath.Join(dataDir, "hostguard") |
| - if err := os.MkdirAll(dir, 0770); err != nil { |
| - return err |
| + defer d.close() |
| + return d.write(r) |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Write the descriptor-relative filesystem helpers** |
| + |
| +`record_unix.go`: |
| + |
| +```go |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type recordDir struct{ fd int } |
| + |
| +func openDirectory(path string) (int, error) { |
| + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if errors.Is(err, unix.ENOENT) { |
| + return -1, fmt.Errorf("%w: %s", ErrNotProvisioned, path) |
| } |
| - d, err := os.Open(dir) |
| if err != nil { |
| - return err |
| + return -1, err |
| } |
| - defer d.Close() |
| - if err := setFileOwner(d, dataDir); err != nil { |
| - return err |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + unix.Close(fd) |
| + return -1, err |
| } |
| - if err := d.Chmod(0770); err != nil { |
| - return err |
| + if st.Mode&unix.S_IFMT != unix.S_IFDIR { |
| + unix.Close(fd) |
| + return -1, fmt.Errorf("hostguard: not a directory: %s", path) |
| } |
| - // Make first creation durable in the data directory as well. |
| - parent, err := os.Open(dataDir) |
| + return fd, nil |
| +} |
| + |
| +func openRecordDir(dataDir string) (*recordDir, error) { |
| + fd, err := openDirectory(filepath.Join(dataDir, "hostguard")) |
| if err != nil { |
| - return err |
| + return nil, err |
| + } |
| + return &recordDir{fd: fd}, nil |
| +} |
| + |
| +func (d *recordDir) close() { _ = unix.Close(d.fd) } |
| + |
| +func (d *recordDir) read(host string) (record, error) { |
| + if !controlname.ValidHostShape(host) { |
| + return record{}, ErrInvalidHost |
| + } |
| + // NONBLOCK prevents a planted FIFO from hanging before Fstat can refuse it. |
| + fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| + if errors.Is(err, unix.ENOENT) { |
| + return record{}, ErrNoRecord |
| } |
| - err = parent.Sync() |
| - err = errors.Join(err, parent.Close()) |
| if err != nil { |
| - return err |
| + return record{}, err |
| } |
| - f, err := os.CreateTemp(dir, "."+r.Host+"-*") |
| + f := os.NewFile(uintptr(fd), host+".json") |
| + defer f.Close() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return record{}, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return record{}, ErrUnsafeFile |
| + } |
| + b, err := io.ReadAll(f) |
| if err != nil { |
| + return record{}, err |
| + } |
| + return decodeRecord(b, host) |
| +} |
| + |
| +func (d *recordDir) write(r record) error { |
| + var nonce [16]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| return err |
| } |
| - tmp := f.Name() |
| - defer os.Remove(tmp) |
| - defer f.Close() |
| - if err := setFileOwner(f, dir); err != nil { |
| + return d.writeUsing(r, "."+r.Host+"-"+hex.EncodeToString(nonce[:]), unix.Renameat) |
| +} |
| + |
| +func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, int, string) error) error { |
| + if err := r.validate(r.Host); err != nil { |
| return err |
| } |
| - if err := f.Chmod(0660); err != nil { |
| + if tmp == "" || tmp == "." || tmp == ".." || filepath.Base(tmp) != tmp || tmp == r.Host+".json" { |
| + return ErrUnsafeFile |
| + } |
| + // Refuse an existing symlink or unreadable record. Renameat itself never |
| + // follows the destination, including a symlink planted after this check. |
| + if _, err := d.read(r.Host); err != nil && !errors.Is(err, ErrNoRecord) { |
| return err |
| } |
| - if _, err := f.Write(append(b, '\n')); err != nil { |
| + b, err := json.Marshal(r) |
| + if err != nil { |
| return err |
| } |
| - if err := f.Sync(); err != nil { |
| + fd, err := unix.Openat(d.fd, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660) |
| + if err != nil { |
| return err |
| } |
| - if err := f.Close(); err != nil { |
| + // Only remove a name this call successfully created. |
| + defer unix.Unlinkat(d.fd, tmp, 0) |
| + f := os.NewFile(uintptr(fd), tmp) |
| + defer f.Close() |
| + if err := unix.Fchmod(fd, 0660); err != nil { |
| return err |
| } |
| - if err := rename(tmp, filepath.Join(dir, r.Host+".json")); err != nil { |
| + if _, err := f.Write(append(b, '\n')); err != nil { |
| return err |
| } |
| - return d.Sync() |
| -} |
| -``` |
| - |
| -- [ ] **Step 5: Write the ownership helpers** |
| - |
| -`owner_unix.go`: |
| - |
| -```go |
| -//go:build unix |
| - |
| -package hostguard |
| - |
| -import ( |
| - "os" |
| - |
| - "golang.org/x/sys/unix" |
| -) |
| - |
| -func setFileOwner(f *os.File, parent string) error { |
| - if os.Geteuid() != 0 { |
| - return nil |
| + if err := unix.Fsync(fd); err != nil { |
| + return err |
| } |
| - d, err := os.Open(parent) |
| - if err != nil { |
| + if err := f.Close(); err != nil { |
| return err |
| } |
| - defer d.Close() |
| - var st unix.Stat_t |
| - if err := unix.Fstat(int(d.Fd()), &st); err != nil { |
| + if err := rename(d.fd, tmp, d.fd, r.Host+".json"); err != nil { |
| return err |
| } |
| - return f.Chown(int(st.Uid), int(st.Gid)) |
| + return unix.Fsync(d.fd) |
| } |
| ``` |
| -`owner_other.go`: |
| +`record_other.go`: |
| ```go |
| //go:build !unix |
| package hostguard |
| -import "os" |
| +type recordDir struct{} |
| -func setFileOwner(*os.File, string) error { return ErrUnsupported } |
| +func openRecordDir(string) (*recordDir, error) { return nil, ErrUnsupported } |
| +func (*recordDir) close() {} |
| +func (*recordDir) read(string) (record, error) { return record{}, ErrUnsupported } |
| +func (*recordDir) write(record) error { return ErrUnsupported } |
| ``` |
| +The operator owns the trusted parent paths. The library pins each guard directory once per operation and never creates or repairs it. A directory symlink is refused; replacing a directory after open cannot redirect the pinned descriptor. |
| + |
| - [ ] **Step 6: Run the green record tests** |
| Run: `gofmt -w internal/hostguard && go test ./internal/hostguard -run 'TestCaptureStrict|TestRecord' -count=1` |
| @@ -487,7 +656,7 @@ func newGuardFixture(t *testing.T) *guardFixture { |
| Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 1}, |
| Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "uk"}}, |
| }} |
| - f.g = Guard{DataDir: t.TempDir(), LockDir: t.TempDir(), Claims: readerFunc(func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + f.g = Guard{DataDir: recordFixture(t), LockDir: t.TempDir(), Claims: readerFunc(func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| f.reads++ |
| return f.obs, f.err |
| })} |
| @@ -804,7 +973,7 @@ Expected: build failure naming the missing guard and platform helpers. |
| - [ ] **Step 3: Write `flock_unix.go`** |
| -The runtime directory already exists from tmpfiles. The data directory is an operator-owned directory whose owner/group is the service account; new record directories inherit that ownership. A privileged writer explicitly preserves the parent owner/group because neither root's umask nor the non-setgid runtime directory guarantees shared access. Do not chown to the socket-consumer group. |
| +Tmpfiles provisions both directories. Root-created files inherit group `carlos` through setgid. New lock files get `Fchmod(fd, 0660)` after `Fstat` validates their type. An existing lock already at 0660 needs no mode change: a service process can open a root-owned file through the group without needing ownership. If an existing mode needs repair, use `Fchmod` and propagate failure. Never change ownership. |
| ```go |
| //go:build unix |
| @@ -815,9 +984,9 @@ import ( |
| "context" |
| "errors" |
| "os" |
| - "path/filepath" |
| "time" |
| + "github.com/carlosframework/platform/internal/controlname" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -828,16 +997,36 @@ func acquire(ctx context.Context, dir, host string) (func(), error) { |
| if err := ctx.Err(); err != nil { |
| return nil, err |
| } |
| - f, err := os.OpenFile(filepath.Join(dir, host+".lock"), os.O_RDWR|os.O_CREATE, 0660) |
| + if !controlname.ValidHostShape(host) { |
| + return nil, ErrInvalidHost |
| + } |
| + dirFD, err := openDirectory(dir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer unix.Close(dirFD) |
| + flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK |
| + fd, err := unix.Openat(dirFD, host+".lock", flags|unix.O_EXCL, 0660) |
| + created := err == nil |
| + if errors.Is(err, unix.EEXIST) { |
| + fd, err = unix.Openat(dirFD, host+".lock", flags, 0660) |
| + } |
| if err != nil { |
| return nil, err |
| } |
| + f := os.NewFile(uintptr(fd), host+".lock") |
| fail := func(err error) (func(), error) { f.Close(); return nil, err } |
| - if err := setFileOwner(f, dir); err != nil { |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| return fail(err) |
| } |
| - if err := f.Chmod(0660); err != nil { |
| - return fail(err) |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG { |
| + return fail(ErrUnsafeFile) |
| + } |
| + if created || st.Mode&07777 != 0660 { |
| + if err := unix.Fchmod(fd, 0660); err != nil { |
| + return fail(err) |
| + } |
| } |
| tick := time.NewTicker(10 * time.Millisecond) |
| defer tick.Stop() |
| @@ -927,7 +1116,7 @@ type Guard struct { |
| Claims ClaimReader |
| } |
| -func (g Guard) locked(ctx context.Context, host string, fn func() error) error { |
| +func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) error) error { |
| if !controlname.ValidHostShape(host) { |
| return ErrInvalidHost |
| } |
| @@ -943,7 +1132,12 @@ func (g Guard) locked(ctx context.Context, host string, fn func() error) error { |
| return err |
| } |
| defer unlock() |
| - return fn() |
| + d, err := openRecordDir(g.DataDir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.close() |
| + return fn(d) |
| } |
| func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) { |
| @@ -979,7 +1173,7 @@ func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) |
| } |
| func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error { |
| - return g.locked(ctx, host, func() error { |
| + return g.locked(ctx, host, func(d *recordDir) error { |
| c, err := g.fresh(ctx, host) |
| if err != nil { |
| return err |
| @@ -993,7 +1187,7 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| if err := check(c); err != nil { |
| return err |
| } |
| - r, err := readRecord(g.DataDir, host) |
| + r, err := d.read(host) |
| if err != nil { |
| return err |
| } |
| @@ -1028,7 +1222,7 @@ func (r *record) retire() { |
| } |
| func (g Guard) Activate(ctx context.Context, host, box string, participant func(*hostclaim.Claim) bool) error { |
| - return g.locked(ctx, host, func() error { |
| + return g.locked(ctx, host, func(d *recordDir) error { |
| c, err := g.fresh(ctx, host) |
| if err != nil { |
| return err |
| @@ -1040,7 +1234,7 @@ func (g Guard) Activate(ctx context.Context, host, box string, participant func( |
| if stamp == 0 { |
| return ErrMalformedClaim |
| } |
| - r, err := readRecord(g.DataDir, host) |
| + r, err := d.read(host) |
| if err != nil && !errors.Is(err, ErrNoRecord) { |
| return err |
| } |
| @@ -1068,12 +1262,12 @@ func (g Guard) Activate(ctx context.Context, host, box string, participant func( |
| if err := ctx.Err(); err != nil { |
| return err |
| } |
| - return writeRecord(g.DataDir, r) |
| + return d.write(r) |
| }) |
| } |
| func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*hostclaim.Claim) error) error { |
| - return g.locked(ctx, host, func() error { |
| + return g.locked(ctx, host, func(d *recordDir) error { |
| c, err := g.fresh(ctx, host) |
| if err != nil { |
| return err |
| @@ -1084,7 +1278,7 @@ func (g Guard) RetireInstallation(ctx context.Context, host string, check func(* |
| if err := check(c); err != nil { |
| return err |
| } |
| - r, err := readRecord(g.DataDir, host) |
| + r, err := d.read(host) |
| if err != nil { |
| return err |
| } |
| @@ -1098,7 +1292,7 @@ func (g Guard) RetireInstallation(ctx context.Context, host string, check func(* |
| if err := ctx.Err(); err != nil { |
| return err |
| } |
| - return writeRecord(g.DataDir, r) |
| + return d.write(r) |
| }) |
| } |
| ``` |
| @@ -1138,9 +1332,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| - "runtime" |
| "sync/atomic" |
| - "syscall" |
| "testing" |
| "time" |
| @@ -1310,125 +1502,160 @@ func TestGuardReleasesAfterErrorAndPanic(t *testing.T) { |
| } |
| } |
| -func TestGuardForeignOwnerProcess(t *testing.T) { |
| - if base := os.Getenv("CARLOS_HOSTGUARD_FOREIGN_OWNER"); base != "" { |
| - f := newGuardFixture(t) |
| - f.g.DataDir, f.g.LockDir = filepath.Join(base, "data"), filepath.Join(base, "locks") |
| - captured, err := Capture(f.g.DataDir, f.obs.Host) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := f.g.With(t.Context(), f.obs.Host, captured, allow, func() error { return nil }); err != nil { |
| - t.Fatal(err) |
| - } |
| - f.obs.Claim.Holder.Stamp = 2 |
| - if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| - t.Fatal(err) |
| - } |
| - return |
| - } |
| - if runtime.GOOS != "linux" || os.Geteuid() != 0 { |
| - t.Skip("cross-UID descriptor-based witness requires Linux root") |
| - } |
| - base := t.TempDir() |
| - if err := os.Chmod(base, 0755); err != nil { |
| - t.Fatal(err) |
| - } |
| - const uid, gid = 65534, 65534 |
| +func TestGuardSharedFileModes(t *testing.T) { |
| f := newGuardFixture(t) |
| - f.g.DataDir, f.g.LockDir = filepath.Join(base, "data"), filepath.Join(base, "locks") |
| - for _, dir := range []string{f.g.DataDir, f.g.LockDir} { |
| - if err := os.Mkdir(dir, 0770); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.Chown(dir, uid, gid); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.Chmod(dir, 0770); err != nil { |
| - t.Fatal(err) |
| - } |
| - } |
| if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| t.Fatal(err) |
| } |
| - for _, path := range []string{filepath.Join(f.g.LockDir, f.obs.Host+".lock"), filepath.Join(f.g.DataDir, "hostguard"), filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json")} { |
| - var st unix.Stat_t |
| - if err := unix.Stat(path, &st); err != nil { |
| - t.Fatal(err) |
| - } |
| - if st.Uid != uid || st.Gid != gid { |
| - t.Fatalf("root-owned artifact: %s uid=%d gid=%d", path, st.Uid, st.Gid) |
| + for _, path := range []string{ |
| + filepath.Join(f.g.LockDir, f.obs.Host+".lock"), |
| + filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), |
| + } { |
| + info, err := os.Stat(path) |
| + if err != nil || info.Mode().Perm() != 0660 { |
| + t.Fatalf("mode: %v %v", info, err) |
| } |
| } |
| - // The Go build directory may be root-only. Copy this test binary into |
| - // our disposable tree, then pass directory/executable descriptors so |
| - // root-private TMPDIR ancestors do not affect the child. |
| - src, err := os.Open(os.Args[0]) |
| - if err != nil { |
| - t.Fatal(err) |
| +} |
| + |
| +func TestGuardLockSymlinkRefused(t *testing.T) { |
| + for _, directory := range []bool{false, true} { |
| + t.Run(fmt.Sprint(directory), func(t *testing.T) { |
| + f := newGuardFixture(t) |
| + outside := t.TempDir() |
| + target := filepath.Join(outside, f.obs.Host+".lock") |
| + if err := os.WriteFile(target, []byte("untouched"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unchangedFile(t, target)() |
| + link, destination := filepath.Join(f.g.LockDir, f.obs.Host+".lock"), target |
| + if directory { |
| + if err := os.Remove(f.g.LockDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + link, destination = f.g.LockDir, outside |
| + } |
| + if err := os.Symlink(destination, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err == nil || f.reads != 0 { |
| + t.Fatalf("symlink admitted: %v reads=%d", err, f.reads) |
| + } |
| + }) |
| } |
| - defer src.Close() |
| - binary := filepath.Join(base, "guard-test") |
| - dst, err := os.OpenFile(binary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) |
| - if err != nil { |
| +} |
| + |
| +func TestGuardLockRequiresRegularFile(t *testing.T) { |
| + f := newGuardFixture(t) |
| + path := filepath.Join(f.g.LockDir, f.obs.Host+".lock") |
| + if err := unix.Mkfifo(path, 0600); err != nil { |
| t.Fatal(err) |
| } |
| - _, copyErr := io.Copy(dst, src) |
| - if err := errors.Join(copyErr, dst.Close()); err != nil { |
| - t.Fatal(err) |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrUnsafeFile) || f.reads != 0 { |
| + t.Fatalf("non-regular lock admitted: %v reads=%d", err, f.reads) |
| } |
| - if err := os.Chmod(binary, 0755); err != nil { |
| - t.Fatal(err) |
| + info, err := os.Lstat(path) |
| + if err != nil || info.Mode().Perm() != 0600 { |
| + t.Fatalf("mode changed before type check: %v %v", info, err) |
| } |
| - baseFD, err := os.Open(base) |
| - if err != nil { |
| - t.Fatal(err) |
| +} |
| + |
| +func TestGuardDirectoriesRequired(t *testing.T) { |
| + for _, which := range []string{"lock", "record"} { |
| + t.Run(which, func(t *testing.T) { |
| + f := newGuardFixture(t) |
| + path := f.g.LockDir |
| + if which == "record" { |
| + path = filepath.Join(f.g.DataDir, "hostguard") |
| + } |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrNotProvisioned) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("directory recreated", err) |
| + } |
| + }) |
| } |
| - defer baseFD.Close() |
| - binaryFD, err := os.Open(binary) |
| - if err != nil { |
| +} |
| + |
| +func TestGuardRecordSymlinkRefused(t *testing.T) { |
| + f := newGuardFixture(t) |
| + host := f.obs.Host |
| + outside := filepath.Join(t.TempDir(), "record") |
| + if err := os.WriteFile(outside, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil { |
| t.Fatal(err) |
| } |
| - defer binaryFD.Close() |
| - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| - defer cancel() |
| - cmd := exec.CommandContext(ctx, "/proc/self/fd/4", "-test.run=^TestGuardForeignOwnerProcess$", "-test.count=1") |
| - cmd.Dir = "/" |
| - cmd.ExtraFiles = []*os.File{baseFD, binaryFD} |
| - cmd.Env = []string{"CARLOS_HOSTGUARD_FOREIGN_OWNER=/proc/self/fd/3", "TMPDIR=/proc/self/fd/3/data"} |
| - cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uid, Gid: gid}} |
| - if out, err := cmd.CombinedOutput(); err != nil { |
| - t.Fatalf("service-user access: %v %s", err, out) |
| + defer unchangedFile(t, outside)() |
| + if err := os.Symlink(outside, filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil { |
| + t.Fatal(err) |
| } |
| - captured, err := Capture(f.g.DataDir, f.obs.Host) |
| - if err != nil || captured.Stamp != 2 { |
| - t.Fatal("service-user update failed", captured, err) |
| + for _, operation := range []func() error{ |
| + func() error { return f.g.Activate(t.Context(), host, "uk", nil) }, |
| + func() error { return f.g.RetireInstallation(t.Context(), host, allow) }, |
| + func() error { |
| + return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { |
| + t.Fatal("symlink admitted writer") |
| + return nil |
| + }) |
| + }, |
| + } { |
| + if err := operation(); err == nil || errors.Is(err, ErrNoRecord) { |
| + t.Fatal("record symlink not refused", err) |
| + } |
| } |
| } |
| -func TestGuardSharedFileModes(t *testing.T) { |
| +func TestGuardDirectorySwap(t *testing.T) { |
| f := newGuardFixture(t) |
| - if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + host := f.obs.Host |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| t.Fatal(err) |
| } |
| - for _, pair := range [][2]string{ |
| - {filepath.Join(f.g.LockDir, f.obs.Host+".lock"), f.g.LockDir}, |
| - {filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), f.g.DataDir}, |
| - } { |
| - info, err := os.Stat(pair[0]) |
| - if err != nil || info.Mode().Perm() != 0660 { |
| - t.Fatalf("mode: %v %v", info, err) |
| - } |
| - var file, parent unix.Stat_t |
| - if err := unix.Stat(pair[0], &file); err != nil { |
| + original := filepath.Join(f.g.DataDir, "hostguard") |
| + pinned := filepath.Join(f.g.DataDir, "pinned") |
| + outside := t.TempDir() |
| + target := filepath.Join(outside, host+".json") |
| + if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":99,"retired_stamps":[]}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer unchangedFile(t, target)() |
| + f.obs.Claim.Holder.Stamp = 2 |
| + f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { |
| + // Guard.locked has already opened its record dirfd before the fresh read. |
| + if err := os.Rename(original, pinned); err != nil { |
| t.Fatal(err) |
| } |
| - if err := unix.Stat(pair[1], &parent); err != nil { |
| + if err := os.Symlink(outside, original); err != nil { |
| t.Fatal(err) |
| } |
| - if file.Uid != parent.Uid || file.Gid != parent.Gid { |
| - t.Fatal("owner/group not preserved") |
| - } |
| + return f.obs, nil |
| + }) |
| + if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| + t.Fatal("pinned update failed", err) |
| + } |
| + fd, err := openDirectory(pinned) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + d := &recordDir{fd: fd} |
| + defer d.close() |
| + r, err := d.read(host) |
| + if err != nil || r.ActiveStamp != 2 { |
| + t.Fatalf("pinned record: %+v %v", r, err) |
| + } |
| + entries, err := os.ReadDir(outside) |
| + if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { |
| + t.Fatalf("write escaped pinned directory: %v %v", entries, err) |
| + } |
| + entries, err = os.ReadDir(pinned) |
| + if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { |
| + t.Fatalf("temporary file left behind: %v %v", entries, err) |
| + } |
| + if _, err := Capture(f.g.DataDir, host); err == nil { |
| + t.Fatal("new capture followed replacement directory symlink") |
| } |
| } |
| ``` |
| @@ -1447,6 +1674,9 @@ import ( |
| ) |
| func TestUnsupportedLock(t *testing.T) { |
| + if _, err := Capture(t.TempDir(), "shop.example"); !errors.Is(err, ErrUnsupported) { |
| + t.Fatal(err) |
| + } |
| g := Guard{DataDir: t.TempDir(), LockDir: t.TempDir()} |
| if err := g.Activate(context.Background(), "shop.example", "box", nil); !errors.Is(err, ErrUnsupported) { |
| t.Fatal(err) |
| @@ -2666,38 +2896,40 @@ Expected: all tests pass. The successful collector test uses a genuine `FenceAnd |
| --- |
| -### Task 7: Provision the shared lock directory |
| +### Task 7: Provision both shared guard directories |
| **Files:** |
| - Modify: `internal/host/bootstrap.go`, `internal/host/units_isolation_test.go` |
| **Interfaces:** |
| -- Produces: `/run/carlos/hostguard`, mode `0770`, owner/group `serviceUser` (`carlos`). |
| +- Produces: `/run/carlos/hostguard` and `/data/hostguard`, mode `2770`, owner/group `serviceUser` (`carlos`). |
| - Consumes: existing `tmpfilesConf` and `TestTmpfilesCreatesTheInstanceTrees`. |
| -- [ ] **Step 1: Add one expected line to the existing test** |
| +- [ ] **Step 1: Add both expected lines to the existing test** |
| -In `TestTmpfilesCreatesTheInstanceTrees`, add this exact string to the `want` slice, immediately after the `/run/carlos-control` expectation. Imports remain unchanged (`strings` and `testing` are already imported). |
| +In `TestTmpfilesCreatesTheInstanceTrees`, add these exact strings to the `want` slice, immediately after the `/run/carlos-control` expectation. Imports remain unchanged (`strings` and `testing` are already imported). |
| ```go |
| - "d /run/carlos/hostguard 0770 carlos carlos -", |
| + "d /run/carlos/hostguard 2770 carlos carlos -", |
| + "d /data/hostguard 2770 carlos carlos -", |
| ``` |
| - [ ] **Step 2: Run the failing test** |
| Run: `go test ./internal/host -run '^TestTmpfilesCreatesTheInstanceTrees$' -count=1` |
| -Expected: failure saying `tmpfilesConf lacks "d /run/carlos/hostguard 0770 carlos carlos -"`. |
| +Expected: failures naming both missing `2770 carlos carlos` guard directory entries. |
| -- [ ] **Step 3: Add the one tmpfiles line** |
| +- [ ] **Step 3: Add both tmpfiles lines** |
| -In `tmpfilesConf`, insert this exact concatenated line immediately after the existing `/run/carlos/apps` line: |
| +In `tmpfilesConf`, insert these exact concatenated lines immediately after the existing `/run/carlos/apps` line: |
| ```go |
| - "d /run/carlos/hostguard 0770 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /run/carlos/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /data/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" + |
| ``` |
| -Root can open the service-owned directory. The ownership helper in Task 2 ensures that a lock first created by root also remains usable by `carlos`. Existing socket-consumer group grants do not extend to host guards. |
| +Both directories are setgid so root-created locks and records inherit group `carlos`. File-descriptor mode changes preserve group read/write despite umask. The library never changes ownership or directory modes. R9 replaces R7's single 0770 runtime entry; these entries assign the service group rather than the socket-consumer group. |
| - [ ] **Step 4: Run the green test** |
| @@ -2744,7 +2976,7 @@ git diff --stat |
| rg -n 'hostguard|stopevidence|OpenCleanup' internal/activator internal/host internal/appconfig |
| ``` |
| -Expected: `git diff --check` is silent. The only existing production file changed is `internal/host/bootstrap.go`, by one tmpfiles line. The search may match the tmpfiles line and its test; there are no guard/collector call sites in production paths. Config sync lives in `internal/appconfig/syncexec.go`. Review the final diff with the repository's authority-review model policy; use a supported equivalent explicitly if the named model is unavailable. |
| +Expected: `git diff --check` is silent. The only existing production file changed is `internal/host/bootstrap.go`, by two tmpfiles lines. The search may match the tmpfiles lines and their test; there are no guard/collector call sites in production paths. Config sync lives in `internal/appconfig/syncexec.go`. Review the final diff with the repository's authority-review model policy; use a supported equivalent explicitly if the named model is unavailable. |
| - [ ] **Step 4: Record the gate and review result for the implementation branch** |
| @@ -2754,16 +2986,18 @@ Keep all implementation iterations on `feat/hostclaim-boxguard`. The later landi |
| ## Rollout note |
| -Plan 3 adds inert libraries and the shared runtime directory. It does not change any current writer, spawn path, timer, agent loop, adopt service or config-sync service. Installing the tmpfiles entry does not enable the claim protocol. Plan 6 wires capture at work selection and guarded admission at every host-specific write, changes every lifecycle spawn to `PrepareStrict`, and chooses the evidence collector's placement and privileges. In particular, an agent running as `carlos` usually cannot read other UIDs' `/proc/*/fd`; this library returns `ErrIncomplete` and produces no evidence in that situation. No claim is revoked on that basis. Plans 5–7 remain behind `CARLOS_HOST_CLAIM` until Plan 8 establishes the protocol floor, as in Plan 1's roadmap. |
| +Plan 3 adds inert libraries and tmpfiles entries for the two shared guard directories. It does not change any current writer, spawn path, timer, agent loop, adopt service or config-sync service. Installing the tmpfiles entries does not enable the claim protocol. Plan 6 wires capture at work selection and guarded admission at every host-specific write, changes every lifecycle spawn to `PrepareStrict`, and chooses the evidence collector's placement and privileges. In particular, an agent running as `carlos` usually cannot read other UIDs' `/proc/*/fd`; this library returns `ErrIncomplete` and produces no evidence in that situation. No claim is revoked on that basis. Plans 5–7 remain behind `CARLOS_HOST_CLAIM` until Plan 8 establishes the protocol floor, as in Plan 1's roadmap. |
| + |
| +Before Plan 6 wires callers, preserve guard ownership across live bootstrap and add a regression test. The existing `privilegedPlan` recursively assigns `/run/carlos` to `carlos:carlos-sockets`, restores only `apps/`, and does not run tmpfiles afterward. Re-running it after guard provisioning can change the guard directory and lock files to the socket-consumer group. Task 7 adds the binding R9 entries; this plan does not change that existing bootstrap behavior. Plan 6 must close this provisioning gap before relying on the shared-group boundary. |
| ## Self-review |
| | Binding ruling or requirement | Covered here | |
| |---|---| |
| -| R1, libraries/tests plus one tmpfiles line; no production callers | Architecture, Tasks 1–7, Task 8 scope check, rollout | |
| +| R1, libraries/tests plus two tmpfiles lines under R9; no production callers | Architecture, Tasks 1–7, Task 8 scope check, rollout | |
| | R2, branch from Plan 1 | Task 1: `feat/hostclaim-boxguard` from `feat/hostclaim-library` | |
| | R3, local schema v1, strict decoding, valid host, atomic write | Task 1: unknown/trailing/malformed fields refused; same-directory temp, file fsync, rename, directory fsync; failed-rename seam | |
| -| R3, exclusive shared flock, cancellation, 0660, non-Unix refusal | Tasks 2–3; root preserves service ownership, never unlinks lock inode | |
| +| R3, exclusive shared flock, cancellation, 0660, non-Unix refusal | Tasks 2–3; setgid supplies the service group, fd-based mode changes follow type validation, never unlinks lock inode | |
| | R3, exact claim adapter and fresh read inside lock | Task 2 `StoreReader`, `Guard.fresh`; Task 3 blocked-lock observation test | |
| | R3, capture at selection and every `With` refusal | Tasks 1–3: distinct absence/incarnation/inactive/stamp errors and callback checks | |
| | R3, initialization/new installation, authorized participant | Task 2 activation, lost-record recovery, same-incarnation no-op, replacement incarnation, retired/old stamp refusals | |
| @@ -2777,8 +3011,11 @@ Plan 3 adds inert libraries and the shared runtime directory. It does not change |
| | R4, collect all conditions, exact fence binding, zero evidence on error | Task 6 concrete activator proof, independent cgroup/member/FD/replicator checks, repeated system reads | |
| | R4, cleanup entry point | Task 6 delegates only to `Cleanup.KillAndEmpty` | |
| | R5, collector placement and privileges deferred | Task 5, Task 6 and rollout; incomplete visibility refuses | |
| -| R6, platform stubs and cross-builds | Tasks 2, 4, 5 and 8 | |
| -| R7, one tmpfiles line and existing test | Task 7 | |
| +| R6, platform stubs and cross-builds | Tasks 1–2, 4, 5 and 8 | |
| +| R7, superseded by R9 for provisioning | Task 7 supplies both setgid directory entries and updates the existing test | |
| +| R9, no ownership or pathname metadata changes; no directory creation | Tasks 1–2 use pinned directory descriptors, Openat with NOFOLLOW, Fstat, Fchmod, Renameat and Fsync; refusing non-Unix stubs; Task 7 provisions both setgid directories | |
| +| Independent review P1, lock symlink could transfer root-file ownership | Tasks 2–3: descriptor-relative no-follow lock open, regular-file validation before mode change, target-content/mode/inode regression | |
| +| Independent review P1, record-directory symlink and replacement race | Tasks 1–3: directory, record and temporary-name symlink refusals; read/write share the pinned directory; swap witness preserves the external target | |
| | R8, Go cache | Global Constraints and Task 8 | |
| | §2, holder stamp/incarnation/op.exec | Tasks 1–2; no changes to claim generation, sessions or writer admissions | |
| | C4, incarnation/operation/held-input freshness | Task 2 required callback under flock; Plan 6 supplies each concrete operation/phase/held-identity check | |
| @@ -2793,6 +3030,6 @@ Plan 3 adds inert libraries and the shared runtime directory. It does not change |
| | §6.4, two retirements and active/retired invariant | Tasks 1–2 validate disjointness; only activation/installation retirement move stamps; Plan 4 never changes stamps during retire-artifacts | |
| | §6.4, ordered installations and paused writer | Tasks 2–3 compare captured stamps, retain latest retired maximum, test successful round trip and blocked fresh read | |
| -Deliberately left to **Plan 4**: inventory discovery/update and per-artifact stamp tags; flat and nested layout retirement; `RENAME_NOREPLACE`, parking journals, fsync of parked directories and acknowledgements; composition of stamp retirement with all-artifact parking. Deliberately left to **Plan 6**: guard integration at work selection and every write; concrete operation/held-input checks; lifecycle strict spawns; probe rendering/materialization; re-establishing admission fences after restart; maintaining fence closure through collection and claim revocation; evidence collector privileges and complete proc visibility; cleanup of legacy escaped processes and all replicators. No artifact operation, claim CAS or authority transfer is hidden in these libraries. |
| +Deliberately left to **Plan 4**: inventory discovery/update and per-artifact stamp tags; flat and nested layout retirement; `RENAME_NOREPLACE`, parking journals, fsync of parked directories and acknowledgements; composition of stamp retirement with all-artifact parking. Deliberately left to **Plan 6**: preserving guard ownership across bootstrap with regression coverage before enabling callers; guard integration at work selection and every write; concrete operation/held-input checks; lifecycle strict spawns; probe rendering/materialization; re-establishing admission fences after restart; maintaining fence closure through collection and claim revocation; evidence collector privileges and complete proc visibility; cleanup of legacy escaped processes and all replicators. No artifact operation, claim CAS or authority transfer is hidden in these libraries. |
| -Plan-author verification: all complete Go blocks passed `gofmt` parsing. The exact code blocks were compiled through an anonymous-memory Go overlay using the real Plan 1 package, without creating implementation files. Hostguard and stopevidence tests, plus the new cgroup cleanup tests, passed with the race detector; the three packages also built for Darwin and Windows. The Linux-root ownership-transfer test was skipped on this unprivileged runner. Vet and the full repository gate remain implementation-branch checks; Go's vet runner cannot chdir into overlay-only package directories, so the overlay test binaries were compiled with `-vet=off`. The default cache was read-only, so verification used the existing on-disk `$TMPDIR/gocache`. A separate `gpt-6-astra` review (the available authority-review model; Opus was unavailable) found the cleanup mutex deadline and root-test permission issues; both were corrected and the follow-up review found no remaining blocker. |
| +Plan-author verification (rev 2): every complete Go block passed `gofmt` parsing. The exact blocks compiled through an anonymous-memory Go overlay with the real Plan 1 package; no implementation files were created. All hostguard and stopevidence tests, the new cgroup cleanup tests, and the updated tmpfiles contract test passed with the race detector. The three library packages built for Darwin and Windows. The root-only ownership-transfer test and ownership helpers were removed. The default cache refused writes, so verification used the existing on-disk `$TMPDIR/gocache`. Overlay test compilation used `-vet=off` because vet cannot chdir into overlay-only package directories; vet and the full repository gate remain implementation-branch checks. An independent `gpt-6-astra` authority review (Opus is not available through the collaboration tools) found the existing bootstrap group-reset gap, now recorded as a prerequisite for Plan 6; it found no additional R9 code defect. |