carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

Scanner host: getting object-storage scanning running on Carloku and Tito

Status: draft 6. Closes Astra review 1 (CODEX-FINDINGS-SCANNER-HOST.md, findings 1–5, all accepted), review 2 (CODEX-FINDINGS-SCANNER-HOST-R2.md, findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3) and review 3 (CODEX-FINDINGS-SCANNER-HOST-R3.md, finding 9, accepted and tested against a real engine, §3.4) and review 4 (CODEX-FINDINGS-SCANNER-HOST-R4.md, findings 10 and 11, both accepted; 10 reshaped how the box listens, §3.2) and review 5 (CODEX-FINDINGS-SCANNER-HOST-R5.md, finding 12, accepted). Review 5 found the gating design sound at spec level once 12 is fixed, and left restart timing, connection cut-off and EICAR transport to the real-box acceptance checks in §3.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). Supersedes: steps 2 and 4 of ../plans/2026-09-01-carloku-scanning-rollout.md (clamd on the console box, CARLOS_STORE_SCANNER as the switch). Its steps 5–8 and its watch list still stand.

---

1. Where things stand

The code is shipped and live. Both consoles run builds that carry the sweep, carlos store scan request|status|grant|revoke|sweep|enforce and carlos system antivirus status|enable|disable (Carloku 7edd4b33, Tito fe2088e3). CARLOS_CLAMD_ADDR already takes host:port as well as a socket path (2eaf2aae, in every release since v0.18.0).

What neither deployment has is a clamd to talk to, permission for the console to read and tag store objects, or a scan tag key. This spec supplies those three things, plus one small client change (§4.3) that stops a partly scanned archive being signed clean. (codex 4)

2. Decisions

| # | Decision | Why | |---|---|---| | D1 | clamd runs on its own box, not the console box | Its ~1.5 GB of resident signatures (about 3 GB during a reload) leave the control plane. Carloku's console box is a t4g.large that also serves IE apps. | | D2 | One box per deployment, in the home region only | Only the console talks to clamd, and every store bucket is in the one region CARLOS_STORE_REGION names (cmd/console/main.go:546-585). A box elsewhere would add cross-region transfer, not remove it. | | D3 | Console to clamd is plain TCP inside the VPC, closed by security group | clamd has no TLS and no authentication. Same-VPC, single-source security group is the same trust model as the console's own S3 traffic. TLS (stunnel or a dialler change) or Tailscale are later options, not v1. (Paul, 2026-10-04: "A is fine for now") | | D4 | The console keeps fetching from S3 and streams to clamd | Scan-on-demand §9.4: tenant-data credentials stay on one box. The scanner box gets no S3 or parameter grants. | | 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, 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 store buckets ever become per-region, scanning would carry tenant bytes out of their region; the answer then is a regional scanner that fetches for itself, which reopens D4. That is a new decision, not more boxes.

3. The scanner-host module (infra/modules/scanner-host)

3.1 Resources

  • aws_instance: ami_id from the caller (the caller passes module.<host>.ami_id, as Tito's svc-pdf.tf does), instance_type default t4g.medium, 20 GiB encrypted gp3 root, IMDSv2 required (http_tokens = "required"), user_data_replace_on_change = false, lifecycle { ignore_changes = [ami, user_data] }, so a newer image or an edited script never silently replaces the box; changing either (including stream_max_bytes) is an explicit -replace. Optional subnet_id; null means the default subnet, as region-host does, and a precondition then requires the client security group's VPC to be the default VPC.
  • aws_security_group: ingress TCP 3310 from client_security_group_id only. No other ingress, no SSH. Egress open, for freshclam and apt. It lands in the client security group's VPC (vpc_id from a data source on that group), so the two can never end up in different VPCs.
  • IAM role and instance profile: AmazonSSMManagedInstanceCore attached, for Session Manager. That managed policy grants ssm:GetParameter* on *, which on its own would let the box read /carlos/scan-tag-key and /carlos/release-key. So the role also carries an explicit Deny on ssm:GetParameter, GetParameters, GetParameterHistory and GetParametersByPath on *, the same shape as region-host's host_ssm_parameter_scope (main.tf:418-450) with no allowed parameter at all. Session Manager's channel actions are untouched. No bucket, no KMS. The box cannot fetch tenant data or the key even if clamd were compromised. (codex 1)

3.2 How the box listens: the invariant

TCP 3310 is open only while a self-test has passed against the clamd process that is running now. Not "the boot script checked once": a failed test, a clamd restart, or a ClamAV package upgrade closes the port until the test passes again. The console's own checks cannot stand in for this, because antivirus status and enable only ask clamd for VERSION (internal/objstore/clamav.go:108, internal/console/apisystem.go:85,133), and an engine that signs truncated scans clean answers VERSION perfectly well. (codex 10)

So clamd itself never listens on TCP. Three units:

  • clamav-daemon, Debian's own unit and socket activation, untouched except a drop-in for MemoryMax (variable, default 3G), MemoryHigh at about 85% of it, and Restart=on-failure. It listens on the unix socket /run/clamav/clamd.ctl only; clamd.conf has no TCPSocket and no TCPAddr. That is why the earlier replacement unit is gone: socket activation handing clamd only the unix socket is now exactly what is wanted. (Finding 2's problem no longer arises.) A drop-in Wants=scanner-tcp.socket makes every start of clamd, including an automatic restart, ask for the gate below to be re-run.
  • scanner-selftest.service, Type=oneshot, RemainAfterExit=yes, BindsTo=clamav-daemon.service, After=clamav-daemon.service. Runs /usr/local/sbin/scanner-selftest against the unix socket. Being bound to clamd, it goes inactive the moment clamd stops or restarts.
  • scanner-tcp.socket and scanner-tcp.service: the socket listens on <private IPv4>:3310 (from IMDSv2, written into the unit at first boot; never 0.0.0.0) and hands connections to systemd-socket-proxyd /run/clamav/clamd.ctl, running as a dedicated user in group clamav. Both carry Requires=scanner-selftest.service, After= it and BindsTo= it. So the port cannot open before the test passes, and it closes, cutting connections in flight, when the test's unit goes inactive.

The socket unit sets DefaultDependencies=no. A socket unit is ordered Before=sockets.target by default, sockets.target comes before basic.target, and every service (the self-test included) comes after basic.target; a socket that must also come after the self-test closes that loop, and systemd breaks an ordering cycle by dropping a job, which could leave the port shut after a reboot. So the unit opts out, restores what it still needs by hand (After=sysinit.target, Conflicts=shutdown.target, Before=shutdown.target), and is started by WantedBy=multi-user.target and clamd's Wants=, never by sockets.target. (codex 12)

clamd.conf keys, each rewritten idempotently (clamd has no include directory):

- StreamMaxLength <stream_max_bytes / 1 MiB>M. Written in whole MiB, not bytes: Debian's debconf round trip strips an M and appends one, so a bare byte count would become a 25 TiB limit on the first configure (final review I1). clamav-daemon/debconf is also set false after install, so later upgrades leave clamd.conf alone. - AlertExceedsMax yes. With the default no, an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion is partly scanned and answered OK. With yes clamd answers Heuristics.Limits.Exceeded.<limit> FOUND, which §4.3 records as unscanned (D8). (codex 4) - MaxFileSize 100M and MaxScanSize 100M: the same value, and fixed in this release. AlertExceedsMax alone is not enough. ClamAV's zip extractor trims a member that expands past MaxFileSize and scans the trimmed copy without raising any limit (libclamav/unzip.c:222-226, 1.4.3). With the two limits equal, the trimmed copy is as large as the whole scan budget, so scanning it always trips MaxScanSize, which does alert. Tested, §3.4. (codex 9) It is a constant, not a variable, because the self-test's fixtures and expected answers are sized for 100 MiB; a configurable limit means deriving both from it, which is a follow-up (§11). (codex 11) - ConcurrentDatabaseReload from a variable, default yes.

3.3 user_data

A templated script (scanner-init.sh.tftpl), set -eu, each step logged to the serial console so aws ec2 get-console-output shows how far it got.

  1. SSM agent first. The AMI is the base Debian image region-host boots from (outputs.tf:26), not a configured host, and Debian ships no SSM agent. Install the arm64 amazon-ssm-agent .deb from the regional bucket and enable it, exactly as host-init.sh.tftpl does. With SSH closed this is the only way onto the box, so it goes before anything that can fail. A region variable feeds the URL. Also unattended-upgrades, as region hosts have. (codex 5)
  2. Install without starting. Write a policy-rc.d that exits 101, then apt-get install clamav-daemon clamav-freshclam clamdscan python3, then remove the policy-rc.d. Debian's postinst would otherwise start clamav-freshclam (clamav-freshclam.postinst.in:333) and the daemon before they are configured. (codex 3)
  3. Write clamd.conf's keys, the drop-in, the self-test script and the three units of §3.2. systemctl daemon-reload.
  4. First signatures. freshclam once in the foreground, with no clamav-freshclam service running (step 2 kept it stopped), so the two never contend for the lock or the update log. Refuse to continue unless main, daily and bytecode (.cvd or .cld) exist. Then enable and start clamav-freshclam (hourly checks by default). (codex 3)
  5. Enable and start clamav-daemon, scanner-selftest.service and scanner-tcp.socket. The ordering in §3.2 does the rest: clamd loads, the self-test runs, and only on a pass does the port open.
  6. Verify, and say so. systemctl is-active scanner-selftest.service scanner-tcp.socket both active; zVERSION answers on <private ip>:3310; ss -ltn shows 3310 bound to the private IP only. Write /var/lib/scanner-host/ready and print SCANNER-HOST READY plus the zVERSION line. Any failure prints SCANNER-HOST FAILED: <which check> and exits non-zero, with the port closed by construction.

The self-test (/usr/local/sbin/scanner-selftest) builds the §3.4 fixtures with python3 in a temp directory (members written from a temp file, so their zip headers are ordinary rather than zip64, which clamd would skip), streams each to the unix socket with clamdscan --stream, and requires the class of reply in the table's last column. An over-limit fixture answering OK is a failure. It re-proves the behaviour on whatever ClamAV version the image or a later upgrade installs, rather than trusting what 1.4.3 did.

Acceptance checks for the module, run on the first real box before any console is wired (§6.2), and recorded in the README:

  • kill clamd: 3310 closes at once, and reopens only after the self-test has re-run and passed;
  • force the self-test to fail (an environment override in a drop-in, used only for this check): clamd comes up, 3310 stays closed, the unit shows failed; remove the override and restart: it opens;
  • an INSTREAM scan of EICAR through <private ip>:3310 from the console box answers Eicar-Test-Signature FOUND, so the proxy carries the protocol;
  • reboot the box: it comes back with the self-test passed and 3310 open, systemd-analyze verify on the three units is clean, and journalctl -b has no ordering cycle line. First boot cannot show this, because basic.target and sockets.target are already reached by the time user_data runs. (codex 12)

3.4 What the limits do, measured

Run 2026-10-05 against Debian trixie's clamd (ClamAV 1.4.3), StreamMaxLength 25M, AlertExceedsMax yes, every fixture streamed with clamdscan --stream. Members are a repeating 16 KiB random block, distinct per member, so the clean cache cannot short-circuit them. "Limit" means a Heuristics.Limits.Exceeded.* reply.

| Fixture | MaxFileSize 25M, MaxScanSize 100M | Both 100M (this spec) | Self-test requires | |---|---|---|---| | small clean file | OK | OK | OK | | EICAR | infected | infected | infected | | zip, one member expanding to 500 MiB | OK (trimmed, silent) | Limit | Limit | | zip, a zip inside holding that member | OK (silent) | Limit | Limit | | zip, 30 distinct 20 MiB members | Limit | Limit | Limit | | gzip of 500 MiB | Limit | Limit | Limit | | tar.gz, 5 × 40 MiB | Limit | Limit | Limit | | bzip2, xz, tar.xz of the same | Limit | Limit | not in self-test | | zip, 500 MiB member then EICAR | infected | Limit | Limit | | zip, 60 MiB member then EICAR | infected | infected | infected | | zip, one 60 MiB member | OK (trimmed, silent) | OK (fully scanned) | OK |

The first column is finding 9, observed: three fixtures signed clean over content nobody read. The second is what this spec configures.

Known and not fixed: zip64 local headers. A zip member written with zip64 local headers is not extracted at all by 1.4.3 (local header - stream out of file), and EICAR inside one reads OK under every config. That is parser coverage, not a limit, and no clamd setting changes it. Encrypted archives are the same class. So a clean verdict means "clamd found nothing in what it could open", never "this object is safe", and the disclosure a member reads before opting in (rollout plan step 7) should say so in those words. Recorded in §11 as a follow-up; it does not block this rollout, because it is true of the engine wherever it runs.

3.5 Variables and outputs

| Variable | Default | Notes | |---|---|---| | name | — | Prefixes every resource | | ami_id | — | Debian arm64, from region-host | | client_security_group_id | — | The console box's group | | instance_type | t4g.medium | t4g.small only with concurrent_database_reload = false | | stream_max_bytes | 26214400 | Must equal the console's CARLOS_CLAMD_MAX_BYTES. Validation: a whole number of MiB, between 8 MiB and 100 MiB, so the self-test's fixtures (about 5 MB compressed) always fit the stream and a whole stream always fits the scan budget (codex 11) | | memory_max | "3G" | clamd's cgroup ceiling | | concurrent_database_reload | true | | | subnet_id | null | | | region | — | For the SSM agent download URL |

Validation: instance_type beginning t4g.small/t4g.micro/t4g.nano with concurrent_database_reload = true is refused with a message naming the reload spike.

Outputs: clamd_addr ("<private_ip>:3310"), instance_id, security_group_id, stream_max_bytes.

3.6 Replacement

A replaced box gets a new private IP, so CARLOS_CLAMD_ADDR must be updated by hand. Accepted: replacements are explicit (-replace) and the failure is loud (sweeps fail per tick, scan status and antivirus status say so, the console stays up). The README says this in its first section.

4. The console's read-and-tag grant

4.1 region-host (Carloku)

  • New variable store_scanning (bool, default false). A precondition refuses true with an empty store_bucket_prefix.
  • When true, a separate aws_iam_role_policy, ${iam_name}-store-scan: s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging on arn:aws:s3:::${store_bucket_prefix}*/*. These are exactly the calls the scanner makes (HeadObject is authorised by GetObject; ListObjectsV2 is already granted by host_store). Store buckets use SSE-S3, so no KMS grant.
  • Separate, so an opted-out deployment plans zero changes, and so the existing host_store comment ("No GetObject: the console provisions stores, it does not read tenant data") stays true. That comment gains one line pointing at store_scanning.
  • The store-user boundary is untouched. Apps still cannot tag their own objects.

4.2 Tito (object-store.tf)

A new statement StoreScan in host_ie_object_store with the same three actions on arn:aws:s3:::carlos-store-tito-*/* and arn:aws:s3:::titogo-uploads-665106592142/*. Tito keeps its own store policy (#201 settled this), so store_scanning is not used there.

4.3 The client change: limits exceeded is unscanned (D8)

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 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). When clamd reports a real match, the client records infected, whatever limit was also hit. 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 a known infection into unscanned, which is worse. When nothing matches, clamd answers with the limit heuristic, so the case D8 exists for is covered. Which of the two an archive gets depends on what clamd reaches before the budget runs out (§3.4: a 500 MiB member ahead of EICAR answers the limit, a 60 MiB member ahead of it answers EICAR). Both are honest and neither is clean. §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 the reply. The docs for carlos store scan status that explain skipped gain one sentence naming over-limit archives.

Because this is console code, both consoles roll to a build containing it before they are wired (§6.2). A console without it, wired to a clamd with AlertExceedsMax yes, would record over-limit archives as infected.

5. Release and consumption

  • The module change, scanner-host and the §4.3 client change land on amadan main through a branch and amadan branch merge -squash, then the GitHub mirror is pushed forward (fast-forward checked first), then v0.24.1 is cut per the README's "Releasing the platform" recipe and pushed to the mirror.
  • Carloku (carloku-infrastructure): move only host_ie's ?ref= to v0.24.1, set store_scanning = true, add module "scanner" with client_security_group_id = module.host_ie.security_group_id. The other three regions stay on v0.21.0. Every apply is -targeted (module.host_ie.aws_iam_role_policy.host_store_scan, module.scanner), and the plan is read for any user_data line before it runs, because a user_data change stops and starts a box. Any other host_ie diff the v0.21.0 to v0.24.1 bump shows is listed in the PR and not applied.
  • Tito (teamtito/carlos-infrastructure): a branch from the store-boundary-201 line (main cannot plan while the Canada box is only on region-ca), module "scanner" at ?ref=v0.24.1, the StoreScan statement. Targeted applies. Tito pins region-host to a SHA; that pin does not move.

6. Wiring the console

6.1 The scan tag key

  • 32 random bytes, hex. Generated once per deployment, never in git, tofu state or a transcript: generated and written in one pipeline.
  • Durable copy: SSM SecureString /carlos/scan-tag-key in the deployment's account (inside the /carlos/* path the box can already read). Losing it makes every stored verdict unverifiable.
  • CARLOS_SCAN_TAG_KEY_PREVIOUS stays unset; rotation exists and is not needed.

6.2 The console's environment

Two preconditions, both checked before any variable is written.

The scanner box is ready. Over SSM on the scanner instance: cloud-init status --wait reports done with no error; /var/lib/scanner-host/ready exists; systemctl is-active scanner-selftest.service scanner-tcp.socket says active twice; and the module's acceptance checks (§3.3) have been run on this box. Nothing the console can ask tells a tested engine from an untested one, so this is checked on the box, on each deployment, Tito included. (codex 10)

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:

CARLOS_CLAMD_ADDR=<module.scanner.clamd_addr>
CARLOS_CLAMD_MAX_BYTES=<module.scanner.stream_max_bytes>
CARLOS_SCAN_TAG_KEY=<from /carlos/scan-tag-key>

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).

CARLOS_STORE_SCANNER stays unset. It is deprecated, and the switch is the record (system/antivirus.json), so the restart wires the engine without turning it on.

6.3 Switching on

carlos system antivirus status: engine wired, clamd answering, signature date today or yesterday. Then carlos system antivirus enable. With no app granted the hourly pass runs and correctly does nothing. Sit in that state until §7 passes.

7. Proving it on Carloku

On a throwaway app we own, with a plaintext store:

  1. Upload one clean object and the EICAR test file. scan request --plaintext, 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 three archives from the §3.4 fixture set and sweep: - the zip with one member expanding to 500 MiB. Pass: skipped and unscanned, never clean or infected; its tag carries no clean verdict. (codex 4, codex 9) - the zip with that member followed by EICAR. Pass: skipped and unscanned (clamd stops at the limit before reaching EICAR). - the zip with a 60 MiB member followed by EICAR. Pass: infected (D8: a real match stands).

If any of the three reads clean, stop.

  1. 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.
  2. enforce on. Remove CARLOS_CLAMD_ADDR, restart. Pass: enforce off still works with no engine built. Restore the address, restart.
  3. Destroy the app. Pass: no scan.json remains under its prefix and the name is claimable at once.

Then stop. Scanning stays enabled; no member store is granted.

8. Tito

After §7 passes and after #201 phase 2 has landed (so two changes do not edit Tito's console environment on the same day): §5's Tito apply, the Tito console roll and the scanner-box readiness check (§6.2's two preconditions), §6 in Tito's account, antivirus status, enable. Tito's proof stops at status and enable. §7 exercises platform code identical to Carloku's, and a throwaway app in a customer's deployment needs the customer's say-so.

9. Docs

  • infra/modules/scanner-host/README.md: what it is, the three outputs the console needs, replacement (§3.6), the invariant (§3.2), the limits and why they are equal (§3.4), the acceptance checks (§3.3), and the sizing rule.
  • infra/modules/region-host/README.md: store_scanning.
  • A dated note at the top of ../plans/2026-09-01-carloku-scanning-rollout.md: steps 2 and 4 are superseded by this spec, CARLOS_STORE_SCANNER is deprecated.
  • An amadan discussion on carlos/platform tracking the rollout.

10. Cost

One t4g.medium plus 20 GiB gp3 per deployment: about $25 a month each, $50 for the pair.

11. What would change this design

  • t4g.medium turning out too small for the reload spike in practice: raise the instance type, or set concurrent_database_reload = false.
  • 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.
  • zip64 and encrypted archive members are not opened by clamd (§3.4), so a clean verdict never covers them. Follow-ups, each needing its own decision: say so in the scan request disclosure (user-facing copy, so copy review); consider AlertEncrypted yes with a mapping to unscanned, the way §4.3 maps limits.
  • Making the scan limit configurable: the self-test's fixture sizes and expected answers must then be derived from it (codex 11). Fixed at 100 MiB until someone needs otherwise.
  • 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.