carlos / platform Public

host: advise a grant the live release does not declare

ungrantedAdvice's mirror. A token this box has GRANTED that the live
manifest never declares materializes nothing, so it is not a hole — it is a
registry row that reads like one, which is what an operator looks at during
an incident. Two ways in: a grant staged for a release nobody shipped, and a
release that DROPPED a capability it used to declare, which removes the
drop-in and leaves the grant behind.

One line per pass per token, naming the token, that it materializes nothing,
and both fixes. Silent for a leave-alone route: it reads the plan's Set,
which the caller fills only from a manifest it actually read, so a bucket
hiccup cannot advise revoking a grant the running release is using.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 05fa2be3e66548d81e3eb531e8e2e2820261d66d parent 5e15c89
2 files changed, +142 −0
  • internal/host/caps_pass.go +43 −0
  • internal/host/caps_pass_test.go +99 −0
diff --git a/internal/host/caps_pass.go b/internal/host/caps_pass.go
index e932ce3eb..140137408 100644
--- a/internal/host/caps_pass.go
+++ b/internal/host/caps_pass.go
@@ -201,6 +201,7 @@ func buildPlans(routes []registry.Route, load func(rt registry.Route) (*release.
}
plans = append(plans, p)
advice = append(advice, ungrantedAdvice(rt, p.Set, g)...)
+ advice = append(advice, undeclaredGrantAdvice(rt, p.Set, g)...)
advice = append(advice, dataAdvice(rt, p.Set)...)
}
return plans, advice
@@ -233,6 +234,48 @@ func ungrantedAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string {
return out
}
+// undeclaredGrantAdvice is ungrantedAdvice's mirror, and the half that was
+// missing (2026-08-03 review, M5): a token this box HAS granted that the live
+// release does not declare.
+//
+// Not a refusal, and not a security hole — an operator-consented capability
+// materializes only where the manifest asks for it, so an undeclared grant
+// writes nothing and reaches nothing. What it IS is a lie in the registry: a
+// row that reads "this box has handed :25 to keymail" while nothing on the box
+// binds :25, which is exactly the state an operator looks at during an
+// incident. It also names the two ways it happens — a grant staged for a
+// release that was never shipped, and a release that DROPPED a capability it
+// used to declare, which removes the drop-in and leaves the grant behind.
+//
+// Silent for a leave-alone route: this reads p.Set, which the caller only
+// fills from a manifest it actually read, so a bucket hiccup cannot make this
+// print "granted but not declared" about a release nobody could see. Sorted
+// by port for the same reason ungrantedAdvice sorts — an hourly log line whose
+// order shuffles per pass reads as churn.
+func undeclaredGrantAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string {
+ declared := make(map[int]bool, len(s.Ports))
+ for _, p := range s.Ports {
+ declared[p.Port] = true
+ }
+ ports := make([]int, 0, len(g.Ports))
+ for port := range g.Ports {
+ if !declared[port] {
+ ports = append(ports, port)
+ }
+ }
+ sort.Ints(ports)
+ var out []string
+ for _, port := range ports {
+ out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — nothing is materialized for it; revoke it with `carlos route -host %s -revoke %s`, or ship a release that declares it",
+ rt.Host, caps.PortToken(port), rt.Host, caps.PortToken(port)))
+ }
+ if g.HostRole && !s.HostRole {
+ out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps DynamicUser and reaches nothing of the box's instance role; revoke it with `carlos route -host %s -revoke %s`, or ship a release that declares it",
+ rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken))
+ }
+ return out
+}
+
// dataAdvice is what the DBPath stamp cannot say by stamping (spec §6).
//
// Route.DBPath is single-valued and data.dbs is a list, so only the first
diff --git a/internal/host/caps_pass_test.go b/internal/host/caps_pass_test.go
index afa428bba..dfa7a62d6 100644
--- a/internal/host/caps_pass_test.go
+++ b/internal/host/caps_pass_test.go
@@ -474,6 +474,105 @@ func TestBuildPlansAdvisesUngrantedDeclaredCapabilities(t *testing.T) {
}
}
+// The mirror of the advisory above (2026-08-03 review, M5): a token this box
+// HAS granted that the live release does not declare. It materializes nothing,
+// so it is not a hole — it is a registry row that reads like a hole, which is
+// what an operator is looking at during an incident. Two ways in: a grant
+// staged for a release nobody shipped, and a release that DROPPED a capability
+// it used to declare (converge removes the drop-in; the grant stays behind).
+func TestBuildPlansAdvisesGrantedButUndeclaredCapabilities(t *testing.T) {
+ cases := []struct {
+ name string
+ set *caps.Set
+ grants string
+ want []string
+ wantNot []string
+ }{
+ {
+ name: "granted port the live release never declares",
+ set: &caps.Set{V: 1, Daemon: true},
+ grants: "port:25",
+ want: []string{"is granted port:25", "the live release does not declare it",
+ "carlos route -host keymail.dev -revoke port:25", "ship a release that declares it"},
+ },
+ {
+ name: "granted host-role the live release never declares",
+ set: &caps.Set{V: 1, Daemon: true},
+ grants: "host-role",
+ want: []string{"is granted host-role", "keeps DynamicUser",
+ "carlos route -host keymail.dev -revoke host-role"},
+ },
+ {
+ name: "declared and granted says nothing",
+ set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}, HostRole: true},
+ grants: "host-role,port:25",
+ want: nil,
+ },
+ {
+ name: "one line per token, in port order, host-role last",
+ set: &caps.Set{V: 1},
+ grants: "host-role,port:25,port:465",
+ want: []string{"-revoke port:25", "-revoke port:465", "-revoke host-role"},
+ },
+ {
+ name: "a declared-but-ungranted capability is the OTHER advisory, not this one",
+ set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}},
+ want: []string{"has not granted it"},
+ // The two lines are opposites and must never both fire for one
+ // token: "granted but not declared" about a token nobody granted
+ // would be nonsense an operator would chase.
+ wantNot: []string{"is granted port:25"},
+ },
+ {
+ name: "a partly-declared grant advises only the undeclared half",
+ set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}},
+ grants: "host-role,port:25",
+ want: []string{"is granted host-role"},
+ // port:25 is declared AND granted — the healthy case, silent.
+ wantNot: []string{"port:25 but", "is granted port:25"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ rt := unitRoute("keymail.dev", "keymail")
+ rt.Caps = c.grants
+ _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", c.set)}, nil))
+ joined := strings.Join(advice, "\n")
+ for _, w := range c.want {
+ if !strings.Contains(joined, w) {
+ t.Errorf("advice =\n%s\nwant a line containing %q", joined, w)
+ }
+ }
+ for _, w := range c.wantNot {
+ if strings.Contains(joined, w) {
+ t.Errorf("advice =\n%s\nwant NOTHING containing %q", joined, w)
+ }
+ }
+ if len(c.want) == 0 && len(c.wantNot) == 0 && len(advice) != 0 {
+ t.Errorf("advice = %v, want none", advice)
+ }
+ })
+ }
+}
+
+// A route whose live manifest could not be read gets NO grant advisory at
+// all — in either direction. The leave-alone state means this pass does not
+// know what the release declares, and "granted but not declared" printed from
+// an absent manifest would send an operator to revoke a grant the running
+// release is using right now.
+func TestBuildPlansSaysNothingAboutGrantsWithNoManifest(t *testing.T) {
+ rt := unitRoute("keymail.dev", "keymail")
+ rt.Caps = "host-role,port:25"
+ plans, advice := buildPlans([]registry.Route{rt},
+ loader(map[string]*release.Manifest{}, nil)) // (nil, nil): no live version
+ if len(plans) != 1 || plans[0].Set != nil {
+ t.Fatalf("plans = %+v, want one leave-alone plan with a nil Set", plans)
+ }
+ if len(advice) != 0 {
+ t.Fatalf("advice = %v, want none — nothing is known about what the release declares", advice)
+ }
+}
+
// An exec-backed route whose release declares capabilities materializes
// nothing (release.CapabilityRoute excludes it) and is told so — the guided
// path through the §5 migration, and the line an operator sees during the