cli: store status says whether scanning is on
Once scanning exists, a store status that said nothing about it is a half-truth. Three states, not a boolean. "Requested" is a member waiting on an operator, "enabled" is an engine looking at objects, and "enforcing" is infected objects actually being blocked. Collapsing those would let someone read "scanning: on" and believe reads were denied when they were only recorded — and the gap between those two is the entire value of the enforcement switch. The reply key is purely additive, which is the only safe move: release.PurgeApp parses this same object with its own struct and freezes the grant and provisioned names. A key it does not know is ignored; a renamed one it does know is a purge that stops working. The test asserts those three names survive alongside the new one. A store that never opted in prints exactly what it printed before, and so does one whose scanning block was emptied by a revoke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
5 files changed,
+180
−0
apimode.go+4 −0internal/console/apiscan_test.go+45 −0internal/console/apistore.go+8 −0store.go+29 −0storescan_test.go+94 −0
diff --git a/apimode.go b/apimode.go| index 98e9e7f33..4d247f794 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1554,6 +1554,10 @@ type apiStoreReply struct { |
| EnvNames []string `json:"env_names"` |
| PendingFinish bool `json:"pending_finish"` |
| MissingVars []string `json:"missing_vars"` |
| + // Scanning is nil on every store that never opted in — which is all of |
| + // them by default — so an older console that does not send it renders |
| + // exactly as it always did. |
| + Scanning *apiScanBlock `json:"scanning,omitempty"` |
| } |
| // ---- store scanning (scan.go) ---------------------------------------- |
diff --git a/internal/console/apiscan_test.go b/internal/console/apiscan_test.go| index a787ceede..4d5ee20f1 100644 |
| --- a/internal/console/apiscan_test.go |
| +++ b/internal/console/apiscan_test.go |
| @@ -246,3 +246,48 @@ func TestScanStatusCarriesProvenance(t *testing.T) { |
| t.Fatal("status carries no signature-database version") |
| } |
| } |
| + |
| +// `carlos store status` carries the scan block once it exists. Additive only: |
| +// release.PurgeApp parses this same object with its own struct and freezes the |
| +// grant and provisioned names, so a new key is safe and a renamed one is a |
| +// purge that stops working. |
| +func TestStoreStatusCarriesScanningBlock(t *testing.T) { |
| + fx, _, token := scanArmed(t, "code-store-status-scan") |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("store status = %d %v", status, out) |
| + } |
| + if _, present := out["scanning"]; !present { |
| + t.Fatal("store status carries no scanning key at all") |
| + } |
| + if out["scanning"] != nil { |
| + t.Fatalf("a store nobody requested scanning for reported %v", out["scanning"]) |
| + } |
| + |
| + if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); s != http.StatusOK { |
| + t.Fatalf("scan request = %d %v", s, o) |
| + } |
| + if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| + t.Fatalf("scan grant = %d %v", s, o) |
| + } |
| + |
| + status, out = getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("store status = %d %v", status, out) |
| + } |
| + sc, ok := out["scanning"].(map[string]any) |
| + if !ok { |
| + t.Fatalf("scanning = %#v, want an object", out["scanning"]) |
| + } |
| + if _, ok := sc["enabled"]; !ok { |
| + t.Fatalf("scanning block does not report the enabled engine: %v", sc) |
| + } |
| + // The grant and provisioned names PurgeApp parses must still be there and |
| + // still be spelled the same. |
| + for _, frozen := range []string{"grant", "provisioned", "declared"} { |
| + if _, present := out[frozen]; !present { |
| + t.Fatalf("the %q key PurgeApp parses is gone from the reply", frozen) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index feae53e4f..e8e85c1de 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -139,6 +139,14 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| // two credentials open this bucket. |
| "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "", |
| "missing_vars": missing, |
| + // Additive, and additive is the ONLY safe move here: release.PurgeApp |
| + // parses this same object with its own struct and freezes the `grant` |
| + // and `provisioned` names. A new key it does not know about is |
| + // ignored; a renamed one it does know about is a purge that stops |
| + // working. Once scanning exists, a status that said nothing about it |
| + // would be a half-truth — but it is nil on every store that never |
| + // opted in, which is all of them by default. |
| + "scanning": rec.Scanning, |
| }, nil |
| } |
diff --git a/store.go b/store.go| index 2b94a67a2..1aa2fdea5 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -365,6 +365,14 @@ func printStoreStatus(w io.Writer, res apiStoreReply) { |
| if res.PendingFinish { |
| warnf(w, "a rotation is pending finish — the previous key is still live: carlos store rotate --finish --app %s", res.App) |
| } |
| + // Scanning, once it exists. Deliberately three distinct states rather |
| + // than a boolean: "requested" is a member waiting on an operator, |
| + // "enabled" is an engine looking at objects, and "enforcing" is infected |
| + // objects actually being blocked. Collapsing those would let someone read |
| + // "scanning: on" and believe reads were denied when they were only |
| + // recorded. |
| + printStoreScanLine(w, res) |
| + |
| if len(res.MissingVars) > 0 { |
| // Named as drift, not as a fresh failure: this is the same state a |
| // healthy store passes through for a few seconds after every grant |
| @@ -376,3 +384,24 @@ func printStoreStatus(w io.Writer, res apiStoreReply) { |
| "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), res.App) |
| } |
| } |
| + |
| +// printStoreScanLine is store status's one-line summary of the scan family. |
| +// The detail lives behind `carlos store scan status`; this exists so a member |
| +// reading store status is never left believing nothing is looking at their |
| +// objects when something is, or the reverse. |
| +func printStoreScanLine(w io.Writer, res apiStoreReply) { |
| + sc := res.Scanning |
| + if sc == nil || (sc.Requested == nil && sc.Enabled == nil) { |
| + return |
| + } |
| + switch { |
| + case sc.Enabled != nil && sc.Enforce != nil: |
| + fmt.Fprintf(w, " scanning: %s, enforcing — infected objects cannot be read by this app\n", sc.Enabled.Engine) |
| + case sc.Enabled != nil: |
| + fmt.Fprintf(w, " scanning: %s, recording only — infected objects are NOT blocked\n", sc.Enabled.Engine) |
| + case sc.Requested != nil && sc.Requested.Encrypted: |
| + mutedf(w, " scanning: requested, attested client-encrypted — no coverage will be claimed") |
| + default: |
| + mutedf(w, " scanning: requested, no engine enabled yet (carlos store scan status --app %s)", res.App) |
| + } |
| +} |
diff --git a/storescan_test.go b/storescan_test.go| new file mode 100644 |
| index 000000000..8221b90fc |
| --- /dev/null |
| +++ b/storescan_test.go |
| @@ -0,0 +1,94 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func storeReplyWithScanning(sc *apiScanBlock) apiStoreReply { |
| + at := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) |
| + return apiStoreReply{ |
| + App: "titogo", |
| + Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE", By: "m@example.com", At: at}, |
| + Provisioned: &apiStoreProvisioned{ |
| + Bucket: "carlos-bab-titogo", Region: "eu-west-1", |
| + IAMUser: "carlos-bab-titogo", KeyID: "AKIA1", At: at, |
| + }, |
| + EnvNames: []string{"CARLOS_STORE_BUCKET"}, |
| + MissingVars: []string{}, |
| + Scanning: sc, |
| + } |
| +} |
| + |
| +// store status must mention scanning once it exists, and must not imply |
| +// coverage — or enforcement — for a store that only has it recorded. |
| +func TestPrintStoreStatusShowsScanState(t *testing.T) { |
| + at := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) |
| + |
| + // Enabled but NOT enforcing: the line must say infected objects are not |
| + // blocked. Someone reading "scanning: clamav" and assuming otherwise is |
| + // the exact misunderstanding this line exists to prevent. |
| + var b bytes.Buffer |
| + printStoreStatus(&b, storeReplyWithScanning(&apiScanBlock{ |
| + Requested: &apiScanRequested{At: at, By: "m@example.com"}, |
| + Enabled: &apiScanEnabled{Engine: "clamav", At: at, By: "op@example.com"}, |
| + })) |
| + if out := b.String(); !strings.Contains(out, "recording only") || !strings.Contains(out, "NOT blocked") { |
| + t.Fatalf("an enabled-but-not-enforcing store did not say so:\n%s", out) |
| + } |
| + |
| + // Enforcing. |
| + var b2 bytes.Buffer |
| + printStoreStatus(&b2, storeReplyWithScanning(&apiScanBlock{ |
| + Requested: &apiScanRequested{At: at, By: "m@example.com"}, |
| + Enabled: &apiScanEnabled{Engine: "clamav", At: at, By: "op@example.com"}, |
| + Enforce: &apiScanEnforce{Key: "carlos-scan", Value: "infected"}, |
| + })) |
| + if out := b2.String(); !strings.Contains(out, "enforcing") { |
| + t.Fatalf("an enforcing store did not say so:\n%s", out) |
| + } |
| + |
| + // Requested only: a member waiting on an operator, with no claim of |
| + // anything having looked at an object. |
| + var b3 bytes.Buffer |
| + printStoreStatus(&b3, storeReplyWithScanning(&apiScanBlock{ |
| + Requested: &apiScanRequested{At: at, By: "m@example.com"}, |
| + })) |
| + out3 := b3.String() |
| + if !strings.Contains(out3, "no engine enabled yet") { |
| + t.Fatalf("a requested-only store did not say it is waiting:\n%s", out3) |
| + } |
| + if strings.Contains(out3, "enforcing") { |
| + t.Fatalf("a requested-only store implied enforcement:\n%s", out3) |
| + } |
| + |
| + // Requested and attested encrypted. |
| + var b4 bytes.Buffer |
| + printStoreStatus(&b4, storeReplyWithScanning(&apiScanBlock{ |
| + Requested: &apiScanRequested{At: at, By: "m@example.com", Encrypted: true}, |
| + })) |
| + if out := b4.String(); !strings.Contains(out, "no coverage will be claimed") { |
| + t.Fatalf("an encrypted store's status implied coverage:\n%s", out) |
| + } |
| +} |
| + |
| +// A store with no scanning block prints exactly what it prints today — every |
| +// existing deployment sees no change at all. |
| +func TestPrintStoreStatusUnchangedWithoutScanning(t *testing.T) { |
| + var b bytes.Buffer |
| + printStoreStatus(&b, storeReplyWithScanning(nil)) |
| + if strings.Contains(b.String(), "scanning") { |
| + t.Fatalf("a store that never opted in grew a scanning line:\n%s", b.String()) |
| + } |
| + |
| + // An empty block is the same case: a record can carry `scanning: {}` after |
| + // a revoke cleared both halves, and that is not a state worth a line. |
| + var b2 bytes.Buffer |
| + printStoreStatus(&b2, storeReplyWithScanning(&apiScanBlock{})) |
| + if strings.Contains(b2.String(), "scanning") { |
| + t.Fatalf("an emptied scanning block still printed a line:\n%s", b2.String()) |
| + } |
| +} |