carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

Scanner Host Rollout Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Object-storage virus scanning switched on, with no member granted, on carloku.com and then on the Tito platform, each with clamd on its own box in the home region.

Architecture: A new scanner-host OpenTofu module builds a clamd box whose TCP port is open only while a self-test has passed against the running clamd. region-host gains an opt-in read-and-tag grant for the console. One small Go change records clamd's limit replies as unscanned. Then a release, and an operational sequence per deployment: apply, acceptance checks on the box, console roll, key, wiring, enable, proof.

Tech Stack: Go (internal/clamd), OpenTofu 1.12 with tofu test and mock_provider, Debian trixie, ClamAV 1.4, systemd (systemd-socket-proxyd), AWS (EC2, IAM, SSM), the carlos and amadan CLIs.

Spec: docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md (draft 6). Read it first; section numbers below (§) refer to it.

Global Constraints

  • Rulings D1–D8 in spec §2 are fixed. Do not reopen them.
  • MaxFileSize and MaxScanSize are both 100M, a constant, not a variable (§3.2).
  • The invariant (§3.2): TCP 3310 is open only while a self-test has passed against the clamd process running now. clamd itself never listens on TCP.
  • The scanner box's role: Session Manager only, explicit Deny on the four parameter-read actions, no S3, no KMS (§3.1).
  • CARLOS_STORE_SCANNER stays unset everywhere. It is deprecated.
  • The scan tag key never appears in git, tofu state, argv, a terminal or a transcript (§6.1).
  • Nothing live changes without Paul's go for that step. Every tofu apply, console roll, env edit, restart, antivirus enable, merge, mirror push and release is preceded by showing him exactly what will run. Tasks 6–12 are marked GATED, as are the merge and mirror steps of Task 5.
  • Every tofu apply is -targeted. Never apply a whole root (flagship has standing user_data drift that restarts boxes; Tito's main cannot plan).
  • Carloku first, then Tito. Tito waits for #201 phase 2 to have landed.
  • User-facing prose (READMEs, docs/site, release notes) goes through the copy-review skill before it is written to a file. No em dashes in copy: full stop and a new sentence.
  • No GitHub issues. Track on an amadan discussion (amadan skill for the house rules).
  • Go: use the default build cache. Scratch goes under $TMPDIR/scanner-host/, never /tmp. Delete it at the end.
  • AWS profiles: keymail is the flagship account (console box i-092c0c1eea75723cb, eu-west-1, bucket carlos-flagship-271376211898). titogo is Tito's (console box i-025a0b70f3d93304d, eu-west-1). Assumed-role profiles need the Bash sandbox off for that command.
  • Commits end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. An engine upgrade that changes limit behaviour. A later ClamAV package could answer OK for an over-limit archive. Expected: the port closes and stays closed. Pinned by Task 7's forced-fail acceptance check and by the self-test running on every clamd start (Task 3).
  2. A reboot of the scanner box. Expected: it comes back scanning with no hand. First boot cannot show an ordering cycle. Pinned by Task 7's reboot check.
  3. A signature name that merely contains the limit phrase. Win.Heuristics.Limits.Exceeded.Fake FOUND must stay infected. Pinned by Task 1's tests.
  4. A console restarted on the old binary with the scanner wired. Expected: wiring stops before any variable is written. Pinned by Task 8's /proc/<pid>/exe check and Task 9's precondition.
  5. A rerun of the key step. Expected: the existing key survives; a second run cannot replace it and orphan every verdict. Pinned by Overwrite:false in Task 9.

File Structure

Platform repo (this worktree, branch worktree-scanner-host):

| File | Responsibility | |---|---| | internal/clamd/clamd.go (modify) | parseReply: limit replies become size-exceeded or error | | internal/clamd/clamd_test.go (modify) | tests for the above | | infra/modules/region-host/variables.tf, main.tf (modify) | store_scanning and the -store-scan policy | | infra/modules/region-host/tests/store_scanning.tftest.hcl (create) | plan-time tests for the grant | | infra/modules/scanner-host/{versions,variables,main,outputs}.tf (create) | the box, its group, its role | | infra/modules/scanner-host/scanner-init.sh.tftpl (create) | first-boot script | | infra/modules/scanner-host/files/scanner-selftest (create) | the self-test | | infra/modules/scanner-host/files/scanner-selftest.service, scanner-tcp.socket, scanner-tcp.service (create) | the gate | | infra/modules/scanner-host/tests/scanner_host.tftest.hcl (create) | plan-time tests | | infra/modules/scanner-host/README.md (create), infra/modules/README.md, infra/modules/region-host/README.md, docs/site/cli.md, docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md (modify) | docs |

Other repos: ~/github.com/carlosframework/carloku-infrastructure (region-ie.tf, new scanner.tf), ~/github.com/teamtito/carlos-infrastructure (object-store.tf, new scanner.tf).

---

Task 1: clamd client records limit replies as unscanned

Files:

  • Modify: internal/clamd/clamd.go (parseReply, near line 268; the StatusSizeExceeded comment near line 38)
  • Test: internal/clamd/clamd_test.go

Interfaces:

  • Consumes: replyFixture(t, reply string) Result (existing test helper), signatureOf(reply string) string (existing).
  • Produces: no new names. parseReply now returns StatusSizeExceeded for limit heuristics other than scan time, and StatusError for Heuristics.Limits.Exceeded.MaxScanTime. objstore.verdictFor is unchanged and already maps these to VerdictSkipped and VerdictFailed.
  • [ ] Step 1: Write the failing tests. Append to internal/clamd/clamd_test.go:
// With AlertExceedsMax on, clamd reports an archive it could only partly scan
// as a FOUND whose signature names the limit. That is not an infection and it
// is not clean: nothing was judged, so it is the same status as an object too
// large to stream. Recording it clean would sign a verdict over bytes nobody
// read; recording it infected would make enforce block large honest archives.
func TestLimitHeuristicIsSizeExceededNotInfected(t *testing.T) {
	for _, limit := range []string{"MaxScanSize", "MaxFileSize", "MaxFiles", "MaxRecursion"} {
		reply := "stream: Heuristics.Limits.Exceeded." + limit + " FOUND\x00"
		res := replyFixture(t, reply)
		if res.Status != StatusSizeExceeded {
			t.Errorf("%s: status = %v, want size-exceeded", limit, res.Status)
		}
		if res.Signature != "" {
			t.Errorf("%s: a limit is not a signature, got %q", limit, res.Signature)
		}
		if !strings.Contains(res.Raw, limit) {
			t.Errorf("%s: the engine's own words did not survive: %q", limit, res.Raw)
		}
	}
}

// Older engines name the limit without saying which one.
func TestBareLimitHeuristicIsSizeExceeded(t *testing.T) {
	res := replyFixture(t, "stream: Heuristics.Limits.Exceeded FOUND\x00")
	if res.Status != StatusSizeExceeded {
		t.Fatalf("status = %v, want size-exceeded", res.Status)
	}
}

// A scan that ran out of TIME depends on load, not on the bytes, so it must be
// retried. StatusError becomes a failed verdict, which every sweep re-issues;
// size-exceeded becomes skipped, which is carried forward until the object
// changes. Filing a timeout under skipped would exclude the object for good.
func TestScanTimeLimitIsAnErrorSoItIsRetried(t *testing.T) {
	res := replyFixture(t, "stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND\x00")
	if res.Status != StatusError {
		t.Fatalf("status = %v, want error", res.Status)
	}
	if !strings.Contains(res.Raw, "MaxScanTime") {
		t.Fatalf("the engine's own words did not survive: %q", res.Raw)
	}
}

// The match is on the SIGNATURE, as a prefix. A real signature that happens to
// carry the phrase elsewhere in its name is still an infection, and so is one
// that only begins with something similar.
func TestLookalikeSignaturesAreStillInfected(t *testing.T) {
	for _, sig := range []string{
		"Win.Heuristics.Limits.Exceeded.Fake",
		"Heuristics.Limits.ExceededButNot",
		"Heuristics.Phishing.Email.SpoofedDomain",
		"Eicar-Signature",
	} {
		res := replyFixture(t, "stream: "+sig+" FOUND\x00")
		if res.Status != StatusInfected {
			t.Errorf("%s: status = %v, want infected", sig, res.Status)
		}
		if res.Signature != sig {
			t.Errorf("signature = %q, want %q", res.Signature, sig)
		}
	}
}
  • [ ] Step 2: Run them and watch them fail.

Run: go test ./internal/clamd -run 'Limit|Lookalike' -count=1 Expected: FAIL. TestLimitHeuristicIsSizeExceededNotInfected, TestBareLimitHeuristicIsSizeExceeded and TestScanTimeLimitIsAnErrorSoItIsRetried report status = infected. TestLookalikeSignaturesAreStillInfected passes already; it is there to stay passing.

  • [ ] Step 3: Implement. In internal/clamd/clamd.go, add beside the timeout constants:
// clamd's names for "I stopped before the end". With AlertExceedsMax yes it
// reports them as FOUND, which parseReply must not read as an infection.
const (
	limitHeuristic         = "Heuristics.Limits.Exceeded"
	limitHeuristicScanTime = "Heuristics.Limits.Exceeded.MaxScanTime"
)

// isLimitHeuristic reports whether sig names a scan limit rather than malware.
// A prefix on the signature, with the dot, so neither a longer first component
// nor a real signature carrying the phrase elsewhere can match.
func isLimitHeuristic(sig string) bool {
	return sig == limitHeuristic || strings.HasPrefix(sig, limitHeuristic+".")
}

and replace the FOUND case of parseReply:

	case strings.HasSuffix(reply, " FOUND"):
		sig := signatureOf(reply)
		switch {
		case sig == limitHeuristicScanTime:
			// Load-dependent, so retried: an error becomes a failed verdict,
			// which every sweep re-issues.
			res.Status = StatusError
		case isLimitHeuristic(sig):
			// Deterministic for these bytes and this config, and nothing was
			// judged: the same fact as an object too large to stream.
			res.Status = StatusSizeExceeded
		default:
			res.Status = StatusInfected
			res.Signature = sig
		}

Extend the StatusSizeExceeded doc comment with one sentence: It also covers clamd's limit heuristics (an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion), which arrive as FOUND when AlertExceedsMax is on.

  • [ ] Step 4: Run the package.

Run: go test ./internal/clamd -count=1 then go vet ./internal/clamd Expected: ok, no vet output.

  • [ ] Step 5: Run what consumes it.

Run: go test ./internal/objstore -run 'Clam|Scan|Verdict' -count=1 Expected: ok (no behaviour change there; this proves nothing asserted the old mapping).

  • [ ] Step 6: Commit.
git add internal/clamd/clamd.go internal/clamd/clamd_test.go
git commit -m "clamd: a limit heuristic is unscanned, and a scan timeout is retried" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

---

Task 2: region-host grants the console read-and-tag, opt-in

Files:

  • Modify: infra/modules/region-host/variables.tf (after store_user_path), infra/modules/region-host/main.tf (after host_store; and the host_store comment)
  • Create: infra/modules/region-host/tests/store_scanning.tftest.hcl

Interfaces:

  • Produces: variable store_scanning (bool, default false); resource aws_iam_role_policy.host_store_scan[0] named "${var.iam_name}-store-scan". Task 7 targets module.host_ie.aws_iam_role_policy.host_store_scan.
  • [ ] Step 1: Write the failing test. Create infra/modules/region-host/tests/store_scanning.tftest.hcl:
# Plan-time tests for the scanning grant. mock_provider means no AWS account
# and no credentials: these assert what the module WOULD create.
mock_provider "aws" {}

override_data {
  target = data.aws_vpcs.default
  values = { ids = ["vpc-0aaaaaaaaaaaaaaaa"] }
}

variables {
  name                  = "carlos-test-ie"
  sg_description        = "carlos test host"
  iam_name              = "carlos-test-host"
  instance_type         = "t4g.small"
  root_volume_size      = 20
  user_data             = "#!/bin/sh\n"
  deployment_bucket_arn = "arn:aws:s3:::carlos-test-platform"
  data_bucket_arn       = "arn:aws:s3:::carlos-test-ie"
  ssm_parameter_arn     = "arn:aws:ssm:eu-west-1:123456789012:parameter/carlos/*"
  store_bucket_prefix   = "carlos-store-test-"
}

run "off_by_default" {
  command = plan
  assert {
    condition     = length(aws_iam_role_policy.host_store_scan) == 0
    error_message = "store_scanning defaults to false and must create nothing"
  }
}

run "on_grants_exactly_read_and_tag" {
  command = plan
  variables {
    store_scanning = true
  }
  assert {
    condition     = length(aws_iam_role_policy.host_store_scan) == 1
    error_message = "store_scanning = true must create the scan policy"
  }
  assert {
    condition     = aws_iam_role_policy.host_store_scan[0].name == "carlos-test-host-store-scan"
    error_message = "the policy is named <iam_name>-store-scan"
  }
  assert {
    condition = toset(jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Action) == toset([
      "s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging",
    ])
    error_message = "exactly the three actions the scanner calls, no more"
  }
  assert {
    condition     = jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Resource == "arn:aws:s3:::carlos-store-test-*/*"
    error_message = "scoped to objects under the store prefix"
  }
}

run "refused_without_a_store_prefix" {
  command = plan
  variables {
    store_scanning      = true
    store_bucket_prefix = ""
  }
  expect_failures = [aws_iam_role_policy.host_store_scan]
}
  • [ ] Step 2: Run it and watch it fail.
export TF_DATA_DIR="$TMPDIR/scanner-host/tf-region-host"
tofu -chdir=infra/modules/region-host init -backend=false -input=false
tofu -chdir=infra/modules/region-host test

Expected: FAIL, Reference to undeclared resource ... "aws_iam_role_policy" "host_store_scan". (The first init downloads the AWS provider into TF_DATA_DIR, on disk, not /tmp. If a mock data source makes an unrelated precondition fail, add an override_data for it in this file and say so in the commit; do not weaken the module.)

  • [ ] Step 3: Implement. In variables.tf, after store_user_path:
variable "store_scanning" {
  description = "Let this host's role READ and TAG objects under store_bucket_prefix, which is what the console's virus scanner needs (s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging). Off by default: without it the console provisions stores and never reads tenant data. Set it only on the box that runs the console, and only on a deployment that runs a scanner (infra/modules/scanner-host). Requires store_bucket_prefix."
  type        = bool
  default     = false
}

In main.tf, directly after the host_store resource:

# ---- object storage scanning (opt-in) --------------------------------------
#
# The one grant that lets this role read tenant object bytes, so it is its own
# policy behind its own switch: a deployment that has not turned scanning on
# plans no change here, and the sentence in host_store above ("it does not
# read tenant data") stays true for it.
#
# Exactly the calls the scanner makes. HeadObject is authorised by GetObject;
# ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is
# no KMS statement. PutObjectTagging is how a verdict is written; the store
# user boundary deliberately lacks it, so an app cannot write one itself.
resource "aws_iam_role_policy" "host_store_scan" {
  count = var.store_scanning ? 1 : 0
  name  = "${var.iam_name}-store-scan"
  role  = aws_iam_role.host.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
        Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
      },
    ]
  })
  lifecycle {
    precondition {
      condition     = var.store_bucket_prefix != ""
      error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account."
    }
  }
}

In the host_store comment, change No GetObject: the console provisions stores, it does not read tenant data, and the economics sweep sizes buckets with ListBucket. to add: (Scanning is the exception, and it is a separate policy behind store_scanning, below.)

  • [ ] Step 4: Run the tests and the formatter.

Run: tofu -chdir=infra/modules/region-host test then tofu fmt -check -recursive infra/modules/region-host Expected: 3 passed, 0 failed; fmt prints nothing.

  • [ ] Step 5: Commit. Confirm git status --short infra/ shows no .terraform or lock file, then:
git add infra/modules/region-host/variables.tf infra/modules/region-host/main.tf infra/modules/region-host/tests/store_scanning.tftest.hcl
git commit -m "region-host: store_scanning, the console's opt-in read-and-tag grant" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

---

Task 3: the scanner-host module

Files:

  • Create: infra/modules/scanner-host/versions.tf, variables.tf, main.tf, outputs.tf, scanner-init.sh.tftpl, files/scanner-selftest, files/scanner-selftest.service, files/scanner-tcp.socket, files/scanner-tcp.service, tests/scanner_host.tftest.hcl

Interfaces:

  • Consumes: nothing from earlier tasks.
  • Produces: module inputs name, ami_id, client_security_group_id, region, instance_type, stream_max_bytes, memory_max, memory_high, concurrent_database_reload, subnet_id; outputs clamd_addr, instance_id, security_group_id, stream_max_bytes. On the box: /usr/local/sbin/scanner-selftest, units scanner-selftest.service, scanner-tcp.socket, scanner-tcp.service, marker /var/lib/scanner-host/ready, console-log lines SCANNER-HOST READY / SCANNER-HOST FAILED: <reason>, and the environment switch SCANNER_SELFTEST_FORCE_FAIL=1. Tasks 7, 9, 11 and 12 use these names.
  • [ ] Step 1: Write the failing tests. Create infra/modules/scanner-host/tests/scanner_host.tftest.hcl:
# Plan-time tests. mock_provider: no account, no credentials.
mock_provider "aws" {}

override_data {
  target = data.aws_security_group.client
  values = { vpc_id = "vpc-0aaaaaaaaaaaaaaaa" }
}

override_data {
  target = data.aws_vpc.client
  values = { default = true }
}

variables {
  name                     = "carlos-test-scanner"
  ami_id                   = "ami-0123456789abcdef0"
  client_security_group_id = "sg-0123456789abcdef0"
  region                   = "eu-west-1"
}

run "the_fence" {
  command = plan
  assert {
    condition     = length(aws_security_group.scanner.ingress) == 1
    error_message = "exactly one way in"
  }
  assert {
    condition = alltrue([
      for i in aws_security_group.scanner.ingress :
      i.from_port == 3310 && i.to_port == 3310 && i.protocol == "tcp"
      && i.security_groups == toset(["sg-0123456789abcdef0"])
      && try(length(i.cidr_blocks), 0) == 0 && try(length(i.ipv6_cidr_blocks), 0) == 0
    ])
    error_message = "3310 from the client security group only, never a CIDR"
  }
  assert {
    condition     = aws_security_group.scanner.vpc_id == "vpc-0aaaaaaaaaaaaaaaa"
    error_message = "the group lands in the client group's VPC"
  }
}

run "the_box" {
  command = plan
  assert {
    condition     = aws_instance.scanner.metadata_options[0].http_tokens == "required"
    error_message = "IMDSv2 only"
  }
  assert {
    condition     = aws_instance.scanner.root_block_device[0].encrypted
    error_message = "encrypted root volume"
  }
  assert {
    condition     = aws_instance.scanner.instance_type == "t4g.medium"
    error_message = "t4g.medium by default"
  }
  assert {
    # base64 is 4 characters per 3 bytes, so 21800 characters is under 16 KiB.
    condition     = length(base64gzip(local.init_script)) < 21800
    error_message = "EC2 refuses user_data over 16 KiB, and the script is sent gzipped to stay well under it"
  }
}

run "the_role_cannot_read_parameters" {
  command = plan
  assert {
    condition     = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Effect == "Deny"
    error_message = "an explicit Deny, because the managed SSM policy allows GetParameter* on *"
  }
  assert {
    condition = toset(jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Action) == toset([
      "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath",
    ])
    error_message = "all four parameter-read actions"
  }
  assert {
    condition     = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Resource == "*"
    error_message = "on every parameter: this box is allowed none"
  }
}

run "the_engine_config" {
  command = plan
  assert {
    condition     = strcontains(local.init_script, "setkey MaxFileSize 100M") && strcontains(local.init_script, "setkey MaxScanSize 100M")
    error_message = "both limits 100M: equal is what makes a trimmed member trip the scan budget"
  }
  assert {
    condition     = strcontains(local.init_script, "setkey AlertExceedsMax yes")
    error_message = "limits must alert"
  }
  assert {
    condition     = strcontains(local.init_script, "setkey StreamMaxLength 26214400")
    error_message = "StreamMaxLength is stream_max_bytes"
  }
  assert {
    condition     = !strcontains(local.init_script, "setkey TCPSocket") && !strcontains(local.init_script, "setkey TCPAddr")
    error_message = "clamd itself never listens on TCP; the gated proxy does"
  }
  assert {
    condition     = output.stream_max_bytes == 26214400
    error_message = "the console's CARLOS_CLAMD_MAX_BYTES comes from this output"
  }
}

run "small_box_with_concurrent_reload_is_refused" {
  command = plan
  variables {
    instance_type = "t4g.small"
  }
  expect_failures = [aws_instance.scanner]
}

run "small_box_without_concurrent_reload_is_fine" {
  command = plan
  variables {
    instance_type              = "t4g.small"
    concurrent_database_reload = false
  }
  assert {
    condition     = strcontains(local.init_script, "setkey ConcurrentDatabaseReload no")
    error_message = "the reload setting reaches clamd.conf"
  }
}

run "stream_limit_too_small_for_the_fixtures" {
  command = plan
  variables {
    stream_max_bytes = 1048576
  }
  expect_failures = [var.stream_max_bytes]
}

run "stream_limit_above_the_scan_budget" {
  command = plan
  variables {
    stream_max_bytes = 209715200
  }
  expect_failures = [var.stream_max_bytes]
}

run "another_vpc_needs_a_subnet" {
  command = plan
  override_data {
    target = data.aws_vpc.client
    values = { default = false }
  }
  expect_failures = [aws_instance.scanner]
}
  • [ ] Step 2: Run it and watch it fail.
export TF_DATA_DIR="$TMPDIR/scanner-host/tf-scanner-host"
tofu -chdir=infra/modules/scanner-host init -backend=false -input=false
tofu -chdir=infra/modules/scanner-host test

Expected: FAIL at init or test: the module has no configuration yet.

  • [ ] Step 3: versions.tf and variables.tf.

versions.tf:

terraform {
  required_version = ">= 1.10"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.0"
    }
  }
}

variables.tf:

variable "name" {
  description = "Name for the instance, its security group, its IAM role and its instance profile (e.g. \"carlos-flagship-scanner\")."
  type        = string
}

variable "ami_id" {
  description = "Debian arm64 AMI. Pass the console box's own image, module.<host>.ami_id, so the two are built from the same base. Ignored after creation: a newer image never replaces the box by itself."
  type        = string
}

variable "client_security_group_id" {
  description = "Security group of the ONE box allowed to reach clamd: the console's. clamd has no authentication at all, so this group is the whole fence. The scanner's own group is created in this group's VPC."
  type        = string
}

variable "region" {
  description = "AWS region the box runs in (e.g. \"eu-west-1\"). The SSM agent package is served per region and the box must fetch its own."
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type (arm64 Graviton). clamd holds about 1.5 GB of signatures resident and briefly about twice that while reloading them, so t4g.medium is the default. A t4g.small works only with concurrent_database_reload = false."
  type        = string
  default     = "t4g.medium"
}

variable "stream_max_bytes" {
  description = "Largest object clamd accepts on a stream (StreamMaxLength), in bytes. Set the console's CARLOS_CLAMD_MAX_BYTES to this module's stream_max_bytes output: the two must be equal. Between 8 MiB and 100 MiB: the box's self-test streams fixtures of about 5 MB, and a whole stream must fit the 100 MiB scan budget."
  type        = number
  default     = 26214400
  validation {
    condition     = var.stream_max_bytes >= 8388608 && var.stream_max_bytes <= 104857600
    error_message = "stream_max_bytes must be between 8388608 (8 MiB) and 104857600 (100 MiB)."
  }
}

variable "memory_max" {
  description = "clamd's cgroup ceiling (systemd MemoryMax). Under memory pressure the kernel kills clamd, never the SSM agent that is the only way onto the box."
  type        = string
  default     = "3G"
}

variable "memory_high" {
  description = "clamd's throttle point (systemd MemoryHigh), about 85% of memory_max."
  type        = string
  default     = "2560M"
}

variable "concurrent_database_reload" {
  description = "Keep scanning while freshclam's new signatures load, at the cost of holding both databases in memory at once. False pauses scanning during each reload and fits a smaller box."
  type        = bool
  default     = true
}

variable "subnet_id" {
  description = "Subnet to launch in. Null (the default) is the default subnet of the default VPC, and is refused unless the client security group lives in the default VPC. The subnet needs a route to the internet: the box fetches signatures and packages."
  type        = string
  default     = null
}
  • [ ] Step 4: main.tf and outputs.tf.

main.tf:

# 🤖 A CARLOS scanner host: one box running clamd for a deployment's console.
#
# The console reads each object from S3 and streams it here. This box reads
# nothing for itself: its role has Session Manager and nothing else, so a
# compromised clamd cannot fetch a tenant object or a key.
#
# What makes it more than "install clamav" is in scanner-init.sh.tftpl and
# files/: TCP 3310 is open only while a self-test has passed against the clamd
# process running now. See README.md.

data "aws_security_group" "client" {
  id = var.client_security_group_id
}

data "aws_vpc" "client" {
  id = data.aws_security_group.client.vpc_id
}

locals {
  # Fixed in this release. The self-test's fixtures and expected answers are
  # sized for it; making it a variable means deriving both.
  scan_limit = "100M"

  small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type))

  init_script = templatefile("${path.module}/scanner-init.sh.tftpl", {
    region            = var.region
    stream_max_bytes  = var.stream_max_bytes
    scan_limit        = local.scan_limit
    memory_max        = var.memory_max
    memory_high       = var.memory_high
    concurrent_reload = var.concurrent_database_reload ? "yes" : "no"
    selftest_b64      = base64encode(file("${path.module}/files/scanner-selftest"))
    selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service"))
    tcp_socket_b64    = base64encode(file("${path.module}/files/scanner-tcp.socket"))
    tcp_service_b64   = base64encode(file("${path.module}/files/scanner-tcp.service"))
  })
}

resource "aws_security_group" "scanner" {
  name        = var.name
  description = "clamd, reachable from the console box only"
  vpc_id      = data.aws_security_group.client.vpc_id

  # The whole fence. clamd authenticates nobody, so nothing but the console's
  # own group may reach it, and never a CIDR.
  ingress {
    description     = "clamd INSTREAM from the console"
    from_port       = 3310
    to_port         = 3310
    protocol        = "tcp"
    security_groups = [var.client_security_group_id]
  }

  # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin
  # access is SSM Session Manager.
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = var.name }
}

resource "aws_iam_role" "scanner" {
  name = var.name
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "scanner_ssm" {
  role       = aws_iam_role.scanner.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone,
# this box could read the deployment's scan tag key and its release key. An
# explicit Deny beats the managed Allow and names only the parameter-read
# actions, so Session Manager is untouched. Same shape as region-host's
# host_ssm_parameter_scope, with no parameter allowed at all.
resource "aws_iam_role_policy" "no_parameters" {
  name = "${var.name}-no-parameters"
  role = aws_iam_role.scanner.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Deny"
        Action = [
          "ssm:GetParameter",
          "ssm:GetParameters",
          "ssm:GetParameterHistory",
          "ssm:GetParametersByPath",
        ]
        Resource = "*"
      },
    ]
  })
}

resource "aws_iam_instance_profile" "scanner" {
  name = var.name
  role = aws_iam_role.scanner.name
}

resource "aws_instance" "scanner" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [aws_security_group.scanner.id]
  iam_instance_profile   = aws_iam_instance_profile.scanner.name

  # Gzipped: the script carries the self-test and three units, and EC2 caps
  # user_data at 16 KiB. cloud-init unpacks it.
  user_data_base64            = base64gzip(local.init_script)
  user_data_replace_on_change = false

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
    encrypted   = true
  }

  tags = { Name = var.name }

  # A newer image or an edited script never replaces the box by itself.
  # Changing either, including stream_max_bytes, is an explicit
  # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box
  # has a new private IP: update the console's CARLOS_CLAMD_ADDR.
  lifecycle {
    ignore_changes = [ami, user_data, user_data_base64]

    precondition {
      condition     = var.subnet_id != null || data.aws_vpc.client.default
      error_message = "The client security group is not in the default VPC, so the scanner cannot use the default subnet: its security group and its instance would be in different VPCs. Pass subnet_id naming a subnet in the client group's VPC."
    }

    precondition {
      condition     = !(local.small_box && var.concurrent_database_reload)
      error_message = "A t4g.small or smaller cannot hold two signature databases at once. Either use t4g.medium, or set concurrent_database_reload = false (scanning pauses during each reload)."
    }
  }
}

outputs.tf:

output "clamd_addr" {
  description = "Where the console reaches clamd: set it as CARLOS_CLAMD_ADDR. A private address, reachable only from the client security group. It changes if the box is replaced."
  value       = "${aws_instance.scanner.private_ip}:3310"
}

output "stream_max_bytes" {
  description = "clamd's StreamMaxLength in bytes: set it as CARLOS_CLAMD_MAX_BYTES on the console."
  value       = var.stream_max_bytes
}

output "instance_id" {
  description = "EC2 instance id of the scanner box, for SSM."
  value       = aws_instance.scanner.id
}

output "security_group_id" {
  description = "Security group id of the scanner box."
  value       = aws_security_group.scanner.id
}
  • [ ] Step 5: the self-test. Create infra/modules/scanner-host/files/scanner-selftest (mode 0755 in git: chmod +x before git add):
#!/bin/bash
# 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for
# an archive it could only partly scan.
#
# scanner-selftest.service runs this every time clamd starts, and the TCP
# listener is bound to that unit, so a failure here keeps port 3310 shut. It
# talks to clamd on its unix socket: the port it guards is not open yet.
#
# Why it exists: with ClamAV's packaged limits, a zip member that expands past
# MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with
# no limit raised. The box is configured so that cannot happen (both limits
# equal, AlertExceedsMax on). This checks that the configuration still does
# what it did when it was chosen, on whatever version is installed today.
#
# Fixtures are sized for a 100 MiB scan limit. Members are written from a temp
# file so their zip headers are ordinary; streamed members get zip64 headers,
# which clamd does not open at all.
set -u
sock=/run/clamav/clamd.ctl

if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then
  echo "scanner-selftest: FAIL (forced, for the acceptance check)"
  exit 1
fi

d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1
trap 'rm -rf "$d"' EXIT
echo "LocalSocket $sock" > "$d/clamdscan.conf"

python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; }
import io, gzip, os, random, sys, tarfile, zipfile
d = sys.argv[1]
fx = os.path.join(d, 'fx')
os.mkdir(fx)
EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'

def block(seed):
    # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and
    # differs per seed, so clamd's clean cache cannot wave a member through.
    r = random.Random(seed)
    return bytes(r.getrandbits(8) for _ in range(16384)) * 64

def bigfile(name, mb, seed):
    p = os.path.join(d, name)
    blk = block(seed)
    with open(p, 'wb') as f:
        for _ in range(mb):
            f.write(blk)
    return p

def mkzip(name, build):
    with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z:
        build(z)

open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n')
open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR)
big = bigfile('big.bin', 500, 1)
mid = bigfile('mid.bin', 60, 5)
mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin'))
with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z:
    z.write(big, 'big.bin')
mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip'))
def many(z):
    for i in range(30):
        z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i)
mkzip('05-zip-30x20.zip', many)
with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g:
    blk = block(2)
    for _ in range(500):
        g.write(blk)
with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t:
    for i in range(5):
        data = block(200 + i) * 40
        ti = tarfile.TarInfo('t%d.bin' % i)
        ti.size = len(data)
        t.addfile(ti, io.BytesIO(data))
def big_then_eicar(z):
    z.write(big, 'big.bin')
    z.writestr('eicar.com', EICAR)
mkzip('08-zip-500-then-eicar.zip', big_then_eicar)
def mid_then_eicar(z):
    z.write(mid, 'mid.bin')
    z.writestr('eicar.com', EICAR)
mkzip('09-zip-60-then-eicar.zip', mid_then_eicar)
mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin'))
PY

# What each fixture must answer. "limit" is the one that matters: an OK there
# is a truncated scan signed clean.
want() {
  case $1 in
    01 | 10) echo ok ;;
    02 | 09) echo infected ;;
    *) echo limit ;;
  esac
}

fail=0
for f in "$d"/fx/*; do
  n=$(basename "$f")
  exp=$(want "${n%%-*}")
  reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1)
  case $reply in
    *"Heuristics.Limits.Exceeded"*" FOUND") got=limit ;;
    *" FOUND") got=infected ;;
    *": OK") got=ok ;;
    *) got=error ;;
  esac
  if [ "$got" = "$exp" ]; then
    echo "scanner-selftest: pass  $n  ($got)"
  else
    echo "scanner-selftest: FAIL  $n  want $exp, got $got: $reply"
    fail=1
  fi
done

if [ $fail -eq 0 ]; then
  echo "scanner-selftest: PASS"
else
  echo "scanner-selftest: FAIL"
fi
exit $fail
  • [ ] Step 6: the three units.

files/scanner-selftest.service:

# 🤖 Runs the self-test against the clamd that is running now. BindsTo makes it
# go inactive the moment clamd stops or restarts, and the TCP listener is bound
# to THIS unit, so the port follows the test, not the boot.
[Unit]
Description=Prove this clamd never answers OK for an over-limit archive
BindsTo=clamav-daemon.service
After=clamav-daemon.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/scanner-selftest
TimeoutStartSec=1800

[Install]
WantedBy=multi-user.target

files/scanner-tcp.socket (@PRIVATE_IP@ is replaced at first boot):

# 🤖 clamd on TCP, for the console. Open only behind a passed self-test.
#
# DefaultDependencies=no is load-bearing. A socket unit is ordered before
# sockets.target by default, sockets.target before basic.target, and every
# service (the self-test included) 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 this port shut after a reboot.
# So it opts out, restores what it still needs, and is started by
# multi-user.target and by clamd's Wants=, never by sockets.target.
[Unit]
Description=clamd on TCP, open only behind a passed self-test
DefaultDependencies=no
After=sysinit.target
Requires=scanner-selftest.service
After=scanner-selftest.service
BindsTo=scanner-selftest.service
Conflicts=shutdown.target
Before=shutdown.target

[Socket]
ListenStream=@PRIVATE_IP@:3310

[Install]
WantedBy=multi-user.target

files/scanner-tcp.service:

# 🤖 Carries each TCP connection to clamd's unix socket. Bound to the self-test
# like the socket is, so connections in flight are cut when the test's unit
# goes inactive.
[Unit]
Description=Proxy TCP 3310 to clamd's unix socket
Requires=scanner-selftest.service
After=scanner-selftest.service
BindsTo=scanner-selftest.service

[Service]
ExecStart=/usr/lib/systemd/systemd-socket-proxyd /run/clamav/clamd.ctl
DynamicUser=yes
SupplementaryGroups=clamav
PrivateTmp=yes
NoNewPrivileges=yes
  • [ ] Step 7: the first-boot script. Create infra/modules/scanner-host/scanner-init.sh.tftpl. It is a templatefile: $${...} would be needed for a literal bash ${...}, so the bash below uses $name and $(...) forms only. Keep it that way.
#!/bin/bash
# 🤖 First-boot init for a CARLOS scanner host (Debian arm64). Rendered by
# infra/modules/scanner-host. Each step says where it got to on the serial
# console, so `aws ec2 get-console-output` answers "how far did it get".
#
# The order is the design:
#   1. SSM agent, because with no SSH it is the only way in to fix the rest.
#   2. Packages installed but NOT started.
#   3. Config, the self-test and the gate units.
#   4. First signatures, alone, before anything else touches the database.
#   5. Start: clamd, then the self-test, then (only on a pass) the port.
#   6. Verify from the outside of the gate, and say READY or FAILED.
set -euo pipefail

say() { echo "scanner-init: $*" | tee /dev/console; }
fail() { say "SCANNER-HOST FAILED: $*"; exit 1; }
trap 'fail "line $LINENO"' ERR
export DEBIAN_FRONTEND=noninteractive

# ---- 1. the way in ---------------------------------------------------------
say "1/6 SSM agent"
apt-get update -q
apt-get install -qy curl unattended-upgrades
curl -fsSL -o /var/tmp/amazon-ssm-agent.deb \
  "https://s3.${region}.amazonaws.com/amazon-ssm-${region}/latest/debian_arm64/amazon-ssm-agent.deb"
dpkg -i /var/tmp/amazon-ssm-agent.deb
rm -f /var/tmp/amazon-ssm-agent.deb
systemctl enable --now amazon-ssm-agent
systemctl enable --now unattended-upgrades

# ---- 2. packages, not started ----------------------------------------------
# Debian's postinst starts clamav-freshclam and the daemon. Neither may run
# before step 3 has configured it, and freshclam's service would fight the
# foreground run in step 4 for the database lock.
say "2/6 clamav packages"
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
apt-get install -qy clamav-daemon clamav-freshclam clamdscan python3 iproute2
rm -f /usr/sbin/policy-rc.d

# ---- 3. config, self-test, gate --------------------------------------------
say "3/6 config and gate"
conf=/etc/clamav/clamd.conf
# clamd has no include directory: replace each key in place.
setkey() {
  sed -i "/^$1[[:space:]]/d" "$conf"
  echo "$1 $2" >> "$conf"
}
setkey StreamMaxLength ${stream_max_bytes}
setkey AlertExceedsMax yes
setkey MaxFileSize ${scan_limit}
setkey MaxScanSize ${scan_limit}
setkey ConcurrentDatabaseReload ${concurrent_reload}
# Debian 13 mounts /tmp as tmpfs: extraction of up to the scan limit per scan
# belongs on disk, not in memory.
setkey TemporaryDirectory /var/tmp
# clamd itself never listens on TCP. The gated proxy does.
sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' "$conf"

echo '${selftest_b64}' | base64 -d > /usr/local/sbin/scanner-selftest
chmod 0755 /usr/local/sbin/scanner-selftest
echo '${selftest_unit_b64}' | base64 -d > /etc/systemd/system/scanner-selftest.service
echo '${tcp_service_b64}' | base64 -d > /etc/systemd/system/scanner-tcp.service

tok=$(curl -fsS -X PUT http://169.254.169.254/latest/api/token \
  -H 'X-aws-ec2-metadata-token-ttl-seconds: 300')
ip=$(curl -fsS -H "X-aws-ec2-metadata-token: $tok" \
  http://169.254.169.254/latest/meta-data/local-ipv4)
case $ip in
  [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;;
  *) fail "no private IPv4 from instance metadata: '$ip'" ;;
esac
echo '${tcp_socket_b64}' | base64 -d | sed "s/@PRIVATE_IP@/$ip/" \
  > /etc/systemd/system/scanner-tcp.socket
grep -q "^ListenStream=$ip:3310\$" /etc/systemd/system/scanner-tcp.socket \
  || fail "the listener is not bound to the private address"

mkdir -p /etc/systemd/system/clamav-daemon.service.d
cat > /etc/systemd/system/clamav-daemon.service.d/10-scanner.conf <<EOF
# Every start of clamd, an automatic restart included, asks for the gate again.
[Unit]
Wants=scanner-tcp.socket

# The kernel kills clamd under memory pressure, never the SSM agent.
[Service]
MemoryMax=${memory_max}
MemoryHigh=${memory_high}
Restart=on-failure
EOF
systemctl daemon-reload

# ---- 4. first signatures ---------------------------------------------------
say "4/6 first signatures"
# NotifyClamd would complain that no clamd is running yet. It is not.
sed '/^NotifyClamd/d' /etc/clamav/freshclam.conf > /run/freshclam-first.conf
freshclam --config-file=/run/freshclam-first.conf
rm -f /run/freshclam-first.conf
for db in main daily bytecode; do
  ls /var/lib/clamav/$db.c[vl]d >/dev/null 2>&1 || fail "no $db signature database after freshclam"
done
systemctl enable --now clamav-freshclam

# ---- 5. start, in the order the units enforce ------------------------------
say "5/6 clamd, self-test, listener (several minutes)"
systemctl enable clamav-daemon.service scanner-selftest.service scanner-tcp.socket
systemctl start clamav-daemon.socket clamav-daemon.service
# Blocks until the self-test has finished. Fails if it failed.
systemctl start scanner-tcp.socket \
  || fail "self-test or listener did not start: $(systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket | tr '\n' ' ')"

# ---- 6. verify from outside the gate ---------------------------------------
say "6/6 verify"
[ "$(systemctl is-active scanner-selftest.service)" = active ] || fail "self-test is not active"
[ "$(systemctl is-active scanner-tcp.socket)" = active ] || fail "listener is not active"
bound=$(ss -ltnH 'sport = :3310' | awk '{print $4}' | sort -u | tr '\n' ' ')
[ "$bound" = "$ip:3310 " ] || fail "3310 is bound to '$bound', want only $ip:3310"
exec 3<>"/dev/tcp/$ip/3310"
printf 'zVERSION\0' >&3
ver=$(timeout 20 head -c 200 <&3 | tr -d '\0')
exec 3>&-
case $ver in
  ClamAV*) ;;
  *) fail "no VERSION answer through the listener: '$ver'" ;;
esac
mkdir -p /var/lib/scanner-host
echo "$ver" > /var/lib/scanner-host/ready
say "SCANNER-HOST READY $ver"
  • [ ] Step 8: Run the tests and the formatter.

Run: tofu -chdir=infra/modules/scanner-host test then tofu fmt -check -recursive infra/modules/scanner-host then bash -n infra/modules/scanner-host/files/scanner-selftest Expected: 9 passed, 0 failed; fmt and bash -n print nothing. If an assertion fails because a mocked attribute is null or unknown at plan time (the ingress block attributes are the likely one), adjust the assertion's try(...), never the module's behaviour, and say which in the commit.

  • [ ] Step 9: Check the rendered script is valid bash and has no stray template syntax.
mkdir -p "$TMPDIR/scanner-host/render"
cat > "$TMPDIR/scanner-host/render/main.tf" <<'EOF'
output "script" {
  value = templatefile("${path.module}/../../../infra/modules/scanner-host/scanner-init.sh.tftpl", {
    region = "eu-west-1", stream_max_bytes = 26214400, scan_limit = "100M",
    memory_max = "3G", memory_high = "2560M", concurrent_reload = "yes",
    selftest_b64 = "QQ==", selftest_unit_b64 = "QQ==", tcp_socket_b64 = "QQ==", tcp_service_b64 = "QQ==",
  })
}
EOF

Adjust the relative path so it resolves to this worktree's template (an absolute path is fine), then:

Run: tofu -chdir="$TMPDIR/scanner-host/render" init -input=false >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" apply -auto-approve >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" output -raw script > "$TMPDIR/scanner-host/render/init.sh" && bash -n "$TMPDIR/scanner-host/render/init.sh" && grep -c 'setkey MaxFileSize 100M' "$TMPDIR/scanner-host/render/init.sh" Expected: 1, and no bash syntax error.

  • [ ] Step 10: Commit. Confirm no .terraform directory or lock file is staged.
chmod +x infra/modules/scanner-host/files/scanner-selftest
git add infra/modules/scanner-host
git commit -m "scanner-host: a clamd box whose port is open only behind a passed self-test" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

---

Task 4: docs, through copy review

Files:

  • Create: infra/modules/scanner-host/README.md
  • Modify: infra/modules/README.md (new ## scanner-host section), infra/modules/region-host/README.md (one paragraph on store_scanning), docs/site/cli.md (the operator table near line 1088 and the "What status will and will not claim" paragraph near line 1071), docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md (a dated note at the top)

Interfaces:

  • Consumes: every name Task 3 produces.
  • [ ] Step 1: Draft, do not write. Use the humane-docs skill for the drafting voice. Content each piece must carry: - scanner-host/README.md: what the box is and that it reads nothing itself; the usage block (module "scanner" with ami_id = module.host_ie.ami_id, client_security_group_id = module.host_ie.security_group_id, region); the two outputs the console needs and the variables they become (clamd_addr to CARLOS_CLAMD_ADDR, stream_max_bytes to CARLOS_CLAMD_MAX_BYTES) plus CARLOS_SCAN_TAG_KEY; replacement changes the private IP (spec §3.6), first; the invariant in one paragraph (§3.2); why the two limits are equal and fixed, with the table from §3.4; what a clean verdict does not cover (zip64 and encrypted members); how to check a box is ready (cloud-init status --wait, /var/lib/scanner-host/ready, systemctl is-active scanner-selftest.service scanner-tcp.socket); the four acceptance checks from §3.3 with their commands (Task 7 has them); sizing; that the subnet needs a route out. - infra/modules/README.md: a short ## scanner-host section in the style of the deployment-buckets one, with the variable table and outputs. - region-host/README.md: store_scanning in three sentences: what it grants, that it is off by default, that it goes on the console's box only. - docs/site/cli.md: the operator table is stale. Replace the CARLOS_STORE_SCANNER and CARLOS_CLAMD_SOCKET rows with CARLOS_CLAMD_ADDR (host:port, or a socket path beginning /) and CARLOS_SCAN_TAG_KEY (required once an address is set; signs every verdict), say the switch is carlos system antivirus enable, and change "six environment variables" to the true count. In the status paragraph add one sentence: an archive that expands past the engine's limits is counted as not scanned, the same as an object too large to stream. - Rollout plan note, at the top: > **2026-10-05:** steps 2 and 4 are superseded by docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md. clamd runs on its own box, the switch is carlos system antivirus enable, and CARLOS_STORE_SCANNER is deprecated. Steps 5 to 8 and the watch list still stand. (Internal plan text, not user-facing: no copy review for this one.)
  • [ ] Step 2: Copy review. Invoke the copy-review skill with every drafted string except the rollout-plan note. Wait for Paul's edits. Apply them verbatim.
  • [ ] Step 3: Write the files with the approved text.
  • [ ] Step 4: Verify the docs gate.

Run: go test ./internal/docsite -count=1 Expected: ok.

  • [ ] Step 5: Commit.
git add infra/modules/scanner-host/README.md infra/modules/README.md infra/modules/region-host/README.md docs/site/cli.md docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
git commit -m "docs: the scanner host, store_scanning, and the scanner's real variables" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

---

Task 5: gate, review, discussion, merge

Files: none new.

  • [ ] Step 1: Full gate.

Run: go build ./... && go vet ./... && go test ./... -count=1 Expected: all ok. If the activator's socket tests fail on path length or /tmp fills, use the -exec wrapper recorded in memory (gate-tmp-exec-wrapper); do not skip packages. Report any failure with its output.

  • [ ] Step 2: Cross-compile, as CI does.

Run: GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./... Expected: no output.

  • [ ] Step 3: Open the tracking discussion. Invoke the amadan skill, then create a discussion on carlos/platform titled Scanner host: object-storage scanning on Carloku and Tito, linking the spec, this plan and the branch, and recording the two follow-ups from spec §11 (the zip64 and encrypted-archive disclosure; a configurable scan limit). Note its number; later tasks post progress there.
  • [ ] Step 4: Astra code review. The session model is Opus, so the reviewer is Astra via codex (memory codex-second-reader has the working command; set model_reasoning_effort="high" and read the header line). Commit a brief as docs/superpowers/specs/CODEX-REVIEW-BRIEF-SCANNER-HOST-CODE.md asking it to review origin/main..HEAD against the spec, with special attention to: the systemd dependency design in files/, the first-boot script's failure paths, the parseReply change, and the IAM statements. Commit findings unedited as CODEX-FINDINGS-SCANNER-HOST-CODE.md. Verify every citation before accepting a finding (receiving-code-review skill). Fix, re-run the affected tests, and repeat until the verdict is accept.
  • [ ] Step 5: Push the branch. git push origin worktree-scanner-host, then amadan ci status carlos/platform until green.
  • [ ] Step 6 (GATED): merge. Show Paul the diffstat and the review verdict. On his go: amadan branch merge -squash per the amadan skill and memory carlos-platform-forge. Record the main SHA in the discussion.
  • [ ] Step 7 (GATED): move the GitHub mirror. Only a fast-forward:
git fetch https://github.com/carlosframework/platform.git main
git merge-base --is-ancestor FETCH_HEAD origin/main && git push https://github.com/carlosframework/platform.git origin/main:refs/heads/main

Expected: the push succeeds. If the ancestor check fails, stop and tell Paul; never force.

---

Task 6 (GATED): release v0.24.1

> 2026-10-06: v0.23.0 had already been cut (3ca43d3) before the scanner merged, so the scanner first shipped in v0.24.0. v0.24.0's first boot failed on AppArmor: freshclam may only read its config under /etc/clamav. The fix ships in v0.24.1, which is the pin used below.

Carloku pins modules to release tags, so the modules reach it through a release. The release carries everything on main since v0.22.2, not only this work, so whether and when to cut it is Paul's call. Ask before starting.

  • [ ] Step 1: List what the release contains: git log --oneline v0.22.2..origin/main. Show Paul. Wait for his go.
  • [ ] Step 2: Follow README.md "Releasing the platform" exactly (steps 1 to 9 and the public-side verification), with memory platform-release-recipe for the traps. Release notes go through copy-review. The operators' section says: object-storage scanning needs a scanner host (infra/modules/scanner-host) and store_scanning on the console's region-host; a console must run this release before it is pointed at a scanner host, because older consoles record an over-limit archive as infected.
  • [ ] Step 3: Push the tag to the GitHub mirror: git push https://github.com/carlosframework/platform.git v0.24.1.
  • [ ] Step 4: Do not roll boxes as part of this plan. The box binary is unaffected; only consoles need the new build (Tasks 8 and 12). If Paul wants the fleet on v0.24.1 too, that is the README's last step and his separate go.

---

Task 7 (GATED): Carloku, the scanner box

Files:

  • ~/github.com/carlosframework/carloku-infrastructure/region-ie.tf (modify: host_ie's ?ref=, add store_scanning = true)
  • ~/github.com/carlosframework/carloku-infrastructure/scanner.tf (create)
  • ~/github.com/carlosframework/carloku-infrastructure/outputs.tf (modify)
  • [ ] Step 1: Branch and edit. In that repo: git checkout -b scanner-host origin/main. In region-ie.tf, change host_ie's source to ?ref=v0.24.1 (that module block only; the other three regions stay on v0.21.0) and add, after store_bucket_prefix:
  # Object-storage virus scanning (platform spec 2026-10-04): lets THIS box's
  # role read and tag store objects. The console is the only thing that scans.
  store_scanning = true

Create scanner.tf:

# The flagship's scanner host: clamd for the console's object-storage virus
# scanning, on its own box so its signatures and CPU stay off the control
# plane. Reachable from the IE host's security group only. The module's README
# has the readiness and acceptance checks; run them before wiring the console.
module "scanner" {
  source                   = "git::https://github.com/carlosframework/platform.git//infra/modules/scanner-host?ref=v0.24.1"
  name                     = "carlos-flagship-scanner"
  ami_id                   = module.host_ie.ami_id
  client_security_group_id = module.host_ie.security_group_id
  region                   = "eu-west-1"
}

Append to outputs.tf:

output "scanner_clamd_addr" {
  description = "CARLOS_CLAMD_ADDR for the flagship console."
  value       = module.scanner.clamd_addr
}

output "scanner_stream_max_bytes" {
  description = "CARLOS_CLAMD_MAX_BYTES for the flagship console."
  value       = module.scanner.stream_max_bytes
}

output "scanner_instance_id" {
  description = "The scanner box, for SSM."
  value       = module.scanner.instance_id
}
  • [ ] Step 2: Plan, targeted. (Sandbox off for the keymail profile.)
AWS_PROFILE=keymail tofu init -upgrade -input=false
AWS_PROFILE=keymail tofu plan -input=false -out="$TMPDIR/scanner-host/flagship.plan" \
  -target=module.scanner \
  -target='module.host_ie.aws_iam_role_policy.host_store_scan'

Expected: creations only: the scanner's security group, role, policy attachment, no_parameters policy, instance profile and instance, plus host_store_scan[0]. No update in-place, no replace, no user_data line on any existing box. If there is one, stop.

  • [ ] Step 3: Show Paul the plan (tofu show "$TMPDIR/scanner-host/flagship.plan"), and separately the full untargeted plan's summary line so he sees what the ref bump leaves pending on host_ie. Wait for his go.
  • [ ] Step 4: Apply that plan file. AWS_PROFILE=keymail tofu apply "$TMPDIR/scanner-host/flagship.plan". Record the three outputs. Commit the branch, push, open a PR on carloku-infrastructure listing what was applied and what the bump leaves unapplied.
  • [ ] Step 5: Wait for first boot. Create the SSM helper used from here on:
mkdir -p "$TMPDIR/scanner-host"
cat > "$TMPDIR/scanner-host/ssm-run.sh" <<'EOF'
#!/bin/sh
# usage: ssm-run.sh <profile> <region> <instance-id> '<command>'
# Runs one shell command on a box and prints its stdout, stderr and status.
set -eu
p=$1; r=$2; i=$3; c=$4
id=$(aws --profile "$p" --region "$r" ssm send-command --instance-ids "$i" \
  --document-name AWS-RunShellScript --parameters "$(jq -nc --arg c "$c" '{commands:[$c]}')" \
  --query Command.CommandId --output text)
aws --profile "$p" --region "$r" ssm wait command-executed --command-id "$id" --instance-id "$i" 2>/dev/null || true
aws --profile "$p" --region "$r" ssm get-command-invocation --command-id "$id" --instance-id "$i" \
  --query '[Status,StandardOutputContent,StandardErrorContent]' --output text
EOF
chmod +x "$TMPDIR/scanner-host/ssm-run.sh"

Then poll the serial console until it says one or the other (first boot takes several minutes: packages, about 300 MB of signatures, the self-test):

Run: aws --profile keymail --region eu-west-1 ec2 get-console-output --instance-id <scanner-id> --latest --output text | grep 'SCANNER-HOST' Expected: SCANNER-HOST READY ClamAV 1.4.x/<sig>/<date>, with the date today or yesterday. On SCANNER-HOST FAILED: <reason>, read the scanner-init: lines above it and fix the module (back to Task 3), not the box.

  • [ ] Step 6: Readiness.

Run: "$TMPDIR/scanner-host/ssm-run.sh" keymail eu-west-1 <scanner-id> 'cloud-init status --wait; cat /var/lib/scanner-host/ready; systemctl is-active scanner-selftest.service scanner-tcp.socket; journalctl -u scanner-selftest.service --no-pager | tail -14; aa-status 2>/dev/null | grep -c /usr/sbin/clamd; journalctl -k --no-pager | grep -c "apparmor=\"DENIED\".*clamd"' Expected: status: done; the VERSION line; active twice; ten pass lines and scanner-selftest: PASS; then 1 or more (clamd's AppArmor profile is loaded) and 0 (it denied clamd nothing, so the /var/tmp local include took).

Then prove a package reconfigure leaves clamd.conf alone (debconf is frozen off; with it on, the postinst would rewrite StreamMaxLength):

Run: ... ssm-run.sh keymail eu-west-1 <scanner-id> 'debconf-show clamav-daemon | grep "clamav-daemon/debconf"; dpkg-reconfigure -fnoninteractive clamav-daemon; grep -E "^(StreamMaxLength|MaxFileSize|MaxScanSize|AlertExceedsMax|TemporaryDirectory) " /etc/clamav/clamd.conf; systemctl restart clamav-daemon.service; for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done; echo "after reconfigure: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH "sport = :3310" | wc -l) listeners"' Expected: a line ending clamav-daemon/debconf: false; these five lines, in any order: StreamMaxLength 25M, AlertExceedsMax yes, MaxFileSize 100M, MaxScanSize 100M, TemporaryDirectory /var/tmp (init appends them in that order, so expect that one, but any order is a pass); then after reconfigure: socket active, selftest active, 1 listeners. The reconfigure moves Debian's extend.conf drop-in aside (postinst, debconf off), so the restart also proves clamd starts on the module's own drop-in. Anything else: stop, fix in Task 3, replace the box.

  • [ ] Step 7: Acceptance check, kill. The port follows the test.

Run: ... ssm-run.sh keymail eu-west-1 <scanner-id> 'systemctl kill -s KILL clamav-daemon.service; sleep 3; echo "after kill: $(ss -ltnH "sport = :3310" | wc -l) listeners, selftest $(systemctl is-active scanner-selftest.service)"; for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done; echo "recovered: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH "sport = :3310" | wc -l) listeners"' Expected: after kill: 0 listeners, selftest anything but active; then recovered: socket active, selftest active, 1 listeners. If the port is still open right after the kill, or never reopens, the dependency design is wrong on this systemd: stop, fix in Task 3, replace the box.

  • [ ] Step 8: Acceptance check, forced fail. A failed test keeps the port shut.

Run, in one command: write /etc/systemd/system/scanner-selftest.service.d/99-force-fail.conf containing [Service] and Environment=SCANNER_SELFTEST_FORCE_FAIL=1; systemctl daemon-reload; systemctl restart clamav-daemon.service; wait 90 seconds; print systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket and the listener count; then remove the drop-in, daemon-reload, systemctl restart clamav-daemon.service, wait until scanner-tcp.socket is active (up to 10 minutes), and print the same again. Expected first print: active, failed, inactive (or failed), 0 listeners. Second: active three times, 1 listener.

  • [ ] Step 9: Acceptance check, the protocol through the proxy, from the console box.

Run: ... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'exec 3<>/dev/tcp/<scanner-private-ip>/3310; printf "zINSTREAM\0\0\0\0\104X5O!P%%@AP[4\\PZX54(P^)7CC)7}\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\$H+H*\0\0\0\0" >&3; timeout 20 head -c 200 <&3 | tr -d "\0"; echo' (\0\0\0\104 is the 68-byte chunk length, octal 104 = 68; the four zero bytes after the payload end the stream.) Expected: stream: Eicar-Test-Signature FOUND. If the nested quoting fights you, send the same bytes with a three-line python3 socket script instead; the bytes are what matter. Also confirm the fence from somewhere that is not the console box: the same connect attempt from another fleet box times out.

  • [ ] Step 10: Acceptance check, reboot.

Run: aws --profile keymail --region eu-west-1 ec2 reboot-instances --instance-ids <scanner-id>, wait five minutes, then ... ssm-run.sh keymail eu-west-1 <scanner-id> 'systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket; ss -ltnH "sport = :3310" | wc -l; journalctl -b --no-pager | grep -ci "ordering cycle"; systemd-analyze verify /etc/systemd/system/scanner-selftest.service /etc/systemd/system/scanner-tcp.socket /etc/systemd/system/scanner-tcp.service 2>&1 | grep -v "^$" | head' Expected: active three times, 1, 0, and nothing from systemd-analyze verify about these three units.

  • [ ] Step 11: Record all four results in the discussion, with the VERSION line.

---

Task 8 (GATED): Carloku, the console on the new build

  • [ ] Step 1: A clean checkout of the v0.24.1 tag (scratch clone under ~/.claude/tmp/carlos-build/, as memory platform-roll-recipe describes). git status --porcelain must print nothing.
  • [ ] Step 2: Show Paul the command. On his go, from carloku-infrastructure: GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> flagship. Expected: it builds arm64, ships, promotes, adopts, restarts, and its own check prints the running executable as .../versions/<sha>/console for the SHA it built. If that check fails, stop: nothing gets wired.
  • [ ] Step 3: carlos system antivirus status --console https://console.carloku.com Expected: it answers, and says no engine is wired. (Wiring is Task 9.)

---

Task 9 (GATED): Carloku, key and wiring

  • [ ] Step 1: Preconditions, both. Task 7's readiness and four acceptance checks are recorded as passed. Task 8's executable check passed for the build with the Task 1 change: git merge-base --is-ancestor <task-1-commit-on-main> v0.24.1 exits 0.
  • [ ] Step 2: Find the console's environment file.

Run: ... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl cat console.service | grep -i EnvironmentFile; systemctl show -p User --value console.service' Expected: one EnvironmentFile= path (written <envfile> below) and the unit's user.

  • [ ] Step 3: Generate the key straight into SSM. It never touches argv, a file or the terminal:
openssl rand -hex 32 \
  | jq -Rc '{Name:"/carlos/scan-tag-key",Type:"SecureString",Value:.,Overwrite:false,Description:"Signs every object-storage scan verdict. Losing it makes every stored verdict unverifiable."}' \
  | aws --profile keymail --region eu-west-1 ssm put-parameter --cli-input-json file:///dev/stdin

Expected: "Version": 1. Overwrite:false means a rerun fails with ParameterAlreadyExists instead of replacing the key; that failure is correct, leave the key alone.

  • [ ] Step 4: Show Paul the three variables (the key as a name only), the file, and that the next step restarts the console for a few seconds. Wait for his go.
  • [ ] Step 5: Write the variables on the box. The box fetches the key itself, so it never crosses the SSM command channel:
set -eu
f=<envfile>
cp -p "$f" "$f.bak-scan-$(date +%Y%m%d)"
key=$(aws ssm get-parameter --region eu-west-1 --name /carlos/scan-tag-key --with-decryption --query Parameter.Value --output text)
[ ${#key} -eq 64 ] || { echo "key is not 64 hex chars"; exit 1; }
grep -v '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=\|^CARLOS_CLAMD_SOCKET=' "$f" > "$f.new"
printf 'CARLOS_CLAMD_ADDR=%s\nCARLOS_CLAMD_MAX_BYTES=%s\nCARLOS_SCAN_TAG_KEY=%s\n' '<scanner_clamd_addr>' '<scanner_stream_max_bytes>' "$key" >> "$f.new"
chmod --reference="$f" "$f.new"; chown --reference="$f" "$f.new"; mv "$f.new" "$f"
grep -c '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=' "$f"
grep -c '^CARLOS_STORE_SCANNER=' "$f" || true

Expected: 3, then 0. Nothing prints the key.

  • [ ] Step 6: Restart and check.

Run: ... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl restart console; sleep 8; systemctl is-active console; readlink /proc/$(systemctl show -p MainPID --value console)/exe; journalctl -u console --since "-1min" --no-pager | grep -i "clamd\|scan\|fatal" | tail -5' Expected: active; the same versions/<sha>/console; no fatal line. If it is not active: cp -p <envfile>.bak-scan-<date> <envfile> && systemctl reset-failed console && systemctl restart console, confirm active, stop, and tell Paul with the journal lines.

  • [ ] Step 7: Status, then enable.

Run: carlos system antivirus status --console https://console.carloku.com Expected: engine wired, answering, signature date today or yesterday, policy off. Show Paul. On his go: carlos system antivirus enable --console https://console.carloku.com, then status again. Expected: policy on. No app is granted, so the hourly pass does nothing.

---

Task 10 (GATED): Carloku, the proof

On a throwaway app Paul's account owns. Ask Paul which account to create it under, and confirm before creating and before destroying.

  • [ ] Step 1: Make the app and its store. Create an app (name scan-proof-<date>), then carlos store create --app <app> and carlos store status --app <app> to learn the bucket name.
  • [ ] Step 2: Build the fixtures locally by running the fixture half of infra/modules/scanner-host/files/scanner-selftest (the python3 heredoc) into $TMPDIR/scanner-host/fx. Upload five with the operator profile: 01-clean.txt, 02-eicar.txt, 03-zip-one-500.zip, 08-zip-500-then-eicar.zip, 09-zip-60-then-eicar.zip: aws --profile keymail s3 cp "$TMPDIR/scanner-host/fx/<file>" s3://<bucket>/proof/<file>
  • [ ] Step 3: Request, grant, sweep.
carlos store scan request --app <app> --plaintext
carlos store scan grant --app <app>
carlos store scan sweep --app <app>
carlos store scan status --app <app>

Expected (spec §7 steps 1 and 2): a completed sweep; 5 objects; 01 clean; 02 and 09 infected; 03 and 08 skipped and counted unscanned. If 03 or 08 reads clean, stop the rollout: carlos system antivirus disable, and tell Paul.

  • [ ] Step 4: The tags. For 01 and 03: aws --profile keymail s3api get-object-tagging --bucket <bucket> --key proof/<file> and aws --profile keymail s3api head-object --bucket <bucket> --key proof/<file> --query ETag. Expected: carlos-scan, carlos-scan-version, carlos-scan-at present; the version tag carries the object's current ETag; 01 says clean, 03 says skipped.
  • [ ] Step 5: A rewrite invalidates the verdict. Overwrite proof/01-clean.txt with different bytes, sweep, status. Expected: the fresh count goes up by one; it is re-scanned, not carried forward. If carried forward, stop the rollout.
  • [ ] Step 6: Enforce survives the engine going away. carlos store scan enforce --app <app> on. Then, with Paul's go (two more console restarts): on the box, back up <envfile>, remove the CARLOS_CLAMD_ADDR line, restart; run carlos store scan enforce --app <app> off. Expected: it succeeds with no engine built. Restore the line from the backup, restart, antivirus status shows the engine wired again.
  • [ ] Step 7: Teardown. Destroy the app. Expected: aws --profile keymail s3 ls s3://carlos-flagship-271376211898/apps/ --recursive | grep '<app>.*scan.json' prints nothing, and the app name can be created again at once (create it, then destroy it).
  • [ ] Step 8: Record every result in the discussion. Scanning stays enabled on Carloku with no member granted.

---

Task 11 (GATED): Tito, the scanner box and the grant

Do not start until Task 10 has passed and #201 phase 2 has landed (check memory tito-console-store-boundary and the teamtito repo). Tito is a third party: every step here is shown to Paul first.

Files: ~/github.com/teamtito/carlos-infrastructure/object-store.tf (modify), scanner.tf (create), outputs.tf (modify)

  • [ ] Step 1: Branch from the line that can plan. git fetch origin, then branch scanner-host from the tip the last targeted apply ran from (store-boundary-201, or whatever has superseded it; main cannot plan while the Canada box is only on region-ca). Say which tip in the PR.
  • [ ] Step 2: Edit. In object-store.tf, add to host_ie_object_store's Statement list:
      {
        # Object-storage virus scanning (platform spec 2026-10-04): the console
        # reads each object to stream it to the scanner host, and writes the
        # verdict as tags. Inert until a member requests scanning for a store
        # and an operator grants it.
        Sid    = "StoreScan"
        Effect = "Allow"
        Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
        Resource = [
          "arn:aws:s3:::carlos-store-tito-*/*",
          "arn:aws:s3:::titogo-uploads-665106592142/*",
        ]
      },

Create scanner.tf as in Task 7 Step 1 with name = "carlos-tito-scanner", and the three outputs.

  • [ ] Step 3: Check the box can read its key. In region-ie.tf, read host_ie's ssm_parameter_arn. If /carlos/scan-tag-key falls outside it, the console box could not fetch the key in Task 12; add that one parameter ARN in the smallest way that file allows and include the resource in the targeted plan. Say so to Paul.
  • [ ] Step 4: Plan, targeted.
AWS_PROFILE=titogo tofu init -upgrade -input=false
AWS_PROFILE=titogo tofu plan -input=false -out="$TMPDIR/scanner-host/tito.plan" \
  -target=module.scanner -target=aws_iam_role_policy.host_ie_object_store

Expected: the scanner's six resources created; host_ie_object_store updated in place with exactly the StoreScan statement added (read the policy diff: any other difference is drift from #201, stop and ask). No change to any instance.

  • [ ] Step 5: Show Paul the plan. On his go, apply the plan file, push the branch, open the PR.
  • [ ] Step 6: Task 7 Steps 5 to 11 on Tito's scanner box (profile titogo, console box i-025a0b70f3d93304d): first boot, readiness, and all four acceptance checks.

---

Task 12 (GATED): Tito, console, key, wiring, enable

  • [ ] Step 1: Roll Tito's console to the same build as Task 8: GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> tito, with Paul's go. Its executable check must pass. Before rolling, diff Tito's console environment against the flagship's for variables the new build requires (the 2026-09-11 lesson): ... ssm-run.sh on each console box, cut -d= -f1 <envfile> | sort, and compare.
  • [ ] Step 2: Task 9 Steps 1 to 7 in Tito's account (profile titogo, region eu-west-1, /carlos/scan-tag-key, console https://carlos.tito.io). A new key: never copy the flagship's.
  • [ ] Step 3: Stop at antivirus status (wired, answering, fresh signatures) and antivirus enable. No throwaway app, no grant, no sweep on Tito (spec §8).
  • [ ] Step 4: Record in the discussion.

---

Task 13: close out

  • [ ] Step 1: Post the final state to the discussion: both deployments enabled, no store granted, the two follow-ups open.
  • [ ] Step 2: Update memory: a scanner-host-rollout entry (what is live, box ids, where the keys are, the replacement rule, the follow-ups) and a pointer line in MEMORY.md; correct tito-console-store-boundary if Tito's policy changed.
  • [ ] Step 3: Delete $TMPDIR/scanner-host/ and the scratch clone if this plan made it. Remove .scratch-codex-review.sh from the worktree. Leave nothing under /tmp.
  • [ ] Step 4: Write the handoff (handoff skill).