Reserve consumed startup descriptors before database access
5 files changed,
+117
−21
Makefile+1 −1go.mod+1 −1implementation/operator-startup/proposal.json+34 −4internal/operatorlaunch/read_linux.go+26 −8internal/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) |