| index 00bff4ce8..93c103496 100644 |
| --- a/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md |
| +++ b/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md |
| @@ -1,7 +1,9 @@ |
| # Scanner host: getting object-storage scanning running on Carloku and Tito |
| |
| -**Status:** draft 2. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`, |
| -five findings, all accepted; marked **(codex N)**). |
| +**Status:** draft 3. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`, |
| +findings 1–5, all accepted) and review 2 (`CODEX-FINDINGS-SCANNER-HOST-R2.md`, |
| +findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3). Accepted |
| +findings are marked **(codex N)**. |
| **Builds on:** `2026-09-01-object-storage-virus-scanning-design.md` (the sweep, |
| verdict tags, enforcement) and `2026-09-02-scan-on-demand-design.md` §9 (clamd |
| off the console box, reachable over TCP). |
| @@ -35,7 +37,7 @@ being signed clean. **(codex 4)** |
| | D5 | Both pieces are platform modules | Carloku consumes the platform the way a self-hoster does. Hand-written root resources would be a workaround and would duplicate clamd's setup per root. | |
| | D6 | Carloku first, then Tito | Tito is a third party, and its console was only unpinned on 2026-10-03 with #201 phase 2 pending. Prove it on our own flagship first. | |
| | D7 | Scope ends at "engine wired, switched on, no member granted" | Rollout plan steps 7–8: a member grant follows a member's request and the disclosure, never an operator's speculation. | |
| -| D8 | An object clamd only partly scanned is recorded **unscanned**, never clean or infected | A signed clean verdict over bytes nobody read is the failure the verdict design exists to prevent; flagging large legitimate archives infected would make enforce block them. Unscanned fits the existing audit arithmetic (`unscanned = skipped + denied + failed`). **(Paul, 2026-10-04, on codex 4)** | |
| +| D8 | An object clamd only partly scanned, with no malware signature matched, is recorded **unscanned**, never clean, and never infected on the strength of the limit alone | A signed clean verdict over bytes nobody read is the failure the verdict design exists to prevent; flagging large legitimate archives infected would make enforce block them. Unscanned fits the existing audit arithmetic (`unscanned = skipped + denied + failed`). A real signature match is positive evidence and stays **infected** even when the scan was partial (§4.3). **(Paul, 2026-10-04, on codex 4)** | |
| |
| **Out of scope:** the unmerged scan-on-demand API (`worktree-store-scan-on-demand`), |
| TLS to clamd, alerting on signature age, GuardDuty, per-region scanning. If |
| @@ -179,14 +181,31 @@ actions on `arn:aws:s3:::carlos-store-tito-*/*` and |
| `internal/clamd/clamd.go` `parseReply` (`:273`) maps any `FOUND` to |
| `StatusInfected`. With `AlertExceedsMax yes`, a limit hit arrives as |
| `stream: Heuristics.Limits.Exceeded.MaxScanSize FOUND` (likewise `.MaxFileSize`, |
| -`.MaxFiles`, `.MaxRecursion`, `.MaxScanTime`). Add one case **before** the |
| -`FOUND` case: a `FOUND` reply whose signature begins |
| -`Heuristics.Limits.Exceeded` becomes `StatusSizeExceeded`, keeping the raw reply. |
| -`objstore/clamav.go:374-383` already maps `StatusSizeExceeded` to |
| -`VerdictSkipped`, so it lands in `skipped` and `unscanned`, never in `clean` or |
| -`infected`, and nothing else changes. |
| - |
| -Tests in `clamd_test.go`: each of the five limit names maps to size-exceeded; |
| +`.MaxFiles`, `.MaxRecursion`, `.MaxScanTime`). Add two cases **before** the |
| +`FOUND` case, both keeping the raw reply: |
| + |
| +- signature exactly `Heuristics.Limits.Exceeded.MaxScanTime` becomes |
| + `StatusError`. A timeout depends on load, not on the bytes, so it must be |
| + retried. `verdictFor` maps `StatusError` to `VerdictFailed`, which |
| + `clamav.go:249-251` re-issues every sweep rather than carrying forward. |
| + **(codex 7)** |
| +- any other signature beginning `Heuristics.Limits.Exceeded` becomes |
| + `StatusSizeExceeded`. These limits are deterministic for given bytes and |
| + config. `clamav.go:374-383` maps it to `VerdictSkipped`, so it lands in |
| + `skipped` and `unscanned`, never `clean` or `infected`, and like an oversize |
| + object it is carried forward until the bytes change. |
| + |
| +**Declined: codex 6** (a malware match hiding an earlier limit hit). With |
| +clamd's default `HeuristicScanPrecedence no`, an archive that trips a limit and |
| +also contains a real match answers with the real match, and the client records |
| +it infected. That is correct under D8: the object is known to contain malware, |
| +whatever else went unread. Setting `HeuristicScanPrecedence yes` to surface the |
| +limit instead would turn that known infection into `unscanned`, which is worse. |
| +When nothing matches, clamd still answers with the limit heuristic at the end, |
| +so the case D8 exists for is covered. §7 step 2 tests both. |
| + |
| +Tests in `clamd_test.go`: `MaxScanTime` maps to error; each of the other four |
| +limit names maps to size-exceeded; |
| `Eicar-Signature FOUND` still maps to infected; a signature that merely |
| contains the phrase elsewhere (`Win.Heuristics.Limits.Exceeded.Fake`) still |
| maps to infected, so the match is a prefix on the signature, not a substring of |
| @@ -230,10 +249,18 @@ before they are wired** (§6.2). A console without it, wired to a clamd with |
| |
| ### 6.2 The console's environment |
| |
| -**Precondition:** the console runs v0.23.0 or a later main build (§4.3). On |
| -Carloku that is `carlos ship` plus `carlos promote --app console` in |
| -direct-bucket mode, boxes untouched (the console app is independent of the box |
| -binary). On Tito it is the same, from Tito's bucket. |
| +**Precondition, checked before any variable is written:** the console |
| +**process** runs a build containing §4.3. Promoting only moves the channel |
| +pointer (`internal/release/release.go:718`); adoption is separate and can defer |
| +(`adopt.go:956`); and `x-carlos-version` comes from the edge's route state |
| +(`internal/edge/edge.go:2192`), not from the running process. So neither a |
| +promote nor the header proves it. The check is the one `roll-console.sh` |
| +already makes (`scripts/roll-console.sh:93-97`): over SSM, `readlink |
| +/proc/$(systemctl show -p MainPID --value console)/exe` must resolve to |
| +`versions/<sha>/console` for the exact SHA built. Roll with `roll-console.sh` |
| +(it ships, promotes, adopts, restarts and runs that check); if the check fails, |
| +**stop**: nothing is wired. Boxes are untouched. On Tito, the same from Tito's |
| +bucket. **(codex 8)** |
| |
| First confirm over SSM, read-only, which file `console.service` loads. Then, |
| after a dated backup of that file: |
| @@ -244,7 +271,8 @@ CARLOS_CLAMD_MAX_BYTES=<module.scanner.stream_max_bytes> |
| CARLOS_SCAN_TAG_KEY=<from /carlos/scan-tag-key> |
| ``` |
| |
| -`systemctl restart console`, then `curl -sI` the console for `x-carlos-version`. |
| +`systemctl restart console`, then repeat the `/proc/<pid>/exe` check (same SHA, |
| +new PID), and `carlos system antivirus status` answers with the engine wired. |
| If it fails to start: restore the backup, `systemctl reset-failed console && |
| systemctl restart console` (a start-limited unit ignores try-restart; seen |
| 2026-09-11). |
| @@ -268,10 +296,12 @@ On a throwaway app we own, with a plaintext store: |
| `grant`, `sweep`. **Pass:** `scan status` shows a completed sweep, one clean, |
| one infected; both objects' tags verify under this deployment's key and carry |
| the object's current ETag. |
| -2. Upload a small archive that expands past `MaxScanSize` (a zip of a few |
| - hundred MB of zeros is a few hundred KB) and `sweep`. **Pass:** it counts as |
| - `skipped` and `unscanned`, never `clean` or `infected`; its tag carries no |
| - clean verdict. **(codex 4)** |
| +2. Upload two small archives and `sweep`: |
| + - one that expands past `MaxScanSize` (a zip of a few hundred MB of zeros is |
| + a few hundred KB). **Pass:** `skipped` and `unscanned`, never `clean` or |
| + `infected`; its tag carries no clean verdict. **(codex 4)** |
| + - one holding the same oversized member followed by EICAR. **Pass:** |
| + `infected` (D8: a real match stands). If it reads `clean`, stop. |
| 3. Overwrite the clean object, `sweep`. **Pass:** it is re-scanned, not carried |
| forward. **If it is carried forward, stop the rollout:** a verdict that |
| outlives its bytes is worse than none. |
| @@ -312,3 +342,7 @@ for the pair. |
| - A member asking for scanning on a client-encrypted store: no change here, the |
| existing `--client-encrypted` path handles it. |
| - Store buckets becoming per-region: see §2, out of scope. |
| +- Raising clamd's limits later: objects already recorded `skipped` under the |
| + old limits are carried forward until their bytes change, as oversize objects |
| + are today. A forced re-sweep of skipped objects is a follow-up if anyone |
| + raises a limit, not part of this rollout. |