carlos / platform Public
Virus scanning for the object storage primitive
Date: 2026-09-01 Status: approved in principle (Paul, 2026-09-01). Items marked (ruling) were decided in conversation; items marked (decision) are this spec's reasoning and can be argued with.
Revision history. Three review rounds, fourteen blockers, all fixed. Draft 3 carried Paul's direction change — ClamAV primary, GuardDuty an option, self-hosters included — after GuardDuty's 25-bucket cap surfaced. Draft 4 (this one) closes review 3: the enforcement deadlock, verdicts not bound to the bytes they judged, the untrue self-hoster claim, an audit sentence that hid unscannable objects, and the ClamAV engine's missing protocol detail.
1. What is being asked, and what can honestly be claimed
Tony asked whether CARLOS can scan objects for viruses. Asked whose problem that solves, the answer was a compliance checkbox — someone wants to know whether stored files are scanned and the deployment needs a defensible yes. Coverage and evidence matter; detection latency and enforcement strength do not. (ruling)
The three sentences an audit response may use, and no stronger ones:
> As of the last completed sweep, which finished at time T, N objects in this > bucket carried no recorded verdict, and a further S were recorded as not > scannable — too large, unreadable, or the engine failed on them.
> Objects scanned clean were scanned by ENGINE against signature database > VERSION.
> For apps whose objects are encrypted before they reach the store, scanning > is not applicable and no coverage is claimed — see §2.1.
The first sentence names two numbers. Draft 3 named only the unscanned count, which let a bucket of 12,000 over-size objects report "0 objects carried no recorded verdict" — technically true under §3's taxonomy and thoroughly misleading. Completed matters too: a sweep in progress is not an answer, which is why §5 keeps the last complete one separately. And T is as wide as a sweep takes, so §7.1 gives it a budget and scan status prints the projection when it spans ticks.
This is a point-in-time reconciliation, not a continuous guarantee. It is still worth more than a scan-on-upload pipeline, whose misses are invisible while a reconciler's are counted.
1.1 Why a reconciler and not a pipeline
CARLOS never sees the PUT. internal/objstore is a control plane and says so:
> It never touches the data plane — apps talk to their bucket directly with > the delivered credentials.
It mints a bucket, creates one IAM user per app whose inline policy is exactly two statements (aws.go:129-130), seals the secret into the app's config, and steps out. There is no request path to hook because there is no request path.
The lever that survives is that CARLOS writes the app's IAM policy, so the control plane can gate reads on a verdict without being in the data path (§8).
2. Scope, and who this is for
The primary engine is ClamAV on the deployment's own infrastructure. No bucket cap, any S3-compatible store, no cloud service to buy. (ruling)
GuardDuty is an option for AWS deployments that would rather buy it. Not the default: AWS caps Malware Protection at 25 protected buckets per account per Region, not adjustable, and CARLOS mints one bucket per app (MintedBucket, walk.go:62), so it runs out at 25 apps.
2.1 Who can actually turn this on today
Draft 3 said a self-hoster "sets CARLOS_STORE_SCANNER=clamav and runs a clamd unit — no AWS account." That is not true today and the correction matters.
The store primitive itself is AWS-bound: cmd/console/main.go:497-498 fatals on any CARLOS_STORE_BACKEND but "aws", and aws.go:73-75 records that a non-AWS endpoint "still means IAM-against-real-AWS in v1 — there is nothing else for it to point at." There is no store for a scanner to scan without AWS IAM.
So the honest statement: scanning is available to any deployment that has the store primitive, and today that still needs an AWS account for IAM. The scanner itself is backend-agnostic and will follow a non-AWS provisioner the moment one lands — that provisioner, promised in provisioner.go's doc comment, is the actual dependency for a fully self-hosted story, and this spec does not deliver it. What draft 4 does deliver is a scanner that will not have to change when it arrives. (decision)
2.2 Scanning ciphertext is meaningless
CARLOS's stated family default is server-blindness — "If the server is compromised, the attacker learns nothing" (building-carlos-apps/SKILL.md line 60). An app following it encrypts objects client-side, before they reach the store.
A scanner cannot scan ciphertext. Every object in such a bucket scans clean forever, whatever it contains.
Note the distinction draft 3 blurred: this is about client-side encryption. Server-side encryption (SSE-S3, SSE-KMS — on by default for every AWS bucket since 2023) is transparent to a reader holding the right credentials and scans perfectly well. Only client-side encryption defeats a scanner.
The platform cannot detect which it is, so it asks. scan request takes an explicit attestation, recorded on the record:
--plaintext— objects are readable by the deployment; scanning is meaningful; the §1 sentences apply.--client-encrypted— objects are ciphertext. The sweep marks the sidecarnot_applicableand does no work,scan statusprints §1's third sentence and refuses to print the first, andenforceis rejected as inert (nothing will ever be tagged infected).
Without a recorded attestation, §1's third sentence has no data behind it and scan status cannot know which answer to give. (decision)
2.3 What the member is actually consenting to
The ClamAV engine reads tenant object bytes, and the per-app opt-in is where the app owner consents to that. One honest qualification, because the consent story would otherwise overclaim:
The console can already read every store. It holds PutUserPolicy and CreateAccessKey over every store user (aws.go:282-311), so it can mint itself a credential to any tenant bucket at will. §10's GetObject grant makes an existing capability explicit rather than creating a new one. What the opt-in governs is what the platform does, not what it could do. Say that plainly rather than implying scanning is what first gives the platform reach into a member's bucket. (decision)
3. The Scanner seam
// Scanner is the scanning-engine seam. Like Provisioner, every method is
// idempotent — "already enabled" and "already gone" are successes.
type Scanner interface {
EnsureEnabled(ctx context.Context, bucket string) error
// EnsureDisabled releases what EnsureEnabled took. Teardown calls it
// BEFORE deleting the bucket (§7.3).
EnsureDisabled(ctx context.Context, bucket string) error
// Verdict answers the recorded verdict for one object AT etag. A verdict
// recorded against a different etag is (VerdictUnknown, nil): the object
// has been rewritten since it was judged (§3.1).
Verdict(ctx context.Context, bucket, key, etag string) (Verdict, error)
// Scan scans one object and records its verdict against its etag. It
// returns the verdict when the engine is synchronous (ClamAV) and
// VerdictUnknown when the engine scans asynchronously (GuardDuty).
Scan(ctx context.Context, bucket, key, etag string) (Verdict, error)
// Enforcement names the tag this engine writes, so the policy writer can
// deny reads on it (§8).
Enforcement() DenyTag
// Capacity reports buckets still protectable and the cap; (-1,-1) is
// uncapped. ClamAV is uncapped; GuardDuty is not.
Capacity(ctx context.Context) (remaining, cap int, err error)
// Provenance names what did the scanning, for the evidence record.
Provenance(ctx context.Context) (engine, version string, err error)
}
type Verdict string
const (
VerdictUnknown Verdict = "" // no recorded verdict for these bytes
VerdictClean Verdict = "clean"
VerdictInfected Verdict = "infected"
VerdictSkipped Verdict = "skipped" // too large, unsupported, unextractable
VerdictDenied Verdict = "denied" // the engine could not read the object
VerdictFailed Verdict = "failed" // the engine tried and failed
)
type DenyTag struct{ Key, Value string }Scan returning a Verdict is draft 4's correction to a subtle counting bug. ClamAV is synchronous — fetch, stream, tag — so the verdict exists the moment Scan returns. Draft 3's "the next sweep reads the result" was GuardDuty's shape applied to both, which would have made the first complete sweep over every bucket report every object as unscanned, and with a 24-hour re-sweep floor the audit sentence would only become true two days in. The sweep counts a verdict Scan hands back.
3.1 Verdicts are bound to the bytes they judged
PutObjectTagging replaces a tag set unconditionally and knows nothing about versions, so a verdict tag alone says "some bytes under this key were clean once." The window: Scan fetches v1, the app overwrites with v2, Scan tags clean — and v2 is marked clean having never been read. Narrow, but it is precisely the window worth timing.
So the engine writes the scanned object's version into the tag set alongside the verdict, and Verdict compares it against what ListObjectsV2 already returns for free on the page being swept. A mismatch is VerdictUnknown — the object gets rescanned, which is the correct outcome for bytes nobody has looked at. This also closes the forgery story properly: a re-uploaded object cannot inherit its predecessor's verdict. (decision — draft 3 had this only as a Hetzner fallback; it is the primary design.)
The version is the ETag AND LastModified, not the ETag alone (implementation review). For a single-part upload the ETag is the content MD5, and chosen-prefix MD5 collisions are practical on ordinary hardware. An attacker who controls both uploads — which is exactly the case enforcement exists for, an app accepting uploads and serving them back — could give a payload the ETag of the clean object it replaces and inherit its verdict, walking through the Deny. LastModified moves on every overwrite and is not the uploader's to choose. Both halves are on the listing page already, so the stronger binding costs nothing. The tag is carlos-scan-version.
4. The engines
4.1 clamavScanner — primary, shipped in v1
Fetch the object, stream it to clamd, write carlos-scan, carlos-scan-version and carlos-scan-at tags — preserving any tag the scanner does not own, because an ADOPTED bucket's objects can carry tags from tooling that predates CARLOS. Capacity is uncapped.
Unreadable and vanished objects are per-object verdicts, not pass failures (implementation review). An AccessDenied on GetObject — an SSE-KMS object outside the console's key policy, a foreign-owned object in an adopted bucket — returned as an error would abandon the page unwritten, and the next tick would re-list the same page and fail identically: the bucket's sweep would never advance past it, forever, while every surface reported a sweep merely "in progress". It is VerdictDenied, which is what that verdict was declared for. A NoSuchKey is "gone": dropped rather than counted, since an object deleted between the listing and the fetch is neither covered nor a gap.
The read path already exists. sizeProvisionedStore (economicspass.go:990) lists a tenant's provisioned bucket with objstore.S3ClientFor(ctx, p.Region, p.Endpoint) (aws.go:95) and the console's ambient credentials, from a scheduled pass, with no IAM involved. Scanning adds GetObject, GetObjectTagging and PutObjectTagging to that same principal. Stream GetObject.Body straight into the socket; never buffer an object in memory.
Transport. clamd's INSTREAM protocol is unauthenticated, so it is never exposed to a network. The console reaches it over a unix socket (CARLOS_CLAMD_SOCKET, default /run/clamav/clamd.ctl). No TCP option in v1: an unauthenticated scanning service reachable over a network is a worse problem than the one being solved. (decision)
Protocol, stated so it is not invented twice. Send zINSTREAM\0, then chunks of a 4-byte big-endian length followed by that many bytes, terminated by a zero-length chunk. Replies are null-terminated: stream: OK\0 → clean, stream: <Signature> FOUND\0 → infected, INSTREAM size limit exceeded. ERROR\0 → skipped. The z (null-terminated) command form is used throughout, not the newline form. One connection per object; IDSESSION is not used in v1, and §11 measures whether that costs enough to revisit.
Size ceiling, checked before the fetch. ListObjectsV2 already returns Size, so an object above CARLOS_CLAMD_MAX_BYTES is tagged skipped without a GetObject — paying egress to fetch bytes clamd will refuse is pure waste. If the variable is set above clamd's own StreamMaxLength, clamd answers size limit exceeded ... ERROR mid-stream; that classifies as skipped, not failed, or the counters lie about what happened.
Timeouts. Dial deadline, per-chunk write deadline and reply read deadline on our side; clamd's ReadTimeout and CommandReadTimeout on its side. All configurable, all defaulted, none infinite.
When clamd is down. A dial failure aborts that app's pass without writing any verdict and is reported as a pass error to retry next tick. It must never stamp thousands of objects failed: "the engine is unreachable" and "the engine read this object and failed on it" are different facts and only the second belongs in a tag.
Re-scan policy. failed and denied are re-issued every sweep — both are usually transient or a permissions fix away. skipped is not re-issued unless the ETag changed or the ceiling moved. clean and infected are re-issued only on ETag mismatch (§3.1).
Provenance. zVERSION\0 answers e.g. ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026 — engine version, signature database version, database date. Sweep.Signature records all three verbatim. scan status warns when the database date is older than CARLOS_CLAMD_MAX_SIG_AGE (default 7 days), because a clean result from stale signatures should not read like a fresh one.
Operational facts self-hosters need up front, and §10 must carry: clamd holds roughly 1–1.5 GB of signatures resident; freshclam must run on a timer or the database rots silently; and the console's unit user must be in the socket's group (/run/clamav/clamd.ctl is group clamav).
4.2 guarddutyScanner — optional, AWS only, gated
EnsureEnabled creates a Malware Protection plan with tagging on; Verdict reads GuardDutyMalwareScanStatus; Capacity reports against the cap.
Verified against AWS documentation rather than assumed:
SendObjectMalwareScanis real, takes an S3 object path (optionally a version), and is the documented on-demand path for objects that predate the plan. It needsAmazonGuardDutyFullAccess_v2on the caller and is excluded from the free tier, so the sweep pays per unknown object per pass.- The object ceiling is 100 GB and the bucket cap is 25, both from the quotas table, both non-adjustable.
The gate that must be measured before any GuardDuty work starts. AWS states tagging "must be enabled before an object gets uploaded" or GuardDuty "will not be able to add tags to the scanned object." Every object in an existing store predates its plan. If an on-demand scan of such an object is not tagged, Verdict never converges on any existing store, and this engine needs an EventBridge consumer reading scan results instead of tags — a different architecture. Measure it against a real bucket first. ClamAV is unaffected either way, which is why it is the primary engine.
5. Evidence: a sidecar, not the store record
Two objects, two writers, no contention. (decision — draft 2 put everything in store.json and needed a page of CAS choreography to survive it.)
store.json gains a scanning block, written only by the doors:
type ScanRequested struct {
At time.Time `json:"at"`
By string `json:"by"`
Encrypted bool `json:"encrypted"` // §2.2 attestation
}
type ScanEnabled struct {
Engine string `json:"engine"` // "clamav" | "guardduty"
At time.Time `json:"at"`
By string `json:"by"`
}
type ScanBlock struct {
Requested *ScanRequested `json:"requested,omitempty"`
Enabled *ScanEnabled `json:"enabled,omitempty"`
// Enforce holds the tag itself, not a bool: Grant takes a Provisioner
// and has no Scanner to ask, so the record carries what the policy
// writer needs (§8).
Enforce *DenyTag `json:"enforce,omitempty"`
}apps/<acct>/<app>/scan.json holds the rollups, written only by the sweep:
type ScanSidecar struct {
Schema int `json:"schema"`
Account string `json:"account"` // §5.1
App string `json:"app"`
NotApplicable bool `json:"not_applicable,omitempty"` // §2.2
Current *Sweep `json:"current,omitempty"`
LastComplete *Sweep `json:"last_complete,omitempty"` // the audit answer
}
type Sweep struct {
StartedAt time.Time `json:"started_at"`
At time.Time `json:"at"`
Engine string `json:"engine"`
Signature string `json:"signature,omitempty"`
Objects int64 `json:"objects"`
Scanned int64 `json:"scanned"`
Infected int64 `json:"infected"`
Skipped int64 `json:"skipped"`
Denied int64 `json:"denied"`
Failed int64 `json:"failed"`
Unscanned int64 `json:"unscanned"`
Bytes int64 `json:"bytes"`
After string `json:"after,omitempty"`
}Current and LastComplete are separate because draft 2's single *Sweep overloaded After == "" to mean both "complete" and "start fresh", so the tick after a sweep finished overwrote the audit answer with page-one partials. A sweep accumulates in Current; on its last page it moves to LastComplete and Current clears. A completed sweep does not restart before CARLOS_STORE_SCAN_INTERVAL (default 24h), so LastComplete stays stable long enough to quote.
Schema is refused when unknown, the rule ReadAppTombstone already applies (trash.go:76-80).
5.1 Why the sidecar carries its own account
accounts migrate copies every key under the source prefix (accounts.go:587-597), so a sidecar would silently follow an app to a new account carrying rollups for a bucket named after the old one. The reader refuses a body whose Account/App disagree with its path, exactly as AppTombstone does.
(store.json has the same latent problem — its bucket and IAM user are named after the original account — but that predates this spec and is noted, not fixed here.)
5.2 The PARSER LIST hazard
objstore.go records that release.PurgeApp parses grant and provisioned with its own local struct and refuses box-side auto-purge when either is present. Additive changes are safe (trash.go:214-217 ignores unknown keys), so scanning is fine, but those two names must not move. The full consumer list for this shape is objstore.Record, PurgeApp's local struct, and handleAPIStoreStatus's map (apistore.go:130-141). §13 adds the additive subtest, which TestPurgeGuardSeesOurRecord does not cover today.
6. Who turns it on
- The member requests —
carlos store scan request --plaintextor--client-encrypted(§2.2). Records the ask and the attestation, spends nothing. - An operator grants —
carlos store scan grant. Calls the cloud account and starts a meter, so it sits behindCARLOS_STORE_OPERATORS, the gateapistore.goapplies to store grants for the same reason, checked after the member gate so a 403 never confirms which apps exist.
CARLOS_STORE_SCANNER unset means no engine is built and grant/sweep 503 naming the variable. The member's request door still records, following the create door's deliberate precedent (apistore.go:145-152): a member cannot see whether their deployment has a backend, and refusing them teaches them nothing they can act on.
revoke and enforce off never 503, and this is draft 4's fix for a deadlock. Draft 3 had §8 refuse a repair when the scanner was nil and Enforce was set, while §6 had the operator doors 503 without an engine — so unsetting CARLOS_STORE_SCANNER on a deployment with any enforcing record left the store unrepairable and the deny unclearable without hand-editing the bucket. Both are record-only doors that work with no engine built, which is the whole point of keeping the DenyTag in the record.
7. The sweep
- Read
store.json. Noscanning.enabled, orRequested.Encrypted— write the sidecarnot_applicableif needed and return. Skip trashed apps (deleted.json,trash.go:34-36): a trashed store is destroyed within 30 days and scanning it bills for data on its way out. - Read
scan.json; resume fromCurrent.Afteror start fresh. - List a page with
ListObjectsV2usingStartAfter— not aContinuationToken, whose cross-process lifetime is unspecified and, on Ceph, unmeasured. A key is a portable resume point. The page givesSizeandETagfor free, which §3.1 and §4.1 both need. - For each key,
Verdict(key, etag); count it;Scanwhen unknown and count the verdict it returns. - Re-read
store.json, then write the sidecar (§7.2), settingAfterto the page's last key. On the final page moveCurrenttoLastComplete.
Honest semantics. A key added lexically before the resume point mid-sweep is missed by that sweep and caught by the next. A key deleted after being counted inflates Objects for that pass. Neither breaks a point-in-time reconciliation.
7.1 The driver and its budget
Its own goroutine on the StartInfraPass shape (infrapass.go:49), started from serve() in cmd/console/main.go alongside StartEconomicsPass and StartInfraPass (main.go:326-330) — never from NewServer, the rule economicspass.go:35-38 states. Not inside economicsPass, whose single goroutine already runs census, statements and billing and already worries about a slow step delaying the others.
Draft 2 wanted to ride storagePass; that was wrong. storagePass returns at economicspass.go:137-140 once the day's object exists, so it iterates once per UTC day.
Enumeration. The pass finds enabled stores the way storagePass does — ListDirs("apps/"), per-account ListDirs, per-app objstore.Get (economicspass.go:150-181). Hourly, that is 24× that pass's listing cost. Accepted for v1 and named here so it is a known cost rather than a surprise; an index is the fix if it bites.
Budget, with the arithmetic stated. CARLOS_STORE_SCAN_BUDGET bounds work per app per tick by time (default 60s). An hourly tick at 60s is 24 minutes of scanning per app per day; at a few hundred objects a minute over INSTREAM, a million-object bucket takes months, not days. So the default is honest only for small and medium stores, and scan status prints the projected completion whenever a sweep spans more than one tick. A deployment with large stores raises the budget or shortens the tick, and §1's T widens accordingly. Apps are swept sequentially in the one goroutine; when N apps × budget exceeds the interval the ticker simply runs back to back, which is correct behaviour and not an error.
Two drivers, one recorder. The scan sweep verb and the ticker can both list a page before either writes. For ClamAV that costs a duplicate fetch; for GuardDuty on-demand it is double-billed. The guarantee is at most one driver records, not that two never run — draft 3 overstated it.
7.2 The pre-write re-read
Before every sidecar write, re-read store.json and stand down if it is gone or scanning.enabled is nil.
This closes a hole the retry guard alone does not. A fresh sweep's first write is create-only by construction — no sidecar exists, etag "". Sequence: the sweep reads an enabled store.json, lists a page over some seconds, Teardown runs and PurgeApp deletes the whole prefix (trash.go:229-238), and the sweep then writes scan.json create-only, which succeeds. The result is an orphan object under a purged app, and that orphan is expensive: the app-name claim gate refuses a name whose prefix holds any key (server.go:2876-2890), rename is refused (appsettings.go:468-479), and box-side auto-purge never reaches it because the tombstone went last.
A one-round-trip tolerance is the same one deleteRecordIfUnchanged (walk.go:519-540) already accepts.
The retry guard still applies to replaces: on a lost CAS, continue only if Current still exists and its After matches where this page started; otherwise stand down. A conditional write against a deleted key reads as ErrCASConflict (release/store.go:521-534; DirStore matches at :260), so a blind retry would write create-only and resurrect the sidecar.
7.3 Teardown disables first
Teardown (walk.go:390-538) empties the bucket, deletes it, deletes the user, unsets config, then deletes the record. Nothing today disables a scanner, so a GuardDuty plan and its managed EventBridge rule would outlive the bucket, counting against the §2 cap, with the record naming them gone.
Teardown takes a Scanner, calls EnsureDisabled before DeleteBucket, and deletes the sidecar. A nil scanner with an enabled record refuses, as a nil provisioner already does (walk.go:417). The destroy door's pre-check (appsettings.go:827-833) tests only StoreProvisioner and needs the matching scanner check, or the refusal surfaces as a generic "Could not destroy" flash. Both callers (appsettings.go:845, ownership.go:417) update with the signature.
7.4 Engine drift
Both cases refuse rather than guess. A record naming another engine: the sweep refuses for that app, because verdicts in the bucket were written under a tag key the current engine does not read, and recounting them as unscanned would destroy the coverage history — an operator revokes and re-grants deliberately. Engine guardduty with Deployment.Endpoint != "": refused at boot, since GuardDuty cannot protect a non-S3 bucket.
8. Enforcement
ScanBlock.Enforce — nil by default — adds a Deny statement: deny s3:GetObject where s3:ExistingObjectTag/<Key> equals <Value>. It stores the tag rather than a bool because Grant (walk.go:133) takes a Provisioner and has no Scanner to ask.
Grant writes the deny from the record whatever the scanner is. A nil scanner never blocks a repair and never silently drops the deny — the record holds everything the policy writer needs, which is why it holds a tag.
How the policy gets rewritten — the bug drafts 1 and 2 both had. PutUserPolicy is reachable only through EnsureUser (aws.go:302), called from one line (walk.go:184), and Grant returns early before it for any healthy store (walk.go:169-177). Rotate never writes a policy. A flag flip would have been permanently inert.
This repo already fixed this exact bug once. internal/mail/walk.go:653-671 ("C2 — review follow-up") hoisted EnsureUser above the idempotence short-circuit, with a comment recording that the short-circuit had left the policy naming only the first domain forever. The same move here:
EnsureUsergainsdeny *DenyTagand stays the single policy writer.Grantcalls it before the short-circuit, passing the record'sEnforce. It needs only the bucket ARN string, resolved atwalk.go:143-151before the guard, soEnsureBucketstays behind the guard and the record-before-create ordering survives (the grant block Put atwalk.go:160-165still precedes it).scan enforce on|offandscan revokewrite the record and re-run the grant walk. One convergence path, no second policy writer. Clearing the record alone would leave the deny live until something re-walked.
This makes every store grant re-run do three IAM calls on a healthy store where it previously did none. That is the intended trade and the plan should expect it.
policyStatement (aws.go:112-116) has no Condition field today; it gains one, marshalled omitempty, so a nil deny emits no Condition key at all.
The document does change once, estate-wide, and pretending otherwise was a contradiction in draft 4. Adding s3:GetObjectTagging to the Allow — which apps need to read their own verdicts — changes the bytes by definition, so "identical byte for byte when the deny is nil" cannot also be true. The correct requirement, and what §13 tests, is narrower: with a nil deny the document has exactly two statements and no Condition, so an enforcement-off store never carries a Deny. Every store's policy is rewritten on its next grant re-walk to gain the new action. That is a one-time, idempotent change, it costs three IAM calls per re-walk (already accepted above), and assertStorePolicyDocument in aws_test.go must be updated to expect four actions rather than three — it asserts the count, so it fails as a logic error rather than a compile error and is easy to miss.
MemProvisioner mirrors the signature and gains policy state, which MemUser lacks (memprovisioner.go:12-15). Note its EnsureUser early-returns for an existing user, so the policy must be written on both branches or enforcement never reaches a store that already has its principal — which is every store this feature will ever be turned on for.
Anti-forgery, stated at its real strength. AWS's documented tag-based access control denies reads unless the tag says clean, so unscanned objects are blocked too. This design denies only known-infected objects; unscanned ones stay readable. That is the right trade under §1's ruling and it must be said, not implied, to anyone quoting this in a compliance answer. It is safe only because the app user has no s3:PutObjectTagging (aws.go:130) and so cannot mark its own object clean — that must never be added to the Allow statement. The app gains s3:GetObjectTagging only, so it can read its own verdicts; the console gains GetObject, GetObjectTagging and PutObjectTagging (§10).
For a --client-encrypted store, enforce is rejected: nothing will ever be tagged infected, so it would be theatre.
9. Surface
| command | actor | effect | |---|---|---| | carlos store scan request --plaintext\|--client-encrypted | member | records the ask and the §2.2 attestation | | carlos store scan status | member | the §1 sentences, engine, signature, projection | | carlos store scan grant | operator | enables the engine on the bucket | | carlos store scan revoke | operator | disables it, clears Enforce, re-walks | | carlos store scan sweep | operator | advances a sweep by one budget | | carlos store scan enforce on\|off | operator | flips the §8 Deny, re-walks |
request/grant mirrors create/grant on the store itself. API mode only, like the rest of store.go.
store status gains the scan line. docs/site/cli.md gains the six verbs under ### carlos store (line 393) — the docsite gate reads only the dispatch switch (cliscan.go:22) and cannot see sub-verbs, so this is a house-rule obligation.
10. The infra half (tito/carlos-infrastructure)
- clamd, per console box: a systemd unit,
freshclamon a timer, a unix socket the console's unit user can reach (groupclamav),StreamMaxLengthmatched toCARLOS_CLAMD_MAX_BYTES, and ~1–1.5 GB RAM for resident signatures. This is the primary path and all a self-hoster needs beyond the store primitive itself. - Console S3 permissions:
GetObject,GetObjectTagging,PutObjectTaggingonarn:aws:s3:::<BucketPrefix>*, alongside the listing it already does. - GuardDuty, only if used: a scanning role GuardDuty assumes (EventBridge managed rule,
GetObject,PutObjectTagging, KMS where encrypted), passed at plan creation — so the console needsiam:PassRoleon it plusguardduty:SendObjectMalwareScan. Scope the role toarn:aws:s3:::<BucketPrefix>*so it needs no per-app edit.
11. Gates and risks
Gate — measure before building the GuardDuty engine: whether SendObjectMalwareScan tags an object uploaded before tagging was enabled (§4.2). Blocks GuardDuty only.
Risks to verify during implementation:
- Hetzner object tagging.
s3compat's quirks table exists because Ceph RGW already differed from S3 in a way that broke every conditional write. MeasurePutObjectTagging/GetObjectTaggingbefore relying on them; the fallback is per-object verdicts in the sidecar keyed by key and ETag. - clamd throughput. One connection per object with a size ceiling sets the real budget in §7.1. Measure before promising any completion window, and revisit
IDSESSIONif per-connection cost dominates. - Enumeration cost at hourly cadence (§7.1).
12. Out of scope
- Scanning the deployment's control-plane bucket, release artifacts, or instance replicas. App stores only.
- A non-AWS store provisioner (§2.1) — the dependency for a fully self-hosted story, not delivered here.
- Quarantine as movement. Verdicts are tags; nothing is copied aside or deleted. Deleting a member's data on a scanner's say-so is a policy decision nobody has made.
- Scan-on-upload latency guarantees.
- Notifying members of an infected object.
13. Testing
Scannercontract test overMemScannerand each real engine: enable twice, disable twice, scan twice, verdict on an absent object, verdict on a changed ETag. Notecontract_test.gotoday isTestDeliveredContractMinio, a delivered-credential wire test — there is noProvisioneridempotence contract to sit beside, so this is a new file.- Version binding. An object rewritten after being tagged reads
VerdictUnknownand is rescanned (§3.1). - Synchronous counting. A ClamAV sweep's first pass reports objects as scanned, not unscanned.
LastCompletesurvives the tick after completion — draft 2's bug.- Resurrection, both paths. Fresh sweep with no sidecar,
TeardownplusPurgeAppbetween list and write: noscan.jsonremains and the app name is claimable. And the retry path on a replace. - Migration. A sidecar whose
Accountdisagrees with its path is refused. - Purge guard, additive. A record carrying
scanningstill makesPurgeApprefuse ongrant/provisionedand ignore the new key. - Policy round-trip.
EnsureUserwithdeny == nilproduces exactly two statements and noConditionkey; with aDenyTag, three statements, aConditionon the verdict tag, and noPutObjectTaggingin any of them. - Enforcement converges on a healthy provisioned store, and
enforce offworks with no engine built (§6's deadlock, asserted). - Teardown disables first, deletes the sidecar, refuses on a nil scanner.
- Encrypted attestation. A
--client-encryptedstore is sweptnot_applicable,scan statusrefuses §1's first sentence,enforceis rejected. - clamd down produces a pass error and zero written verdicts.
- Skipped is not clean, and an over-ceiling object is tagged without a
GetObject. - Trashed apps are skipped.
- Gate order and inert deployment.
grant/sweep503 without an engine;request,revokeandenforce offstill work.