oficina / home Public

Prove refusal on uncertain operator cleanup

Paul Campbell pushed by paul@keymail.dev 790208eb05970d84c40e32a93880afcfa6a16545 parent ad8762f
3 files changed, +74 −12
  • implementation/operator-binding/proposal.json +28 −5
  • internal/operator/command_linux.go +3 −0
  • internal/operator/command_linux_test.go +43 −7

CI passed — run details

diff --git a/implementation/operator-binding/proposal.json b/implementation/operator-binding/proposal.json
index 656c7c9..7f4f1d1 100644
--- a/implementation/operator-binding/proposal.json
+++ b/implementation/operator-binding/proposal.json
@@ -1,11 +1,29 @@
{
"status": "Proposed private adapter component; no command or HTTP mounting",
- "parents": ["Home startup6289db0 with Fable approval", "Opened DB4770bc6", "Operator/provisioner898fe219"],
+ "parents": [
+ "Home startup6289db0 with Fable approval",
+ "Opened DB4770bc6",
+ "Operator/provisioner898fe219"
+ ],
"configuration": {
"source": "HOME_OPERATOR_COMMAND from the worker's immutable protected app environment; never argv or signed-request fields",
"wire": "UTF-8 JSON at most8192 bytes; duplicate, unknown and missing fields refuse",
- "fields": ["version=1", "account", "app", "host", "role", "origin", "installation", "registry", "device", "public_key", "key_id", "actions"],
- "validation": "Shared route validators, existing NewVerifier role/origin/identity/key/action checks, and exact derived key ID match. No default, trust-on-first-input or key rotation by request."
+ "fields": [
+ "version=1",
+ "account",
+ "app",
+ "host",
+ "role",
+ "origin",
+ "installation",
+ "registry",
+ "device",
+ "public_key",
+ "key_id",
+ "actions"
+ ],
+ "validation": "Shared route validators, existing NewVerifier role/origin/identity/key/action checks, and exact derived key ID match. No default, trust-on-first-input or key rotation by request.",
+ "origin_and_host": "Transport host names the deployment endpoint; signed origin is the canonical Home identity origin and can differ during preview/custom-host routing. Both are independently pinned from protected configuration and matched against their corresponding transport or signed fields; neither is derived from the other."
},
"ordering": [
"Parse protected configuration and build the existing signature verifier before database access.",
@@ -16,6 +34,11 @@
"Final cleanup checks pinned database identity, closes the database and clears retained private byte buffers; any uncertain cleanup/deadline yields unavailable, never a successful result."
],
"replay": "Expired authentic requests remain eligible for the existing engine's exact committed-result recovery. The adapter does not grant freshness or execute a mutation. The engine separately enforces freshness before a new operation.",
- "tests": "Real inherited-descriptor subprocesses plus Linux inotify IN_OPEN prove wrong signer, signed scope, configuration and transport scope refuse before any database open. Actual pinned modernc connection accepts the valid path; wrong inode is opened only for identity refusal. Closed config tests cover every missing field, duplicates, unknown fields, trailing data and key mismatch.",
- "remaining": ["Fable approval of configuration alignment and component", "Final fixed command dispatch, role-specific key/sealer/owner resolver and schema checks", "Native worker authentication, owner approval, lease/fence/checkpoint and resume workflow", "Whole integration/canary validation before any live invocation"]
+ "tests": "Real inherited-descriptor subprocesses plus Linux inotify IN_OPEN prove wrong signer, signed scope, configuration and transport scope refuse before any database open. Actual pinned modernc connection accepts the valid path; wrong inode is opened only for identity refusal. Closed config tests cover every missing field, duplicates, unknown fields, trailing data and key mismatch. Real subprocess cleanup refuses a swapped database and expired caller deadline, refuses a second close, and parent checks original rows unchanged. Descriptor tests pair controlled and default Go runtime settings. Oversized valid JSON is refused.",
+ "remaining": [
+ "Fable approval of configuration alignment and component",
+ "Final fixed command dispatch, role-specific key/sealer/owner resolver and schema checks",
+ "Native worker authentication, owner approval, lease/fence/checkpoint and resume workflow",
+ "Whole integration/canary validation before any live invocation"
+ ]
}
diff --git a/internal/operator/command_linux.go b/internal/operator/command_linux.go
index 558bdbb..a632c4e 100644
--- a/internal/operator/command_linux.go
+++ b/internal/operator/command_linux.go
@@ -91,6 +91,9 @@ func openCommandInvocation(parent context.Context, database string) (*commandInv
db, err := newBoundDatabase(launch.DatabasePath, launch.DatabaseDevice, launch.DatabaseInode, deadline)
if err != nil {
cancel()
+ if err == ErrUnavailable {
+ return nil, ErrUnavailable
+ }
return nil, ErrRefused
}
keep = true
diff --git a/internal/operator/command_linux_test.go b/internal/operator/command_linux_test.go
index 0ec2aa0..f998ddf 100644
--- a/internal/operator/command_linux_test.go
+++ b/internal/operator/command_linux_test.go
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/sha256"
+ "database/sql"
"encoding/hex"
"encoding/json"
"errors"
@@ -49,7 +50,7 @@ func TestCommandConfigRequiresExactPins(t *testing.T) {
}
fields[key] = value
}
- for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} {
+ for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), string(raw) + strings.Repeat(" ", 8193-len(raw)), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} {
if _, _, err := parseCommandConfig(bad); err == nil {
t.Fatal("bad configuration accepted")
}
@@ -69,7 +70,19 @@ func TestCommandInvocationChild(t *testing.T) {
if i.request.Authorization().OperationID != token(4) || i.launch.RequestID != token(5) || i.launch.AttemptID != token(6) || i.database.db.QueryRowContext(i.ctx, "SELECT count(*) FROM changes").Scan(&count) != nil || count != 0 {
os.Exit(3)
}
- if err := i.close(); err != nil {
+ var wantClose error
+ switch os.Getenv("HOME_COMMAND_FIXTURE") {
+ case "swap-before-close":
+ path := os.Getenv("HOME_COMMAND_DATABASE")
+ if os.Rename(path, path+".saved") != nil || os.Rename(path+".other", path) != nil {
+ os.Exit(7)
+ }
+ wantClose = ErrUnavailable
+ case "deadline-before-close":
+ <-ctx.Done()
+ wantClose = ErrUnavailable
+ }
+ if err := i.close(); err != wantClose {
os.Exit(4)
}
if i.close() != ErrRefused {
@@ -88,7 +101,7 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
t.Fatal(err)
}
imageHash := sha256.Sum256(image)
- for _, mode := range []string{"valid", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} {
+ for _, mode := range []string{"valid", "runtime-default", "swap-before-close", "deadline-before-close", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} {
t.Run(mode, func(t *testing.T) {
k, v, a := fixture(t)
cfg := commandConfigFixture(v)
@@ -97,10 +110,11 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
}
switch mode {
case "signer":
- k, err = family.Generate()
- if err != nil {
- t.Fatal(err)
+ otherKey, generateErr := family.Generate()
+ if generateErr != nil {
+ t.Fatal(generateErr)
}
+ k = otherKey
case "origin":
a.Origin = "https://other.example"
case "registry":
@@ -117,6 +131,9 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "home.db")
seedOpenDB(t, database)
+ if mode == "swap-before-close" {
+ seedOpenDB(t, database+".other")
+ }
var st unix.Stat_t
if unix.Stat(database, &st) != nil {
t.Fatal("database stat")
@@ -175,6 +192,9 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
cmd := exec.CommandContext(ctx, path, "-test.run=^TestCommandInvocationChild$")
cmd.Dir = "/"
cmd.Env = []string{"HOME_COMMAND_FIXTURE=" + mode, "HOME_COMMAND_DATABASE=" + database, commandConfigEnvironment + "=" + string(config)}
+ if mode != "runtime-default" {
+ cmd.Env = append(cmd.Env, "GODEBUG=containermaxprocs=0,updatemaxprocs=0")
+ }
cmd.Stdin = inR
cmd.ExtraFiles = []*os.File{contextR, pin}
var output bytes.Buffer
@@ -201,7 +221,7 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
<-writes
<-writes
var result struct{ Accepted bool }
- want := mode == "valid" || mode == "expired-authentic"
+ want := mode == "valid" || mode == "runtime-default" || mode == "swap-before-close" || mode == "deadline-before-close" || mode == "expired-authentic"
if json.Unmarshal(output.Bytes(), &result) != nil || result.Accepted != want {
t.Fatal("wrong invocation result", output.String())
}
@@ -211,6 +231,22 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) {
if err != nil && !errors.Is(err, unix.EAGAIN) {
t.Fatal(err)
}
+ if mode == "swap-before-close" || mode == "deadline-before-close" {
+ original := database
+ if mode == "swap-before-close" {
+ original += ".saved"
+ }
+ db, err := sql.Open("sqlite", "file:"+original+"?mode=ro")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var count int
+ queryErr := db.QueryRow("SELECT count(*) FROM changes").Scan(&count)
+ closeErr := db.Close()
+ if queryErr != nil || closeErr != nil || count != 0 {
+ t.Fatal("original database changed", queryErr, closeErr, count)
+ }
+ }
if opened != (want || mode == "inode") {
t.Fatal("database opened before signed scope validation", mode, opened)
}