oficina / home Public

Reserve consumed startup descriptors before database access

Paul Campbell pushed by paul@keymail.dev 6289db0b2df45206fd91434df1f41c3107531bf6 parent 6143a83
5 files changed, +117 −21
  • Makefile +1 −1
  • go.mod +1 −1
  • implementation/operator-startup/proposal.json +34 −4
  • internal/operatorlaunch/read_linux.go +26 −8
  • internal/operatorlaunch/read_linux_test.go +55 −7

CI passed — run details

diff --git a/Makefile b/Makefile
index 8943f10..9c04636 100644
--- a/Makefile
+++ b/Makefile
@@ -40,7 +40,7 @@ test-fmt:
test-build:
CGO_ENABLED=0 go build ./...
- CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch
cd handoff && CGO_ENABLED=0 go build ./...
cd roster && CGO_ENABLED=0 go build ./...
cd connect && CGO_ENABLED=0 go build ./...
diff --git a/go.mod b/go.mod
index 0cc4a06..79d9ab5 100644
--- a/go.mod
+++ b/go.mod
@@ -20,7 +20,7 @@ require (
)
require (
- amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1 // indirect
+ amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1
filippo.io/hpke v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
diff --git a/implementation/operator-startup/proposal.json b/implementation/operator-startup/proposal.json
index 42b1c58..0341090 100644
--- a/implementation/operator-startup/proposal.json
+++ b/implementation/operator-startup/proposal.json
@@ -1,7 +1,11 @@
{
"status": "Proposed startup component; no command mounting or database integration",
- "parent_contracts": ["home-operator-open.md at approved 4770bc6", "platform maintenance-launch.md at approved 8eb788c9", "home-provisioner.md at approved 898fe219"],
- "alignment": "The prior opened-DB contract listed fd0/1/2/3. The reviewed platform launch adds fd4 as a startup-only readonly artifact pin. Home verifies fd4 against /proc/self/exe and closes both executable descriptors with checked errors before any opened-DB baseline. fd4 never becomes an exception in the database descriptor inventory.",
+ "parent_contracts": [
+ "home-operator-open.md at approved 4770bc6",
+ "platform maintenance-launch.md at approved 8eb788c9",
+ "home-provisioner.md at approved 898fe219"
+ ],
+ "alignment": "The prior opened-DB contract listed fd0/1/2/3. The reviewed platform launch adds fd4 as a startup-only readonly artifact pin. Home verifies fd4 against /proc/self/exe and closes both executable descriptors with checked errors before any opened-DB baseline. fd4 never becomes an exception in the database descriptor inventory. Consumed fd0/fd3/fd4 are closed by atomic replacement with readonly /dev/null, preserving their reservation and preventing os.Stdin from aliasing a future SQLite file. The private /dev/null source is closed after replacement. Executable descriptors do not enter the regular-file baseline.",
"implementation": "internal/operatorlaunch.ReadOperator checks mutable-v1 metadata using the shared exact-pinned launchctx module. No HTTP or command entrypoint invokes it yet.",
"ordering": [
"Read only the private FIFO at fd3 under a five-second startup bound and caller cancellation; cap at 8192 plus one byte, require EOF, close with checked error, reject malformed or checker-mode context.",
@@ -11,6 +15,32 @@
"The future command adapter must next validate the signed Home request using pinned role/origin/action/configuration, then construct the existing opened-DB connector. Startup metadata alone grants no mutation or worker authority. No database is opened by this component."
],
"boundary": "Native Linux, fixed nonserving trusted-worker process. The worker owns actual owner approval, lifecycle fence, continuous lease, verified release/environment and strict containment. Neither synthetic inherited descriptors nor metadata establish those facts.",
- "verification": ["Actual test executable with inherited private pipes and readonly artifact", "Wrong context, oversized input/context, wrong selector/hash/image/flags, wrong mode and expired deadline refuse", "Stalled context and stdin terminate under startup deadline", "fd0/fd3/fd4 are closed before return; database sentinel remains unchanged"],
- "remaining_before_mount": ["Fable review of this alignment and source", "Fixed pinned configuration schema and signed-request binding in final command adapter", "Modernc package-init and opened-DB baseline call-graph review", "Native full worker plus consumer integration", "Real owner-approved preview provisioning and restore drill"]
+ "verification": [
+ "Actual subprocesses with both default Go runtime and controlled fixture runtime settings",
+ "Valid v2 checker context rejected by mutable-v1 reader",
+ "Regular-file context/stdin and same-inode O_PATH executable descriptor rejected",
+ "Wrong context, oversized input/context, wrong selector/hash/image/flags and expired deadline refuse",
+ "Stalled context and stdin terminate under the two-second caller deadline (the component also sets an independent five-second upper bound)",
+ "fd0/fd3/fd4 refer to readonly /dev/null on return; a subsequently opened regular file uses a descriptor above 4 and remains usable after os.Stdin.Close; database sentinel remains unchanged"
+ ],
+ "remaining_before_mount": [
+ "Fable review of this alignment and source",
+ "Fixed pinned configuration schema and signed-request binding in final command adapter",
+ "Modernc package-init and opened-DB baseline call-graph review",
+ "Native full worker plus consumer integration",
+ "Real owner-approved preview provisioning and restore drill"
+ ],
+ "review_history": [
+ {
+ "revision": "6143a83",
+ "model": "claude-fable-5-1",
+ "verdict": "REQUEST_CHANGES",
+ "receipt": "/home/paulca/.cache/oficina-provisioning-20260911/home-startup-review/review.json",
+ "required": [
+ "Atomically replace consumed descriptors with readonly /dev/null",
+ "Prove regular-file context/stdin refusal"
+ ]
+ }
+ ],
+ "parent_contract_note": "Platform maintenance-launch.md retains a stale pending annotation. Actual approvals are recorded in platform discussion117; this Home alignment and source require their own fresh verdict before mounting."
}
diff --git a/internal/operatorlaunch/read_linux.go b/internal/operatorlaunch/read_linux.go
index e117993..2e4fea1 100644
--- a/internal/operatorlaunch/read_linux.go
+++ b/internal/operatorlaunch/read_linux.go
@@ -16,23 +16,41 @@ import (
const startupTimeout = 5 * time.Second
const inputLimit = 64 << 10
-// ReadOperator consumes and closes fd0, fd3 and fd4 in a nonserving process.
+// ReadOperator replaces consumed fd0, fd3 and fd4 with readonly /dev/null.
// It never opens a database or establishes owner, fence or lease authority.
func ReadOperator(parent context.Context, database string) (launch launchctx.LaunchContext, input []byte, err error) {
if parent == nil {
return launchctx.LaunchContext{}, nil, ErrRefused
}
+ null, e := unix.Open("/dev/null", unix.O_RDONLY|unix.O_CLOEXEC, 0)
+ if e != nil {
+ return launchctx.LaunchContext{}, nil, ErrRefused
+ }
+ source, dupErr := unix.FcntlInt(uintptr(null), unix.F_DUPFD_CLOEXEC, 5)
+ closeErr := unix.Close(null)
+ if dupErr != nil || closeErr != nil {
+ if dupErr == nil {
+ unix.Close(source)
+ }
+ return launchctx.LaunchContext{}, nil, ErrRefused
+ }
owned := map[int]bool{0: true, 3: true, 4: true}
- closeFD := func(fd int) error {
+ consumeFD := func(fd int) error {
+ if e := unix.Dup3(source, fd, unix.O_CLOEXEC); e != nil {
+ return e
+ }
delete(owned, fd)
- return unix.Close(fd)
+ return nil
}
defer func() {
for fd := range owned {
- if closeFD(fd) != nil {
+ if consumeFD(fd) != nil {
err = ErrRefused
}
}
+ if unix.Close(source) != nil {
+ err = ErrRefused
+ }
if err != nil {
clear(input)
input = nil
@@ -47,7 +65,7 @@ func ReadOperator(parent context.Context, database string) (launch launchctx.Lau
return launchctx.LaunchContext{}, nil, ErrRefused
}
defer clear(raw)
- if closeFD(3) != nil {
+ if consumeFD(3) != nil {
return launchctx.LaunchContext{}, nil, ErrRefused
}
launch, e = launchctx.DecodeOperator(raw)
@@ -60,11 +78,11 @@ func ReadOperator(parent context.Context, database string) (launch launchctx.Lau
}
ctx, cancelLaunch := context.WithDeadline(ctx, deadline)
defer cancelLaunch()
- if checkExecutable(ctx, 4, launch.ArtifactDigest) != nil || closeFD(4) != nil {
+ if checkExecutable(ctx, 4, launch.ArtifactDigest) != nil || consumeFD(4) != nil {
return launchctx.LaunchContext{}, nil, ErrRefused
}
input, e = readPipe(ctx, 0, inputLimit)
- if e != nil || closeFD(0) != nil || ctx.Err() != nil {
+ if e != nil || consumeFD(0) != nil || ctx.Err() != nil {
return launchctx.LaunchContext{}, input, ErrRefused
}
sum := sha256.Sum256(input)
@@ -115,7 +133,7 @@ func readPipe(ctx context.Context, fd, limit int) ([]byte, error) {
func checkExecutable(ctx context.Context, artifact int, digest string) (err error) {
flags, e := unix.FcntlInt(uintptr(artifact), unix.F_GETFL, 0)
var pin unix.Stat_t
- if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || unix.Fstat(artifact, &pin) != nil || pin.Mode&unix.S_IFMT != unix.S_IFREG {
+ if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || flags&unix.O_PATH != 0 || unix.Fstat(artifact, &pin) != nil || pin.Mode&unix.S_IFMT != unix.S_IFREG {
return ErrRefused
}
self, e := unix.Open("/proc/self/exe", unix.O_RDONLY|unix.O_CLOEXEC, 0)
diff --git a/internal/operatorlaunch/read_linux_test.go b/internal/operatorlaunch/read_linux_test.go
index adc8991..1cb6ecf 100644
--- a/internal/operatorlaunch/read_linux_test.go
+++ b/internal/operatorlaunch/read_linux_test.go
@@ -9,7 +9,6 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
- "errors"
"io"
"os"
"os/exec"
@@ -31,13 +30,27 @@ func TestStartupChild(t *testing.T) {
c, raw, err := ReadOperator(ctx, os.Getenv("HOME_STARTUP_DATABASE"))
closed := true
for _, fd := range []uintptr{0, 3, 4} {
- if _, e := unix.FcntlInt(fd, unix.F_GETFD, 0); !errors.Is(e, unix.EBADF) {
+ var st unix.Stat_t
+ flags, e := unix.FcntlInt(fd, unix.F_GETFL, 0)
+ if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || unix.Fstat(int(fd), &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFCHR || unix.Major(st.Rdev) != 1 || unix.Minor(st.Rdev) != 3 {
closed = false
}
}
if err != nil && (err != ErrRefused || len(raw) != 0 || c.Version != 0) {
os.Exit(3)
}
+ if err == nil {
+ file, e := os.Open(os.Getenv("HOME_STARTUP_DATABASE"))
+ if e != nil || file.Fd() <= 4 {
+ os.Exit(4)
+ }
+ if os.Stdin.Close() != nil {
+ os.Exit(5)
+ }
+ if _, e := file.Stat(); e != nil || file.Close() != nil {
+ os.Exit(6)
+ }
+ }
json.NewEncoder(os.Stdout).Encode(map[string]any{"accepted": err == nil, "closed": closed, "input_bytes": len(raw)})
clear(raw)
os.Exit(0)
@@ -49,7 +62,7 @@ func TestStartupTransportInSubprocess(t *testing.T) {
t.Fatal(e)
}
imageSum := sha256.Sum256(image)
- for _, mode := range []string{"valid", "context", "context-overflow", "input", "input-overflow", "selector", "artifact-hash", "artifact-identity", "artifact-flags", "expired", "checker", "stalled-context", "stalled-input"} {
+ for _, mode := range []string{"valid", "runtime-default", "context", "context-file", "context-overflow", "input", "input-file", "input-overflow", "selector", "artifact-hash", "artifact-identity", "artifact-flags", "artifact-path", "expired", "checker", "stalled-context", "stalled-input"} {
t.Run(mode, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "image")
@@ -77,6 +90,8 @@ func TestStartupTransportInSubprocess(t *testing.T) {
selector += ".other"
case "artifact-hash":
c.ArtifactDigest = digest
+ case "artifact-path":
+ artifactFlags = unix.O_PATH
case "artifact-identity", "artifact-flags":
artifactPath = filepath.Join(dir, "other")
if e := os.WriteFile(artifactPath, image, 0600); e != nil {
@@ -87,6 +102,16 @@ func TestStartupTransportInSubprocess(t *testing.T) {
}
case "expired":
c.ExecutionDeadline = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano)
+ case "checker":
+ c.Version = 2
+ c.ExecutionKind = "compatibility-check"
+ c.OperationID = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32))
+ c.ExpectedStateSHA256 = digest
+ c.ClosedCheckpoint = &launchctx.ClosedCheckpoint{Version: 1, PreparationID: id, CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), MainLength: 1, MainSHA256: digest}
+ c.ClosedCheckpoint.EvidenceSHA256, e = c.CheckpointDigest()
+ if e != nil {
+ t.Fatal(e)
+ }
}
raw, e := launchctx.EncodeLaunch(c)
if e != nil {
@@ -97,8 +122,6 @@ func TestStartupTransportInSubprocess(t *testing.T) {
raw = []byte(`{"version":1}`)
case "context-overflow":
raw = bytes.Repeat([]byte(" "), launchctx.MaxLaunchBytes+1)
- case "checker":
- raw = bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":2`), 1)
}
pin, e := os.OpenFile(artifactPath, artifactFlags, 0)
if e != nil {
@@ -121,9 +144,33 @@ func TestStartupTransportInSubprocess(t *testing.T) {
defer cancel()
cmd := exec.CommandContext(ctx, path, "-test.run=^TestStartupChild$")
cmd.Dir = "/"
+ // Keep the fixture's descriptor inventory independent of runtime CPU detection.
cmd.Env = []string{"HOME_STARTUP_FIXTURE=1", "HOME_STARTUP_DATABASE=" + selector, "GODEBUG=containermaxprocs=0,updatemaxprocs=0"}
+ if mode == "runtime-default" {
+ cmd.Env = cmd.Env[:2]
+ }
cmd.Stdin = inR
cmd.ExtraFiles = []*os.File{contextR, pin}
+ if mode == "input-file" || mode == "context-file" {
+ content := input
+ if mode == "context-file" {
+ content = raw
+ }
+ filePath := filepath.Join(dir, "invalid-transport")
+ if e := os.WriteFile(filePath, content, 0600); e != nil {
+ t.Fatal(e)
+ }
+ file, e := os.Open(filePath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer file.Close()
+ if mode == "input-file" {
+ cmd.Stdin = file
+ } else {
+ cmd.ExtraFiles[0] = file
+ }
+ }
var output bytes.Buffer
cmd.Stdout = &output
if e := cmd.Start(); e != nil {
@@ -155,10 +202,11 @@ func TestStartupTransportInSubprocess(t *testing.T) {
Closed bool `json:"closed"`
InputBytes int `json:"input_bytes"`
}
- if json.Unmarshal(output.Bytes(), &result) != nil || !result.Closed || result.Accepted != (mode == "valid") {
+ wantAccepted := mode == "valid" || mode == "runtime-default"
+ if json.Unmarshal(output.Bytes(), &result) != nil || !result.Closed || result.Accepted != wantAccepted {
t.Fatal("wrong startup result", output.String())
}
- if mode == "valid" && result.InputBytes != len(input) {
+ if wantAccepted && result.InputBytes != len(input) {
t.Fatal("private input changed")
}
unchanged, e := os.ReadFile(database)