carlos / platform Public

console: move detail carries no env values; waiting_for_box in every no-box phase (Task 2 fix round 1)

- The detail answer no longer passes any value from either report's env
  section. A malformed report's raw value can have an HMAC's shape, so the
  hex-shape filter is gone: the source report's environment is one
  {name, kind} entry per variable (moves.Class), with neither HMACs nor the
  digest, and the target report's inputs.env_report is blanked.
- waiting_for_box is reported for a relocating claim of this move with exec
  "none" in any phase (starting, releasing, pinning), not only pinning (L18).
- TestMoveDetailLeaksNothing plants raw 64-hex secrets in a malformed source
  report (a var and the digest) and in the target's env input, and asserts no
  env value of either report reaches the body; the waiting tests cover each
  phase that runs on no box, and a releasing phase on its box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 63ccceaffc3e4e9d119b43c9f21f39a638c63514 parent 66d0ccd
2 files changed, +131 −61
  • internal/console/apiinstancemove.go +44 −42
  • internal/console/apiinstancemove_test.go +87 −19
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index ed220c1da..1febe23d8 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -1093,8 +1093,11 @@ func sourceEnrollment(r hostactors.Roster, box string) (string, bool) {
// moveDetail is the operator's detail answer: the flat view, unchanged, and
// beside it the detail object. Everything in it is a record field, a claim
// field, a report the boxes published for the CLI, or an object key: never
-// an evidence or log body, and never a launch environment value (the
-// source report's environment is HMACs only, sourceReportHMACsOnly).
+// an evidence or log body, and never a launch environment value. The
+// console cannot tell an HMAC from a raw value of the same shape in a
+// malformed report, so neither report's env section passes any value: the
+// source's environment is names and kinds (moveSourceReport), and the
+// target's env input is blanked.
type moveDetail struct {
moveView
Detail moveDetailBody `json:"detail"`
@@ -1108,7 +1111,7 @@ type moveDetailBody struct {
// TargetReport and SourceReport are the reports under the move's report
// prefix, nil when absent or unreadable as a report.
TargetReport *moves.TargetReport `json:"target_report,omitempty"`
- SourceReport *moves.SourceReport `json:"source_report,omitempty"`
+ SourceReport *moveSourceReport `json:"source_report,omitempty"`
// Evidence names the stop evidence the record points at, by key.
Evidence moveEvidenceKeys `json:"evidence"`
// Log is every key under the move's log/ prefix, sorted (preflight L26:
@@ -1119,6 +1122,39 @@ type moveDetailBody struct {
WaitingForBox string `json:"waiting_for_box,omitempty"`
}
+// moveSourceReport is the source report with its environment replaced by
+// one entry per variable: the name and its T3 kind (moves.Class). The
+// report's Env (per-name HMACs and their digest) is shadowed by this Env
+// and never encoded.
+type moveSourceReport struct {
+ moves.SourceReport
+ Env []moveEnvEntry `json:"env"`
+}
+
+// moveEnvEntry is a launch environment variable as the detail shows it. The
+// reports compare values only by HMAC and record no per-name result, so
+// the kind is all there is to say beside the name.
+type moveEnvEntry struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+}
+
+// sourceReportWithoutValues is the source report as the detail shows it.
+func sourceReportWithoutValues(r moves.SourceReport) *moveSourceReport {
+ names := make([]string, 0, len(r.Env.Vars))
+ for name := range r.Env.Vars {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+ out := &moveSourceReport{Env: make([]moveEnvEntry, 0, len(names))}
+ for _, name := range names {
+ out.Env = append(out.Env, moveEnvEntry{Name: name, Kind: moves.Class(name)})
+ }
+ r.Env = moves.EnvReport{}
+ out.SourceReport = r
+ return out
+}
+
// moveClaimView is the claim operation's shape and nothing else (no journal,
// no holder). Target is the move's target box, Intent["target"], which M-C10
// names (preflight L28).
@@ -1138,12 +1174,6 @@ type moveEvidenceKeys struct {
Target string `json:"target,omitempty"`
}
-// claimPhasePinning is the relocating claim's first phase, which runs on no
-// box (exec "none") until one is chosen. hostclaim does not export it; the
-// literal is hostclaim/relocating.go's (validateRelocating and
-// ConvertMovingToRelocating), preflight L30.
-const claimPhasePinning = "pinning"
-
// moveRelocationRegion is the region of a relocating claim operation that
// belongs to this record's move (the same holder incarnation), and whether
// there is one.
@@ -1214,18 +1244,20 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A
return
}
if tok {
+ target.Inputs.EnvReport = ""
out.Detail.TargetReport = &target
}
if sok {
- sourceReportHMACsOnly(&source)
- out.Detail.SourceReport = &source
+ out.Detail.SourceReport = sourceReportWithoutValues(source)
}
slices.Sort(keys)
out.Detail.Log = append(out.Detail.Log, keys...)
}
if (rec.Move != nil && rec.Move.State == provision.MoveRelocating) || moveConvertedAfterCommit(rec, obs) {
out.State = provision.MoveRelocating
- if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Phase == claimPhasePinning && obs.Claim.Op.Exec == "none" {
+ // Any phase that runs on no box (starting, releasing and pinning
+ // today, hostclaim's validateRelocating) is waiting for one.
+ if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Exec == "none" {
live, known := s.regionHasLiveBox(ctx, region)
if known && !live {
out.Detail.WaitingForBox = s.homeRegionOut(region)
@@ -1252,36 +1284,6 @@ func (s *Server) readMoveReport(ctx context.Context, key string, into any) (bool
return true, nil
}
-// sourceReportHMACsOnly keeps the report's environment to what ReportEnv
-// writes: per-name HMACs, 64 hex characters each, and a digest of them,
-// "sha256:" and 64 hex characters. A value of any other shape is not an
-// HMAC and may be the value itself, so it is dropped rather than passed to
-// the operator (Review Focus 2).
-func sourceReportHMACsOnly(r *moves.SourceReport) {
- vars := make(map[string]string, len(r.Env.Vars))
- for k, v := range r.Env.Vars {
- if isHex64(v) {
- vars[k] = v
- }
- }
- r.Env.Vars = vars
- if d, ok := strings.CutPrefix(r.Env.Digest, "sha256:"); !ok || !isHex64(d) {
- r.Env.Digest = ""
- }
-}
-
-func isHex64(s string) bool {
- if len(s) != 64 {
- return false
- }
- for _, c := range s {
- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
- return false
- }
- }
- return true
-}
-
// regionHasLiveBox reports whether the region (record vocabulary) has a live
// enrolled box, through the region HA contracts (Task 2 rulings, L18):
// boxchoice.Eligible (eligibleBoxes) over the live edges snapshot, with the
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index 52f958407..bd80efd44 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -1685,8 +1685,17 @@ type moveAnswer struct {
Target string `json:"target"`
} `json:"claim"`
TargetReport *moves.TargetReport `json:"target_report"`
- SourceReport *moves.SourceReport `json:"source_report"`
- Evidence struct {
+ SourceReport *struct {
+ Box string `json:"box"`
+ MoveID string `json:"move_id"`
+ S1 moves.Check `json:"s1"`
+ S2 moves.Check `json:"s2"`
+ Env []struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ } `json:"env"`
+ } `json:"source_report"`
+ Evidence struct {
Source string `json:"source"`
Target string `json:"target"`
} `json:"evidence"`
@@ -1854,12 +1863,20 @@ func TestMoveDetailLeaksNothing(t *testing.T) {
}
src := moves.SourceReport{V: 1, Box: "ie-box", MoveID: mv.ID, At: time.Now().UTC(), Env: moves.ReportEnv(hk, env),
S1: moves.Check{OK: true}, S2: moves.Check{OK: true}, Release: "v0.25.0"}
- // A report that broke the HMAC rule: a raw value under a name. The
- // console passes HMACs only.
+ // Malformed reports: raw values under a name, one of them a secret that
+ // is 64 hex characters (the shape of an HMAC), a digest that is a raw
+ // secret, and a target report whose env input is one. The console can
+ // tell none of them from an HMAC, so it passes no env value at all.
+ const hexSecret = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
+ const hexDigestSecret = "sha256:60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752"
+ const hexTargetSecret = "fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13"
src.Env.Vars["PLAIN_LEAK"] = "raw-env-value-canary"
+ src.Env.Vars["HEX_API_SECRET"] = hexSecret
+ malformed := src
+ malformed.Env.Digest = hexDigestSecret
dst := moves.TargetReport{V: 1, Box: "uk-box", Region: "uk", MoveID: mv.ID, At: time.Now().UTC(), Checks: map[string]moves.Check{
moves.CheckProtocol: {OK: true}, moves.CheckEnv: {OK: false, Reason: "the launch environment differs"},
- }}
+ }, Inputs: provision.MoveInputs{EnvReport: hexTargetSecret, Release: "v0.25.0"}}
putJSON := func(key string, v any) {
t.Helper()
b, err := json.Marshal(v)
@@ -1870,7 +1887,7 @@ func TestMoveDetailLeaksNothing(t *testing.T) {
t.Fatal(err)
}
}
- putJSON(moves.SourceReportKey(prefix), src)
+ putJSON(moves.SourceReportKey(prefix), malformed)
putJSON(moves.TargetReportKey(prefix), dst)
putJSON(srcKey, map[string]any{"v": 1, "role": "source", "note": "evidence-body-canary-source", "env": env["DATABASE_URL"]})
putJSON(dstKey, map[string]any{"v": 1, "role": "target", "note": "evidence-body-canary-target"})
@@ -1883,21 +1900,54 @@ func TestMoveDetailLeaksNothing(t *testing.T) {
if a.Detail == nil || a.Detail.TargetReport == nil || a.Detail.SourceReport == nil {
t.Fatalf("detail = %s, want both reports", out)
}
- for _, leak := range []string{
+ leaks := []string{
env["DATABASE_URL"], "hunter2-db-canary", env["STRIPE_SECRET_KEY"], secretsKey, env["CARLOS_APP_TOKEN"],
- "raw-env-value-canary", "evidence-body-canary", "log-body-canary", token,
- } {
+ "evidence-body-canary", "log-body-canary", token, hexDigestSecret, hexTargetSecret,
+ strings.TrimPrefix(hexDigestSecret, "sha256:"), src.Env.Digest,
+ }
+ // No value of either report's env section: every one of the source's
+ // (the HMACs, the raw canary, the hex secret) and the target's input.
+ for _, v := range malformed.Env.Vars {
+ leaks = append(leaks, v)
+ }
+ for _, leak := range leaks {
if strings.Contains(out, leak) {
t.Errorf("the detail answer holds %q:\n%s", leak, out)
}
}
- for _, want := range []string{moves.CheckProtocol, moves.CheckEnv, `"s1"`, `"s2"`, srcKey, dstKey, src.Env.Digest, src.Env.Vars["DATABASE_URL"]} {
+ for _, want := range []string{moves.CheckProtocol, moves.CheckEnv, `"s1"`, `"s2"`, srcKey, dstKey, "v0.25.0"} {
if !strings.Contains(out, want) {
t.Errorf("the detail answer lacks %q:\n%s", want, out)
}
}
- if _, ok := a.Detail.SourceReport.Env.Vars["PLAIN_LEAK"]; ok {
- t.Errorf("a non-HMAC environment value was passed through: %+v", a.Detail.SourceReport.Env.Vars)
+ // Each env entry is its name and its kind, nothing else.
+ got := map[string]string{}
+ for _, e := range a.Detail.SourceReport.Env {
+ got[e.Name] = e.Kind
+ }
+ want := map[string]string{}
+ for name := range malformed.Env.Vars {
+ want[name] = moves.Class(name)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("source env = %v, want the names and kinds %v", got, want)
+ }
+ var raw struct {
+ Detail struct {
+ SourceReport map[string]json.RawMessage `json:"source_report"`
+ } `json:"detail"`
+ }
+ if err := json.Unmarshal([]byte(out), &raw); err != nil {
+ t.Fatal(err)
+ }
+ var entries []map[string]any
+ if err := json.Unmarshal(raw.Detail.SourceReport["env"], &entries); err != nil {
+ t.Fatalf("source env is not a list of entries: %v in %s", err, raw.Detail.SourceReport["env"])
+ }
+ for _, e := range entries {
+ if len(e) != 2 {
+ t.Errorf("an env entry carries more than its name and kind: %v", e)
+ }
}
if a.Detail.Evidence.Source != srcKey || a.Detail.Evidence.Target != dstKey {
t.Errorf("evidence = %+v, want the two keys", a.Detail.Evidence)
@@ -1931,6 +1981,11 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) {
c.Op.Intent = map[string]string{"region": region, "source_box": box}
}
}
+ // Every phase hostclaim lets run on no box (validateRelocating).
+ noBoxPhases := []string{"starting", "releasing", "pinning"}
+ inPhase := func(phase string) func(*hostclaim.Claim) {
+ return func(c *hostclaim.Claim) { c.Op.Phase, c.Op.Exec = phase, "none" }
+ }
t.Run("before the flip", func(t *testing.T) {
fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-wait-src")
readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted)
@@ -1947,9 +2002,12 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) {
if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating || a.ID != mv.ID {
t.Fatalf("flat view = %s", out)
}
- a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK)
- if a.State != provision.MoveRelocating || waitingFor(a) != "ie" {
- t.Fatalf("detail with no box in ie = %s, want waiting_for_box ie", out)
+ for _, phase := range noBoxPhases {
+ rewriteClaim(t, fx, "acme.go.tito.io", inPhase(phase))
+ a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK)
+ if a.State != provision.MoveRelocating || waitingFor(a) != "ie" {
+ t.Fatalf("detail in %s/none with no box in ie = %s, want waiting_for_box ie", phase, out)
+ }
}
// A live enrolled box joins ie: nothing is waiting.
armMoveFleet(t, fx, ieBox("ie-box2"))
@@ -1981,9 +2039,12 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) {
if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating {
t.Fatalf("flat view of a converted landing = %s, want relocating", out)
}
- a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK)
- if a.State != provision.MoveRelocating || waitingFor(a) != "uk" || (a.Detail != nil && a.Detail.Move.State != provision.MoveCommitted) {
- t.Fatalf("detail of a converted landing with no box in uk = %s, want waiting_for_box uk", out)
+ for _, phase := range noBoxPhases {
+ rewriteClaim(t, fx, "acme.go.tito.io", inPhase(phase))
+ a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK)
+ if a.State != provision.MoveRelocating || waitingFor(a) != "uk" || (a.Detail != nil && a.Detail.Move.State != provision.MoveCommitted) {
+ t.Fatalf("detail of a converted landing in %s/none with no box in uk = %s, want waiting_for_box uk", phase, out)
+ }
}
// A live enrolled box joins uk: still relocating, nothing waiting.
armMoveFleet(t, fx, ukBox("uk-box2"))
@@ -1991,7 +2052,7 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) {
if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" {
t.Fatalf("detail with a live box in uk = %s", out)
}
- // Past pinning the relocation has its box: nothing waits.
+ // A phase that runs on a box is not waiting for one.
putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}})
rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) {
c.Op.Phase, c.Op.Exec, c.Op.Intent["target_box"] = "installing", "uk-box2", "uk-box2"
@@ -1999,6 +2060,13 @@ func TestMoveDetailShowsWaitingForBox(t *testing.T) {
if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" {
t.Fatalf("detail of a relocation that is installing = %s", out)
}
+ rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) {
+ c.Op.Phase, c.Op.Exec = "releasing", "uk-box"
+ delete(c.Op.Intent, "target_box")
+ })
+ if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" {
+ t.Fatalf("detail of a relocation releasing on its source box = %s", out)
+ }
})
}