#!/usr/bin/env bash # aws-posture.sh — Plane-1 / Tier-2 checker for the R720 agent-team. # # Design refs: docs/r720-agent-team-design.md D5 / §4 (Tier 2 roster: aws-posture — # "Idle/anomalous spend (≈$330/mo flagged) + reasoning layer over baseline findings. Auths via # Roles Anywhere (short-lived leaf certs, auto-rotated by step-ca). Complements existing # GuardDuty/Security Hub/Config, does not replace them") and §6.3 / §7 Phase 3 ("doc-drift + # step-ca/Roles Anywhere + aws-posture"). This is a Tier-2 checker built on the Phase-0 shared # substrate (lib/sweep_substrate.sh); it mirrors compliance-drift.sh / dependency-cve.sh / # doc-drift.sh conventions VERBATIM so the coordinator (§5) can drive all of them identically. # # WHAT IT DOES (read-only): # Watches the Sea Haven AWS account (328440206208, us-east-1) for IDLE / ANOMALOUS SPEND and # idle-resource posture: # - anomalous Cost Explorer deltas (ce get-anomalies above a $ impact threshold) # - stopped EC2 instances still paying for attached EBS # - unattached ("available") EBS volumes # - unassociated Elastic IPs # - idle NAT gateways (≈0 bytes out over the window) # - idle load balancers (0 healthy targets) # - idle RDS instances (0 connections over the window) # It COMPLEMENTS GuardDuty / Security Hub / Config (design §4) — it is a spend/idle-posture # watch, NOT a threat detector, and does not replace them. # # AUTH / PROVISIONING GATE (design D5 / §6.3 / §7 B3): # The LIVE read-only AWS calls require credentials vended via IAM Roles Anywhere using a # short-lived step-ca leaf cert — this is **PROVISIONING-GATED and NOT available yet** (the IAM # cross-review PASSED 2026-06-18, which unblocked BUILDING this checker, but step-ca + the trust # anchor + the role are not stood up). See security-review/iam/ for the reviewed artifacts. # Therefore the checker: # (a) attempts read-only `aws` CLI calls ONLY when credentials are actually available # (an STS identity probe succeeds) AND --no-api/--canary were not passed; # (b) when there are NO credentials, OR --no-api, OR --canary: it SKIPS the live calls and # NOTES them — it NEVER alarms on missing data (memory feedback_cloudwatch_alarms: no # false alarms on no-data). This mirrors compliance-drift's API-skip pattern EXACTLY. # # REPORTING (matches secrev sweep conventions): # - Writes a per-run JSON + text report under $REPORT_ROOT//, mode 600 (umask 077). # - Slack ALARM-ONLY: a clean run (no confirmed waste) posts NOTHING (memory # feedback_cloudwatch_alarms). Secret-shaped values are redacted from the Slack string. # - Reuses the substrate's redact() + post_slack_alarm() verbatim. # # SUBSTRATE REUSE (lib/sweep_substrate.sh, sourced — bash dynamic scoping): # redact, post_slack_alarm -> Slack delivery (reads SLACK_WEBHOOK_URL, REPORT_DIR, SWEEP_LOG) # (aws-posture does NOT use discover_repos/mirror_repo — it scans an AWS account, not repos.) # # CANARY / DRY-RUN (offline, no network, no aws, no credentials): # --canary runs the SAME detectors against a fixture of mocked AWS JSON responses # (checkers/fixtures/aws-posture/) and asserts the known finding count against # EXPECTED_FINDING_COUNT (exit 3 on mismatch). It makes ZERO `aws` calls and ZERO network # calls. This is the anti-complacency floor (design §6.4) AND the routing dry-run (§7 Phase 3): # --canary implies --dry-run + --no-api; with --dry-run the Slack alarm is composed + printed # but NOT POSTed. # # SCOPE / SAFETY: # Read-only. The reasoning ("Sonnet collectors + judge", design §4) is a LATER enhancement: a # clearly-marked inert stub hook (maybe_judge) marks the future seam; it does NOTHING offline # and NOTHING in this phase (the deterministic detectors are the whole checker here). Does NOT # touch agent_team/ or agent-team/, is NOT wired into systemd, and stands NOTHING up in AWS — # that is Phase-3/6 provisioning (gated). See the "PROVISIONING (NOT DONE HERE)" note at bottom. # # Exit: 0 = ran (whether or not it alarmed); 2 = setup/usage error; 3 = canary assertion FAILED. set -euo pipefail export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" log() { echo "[aws-posture] $*" >&2; } die() { echo "[aws-posture] FATAL: $*" >&2; exit 2; } # --- Shared substrate --------------------------------------------------------- HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SUBSTRATE="$HERE/../lib/sweep_substrate.sh" [ -f "$SUBSTRATE" ] || die "shared substrate not found: $SUBSTRATE" # shellcheck source=../lib/sweep_substrate.sh . "$SUBSTRATE" # --- Config + defaults (env, all optional) ------------------------------------ AWS_ACCOUNT="${AWS_ACCOUNT:-328440206208}" AWS_REGION="${AWS_REGION:-us-east-1}" REPORT_ROOT="${REPORT_ROOT:-$HOME/sweep-reports/aws-posture}" # Cost-anomaly $ impact threshold: only anomalies whose TotalImpact >= this are flagged # (a tiny anomaly is noise, not waste — no false alarm on a sub-threshold blip). COST_ANOMALY_MIN_IMPACT="${COST_ANOMALY_MIN_IMPACT:-25}" # A NAT gateway with bytes-out below this over the window is treated as idle. NAT_IDLE_BYTES_MAX="${NAT_IDLE_BYTES_MAX:-1024}" # An RDS instance with max connections at/below this over the window is treated as idle. RDS_IDLE_CONN_MAX="${RDS_IDLE_CONN_MAX:-0}" DO_API=1 # --no-api: skip ALL live AWS calls (offline). Without creds this is forced. DRY_RUN=0 # --dry-run: compose the Slack alarm but DO NOT post it (routing dry-run). CANARY=0 # --canary: run the detectors against the mocked-AWS fixture + assert count. TARGETS_OVERRIDE="" # --targets DIR: read mocked-AWS JSON from DIR instead of the live account # (offline + deterministic; same file shape as the canary fixture). usage() { cat >&2 </dev/null || die "jq is required" # --- Report dir (mode 600 reports; matches sweep conventions) ----------------- umask 077 UTC_DATE="$(date -u +%Y-%m-%d)" UTC_STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" REPORT_DIR="$REPORT_ROOT/$UTC_DATE" mkdir -p "$REPORT_DIR"; chmod 700 "$REPORT_ROOT" "$REPORT_DIR" 2>/dev/null || true # shellcheck disable=SC2034 # read by the sourced substrate (post_slack_alarm) via dynamic scope SWEEP_LOG="$REPORT_DIR/aws-posture.log" # name the substrate's post_slack_alarm() references REPORT_JSON="$REPORT_DIR/aws-posture.json" REPORT_TXT="$REPORT_DIR/aws-posture.txt" log "=== aws-posture $UTC_STAMP (canary=$CANARY dry_run=$DRY_RUN api=$DO_API account=$AWS_ACCOUNT region=$AWS_REGION) ===" # ------------------------------------------------------------------------------ # FINDINGS (spirit of finding.schema.json so the coordinator can route like an agentic finding). # category="other" (idle-spend is not one of the schema's security categories); # status="confirmed" only for a deterministic idle/anomaly fact derived from a real response. # A live call that could not be made (no creds / --no-api / transport failure) is a SKIP, never a # finding (memory feedback_cloudwatch_alarms: no false alarms on missing data). # ------------------------------------------------------------------------------ declare -a FINDINGS=() add_finding() { # id title severity check resource proof local id="$1" title="$2" sev="$3" check="$4" resource="$5" proof="$6" FINDINGS+=( "$(jq -n \ --arg id "$id" --arg title "$title" --arg sev "$sev" \ --arg check "$check" --arg resource "$resource" --arg proof "$proof" \ '{account:env.AWS_ACCOUNT_FOR_FINDING, id:$id, title:$title, severity:$sev, category:"other", check:$check, status:"confirmed", proof:{resource:$resource, outcome:$proof}}')" ) } export AWS_ACCOUNT_FOR_FINDING="$AWS_ACCOUNT" declare -a SKIPPED_CHECKS=() # (check:reason) live calls skipped on missing data — reported, never alarmed note_skip() { SKIPPED_CHECKS+=( "$1" ); } # Inert future seam (design §4 "Sonnet collectors + judge"): in LIVE mode an ambiguous idle # candidate ("is this RDS truly idle or just low-traffic?") could be escalated to a reasoning # judge. This phase keeps the deterministic detectors ONLY — the stub does nothing and is never # reached offline / in canary / dry-run. maybe_judge() { # candidate_json (no-op stub; Phase-3 intentionally inert) return 0 } # ============================================================================== # DETECTORS — each consumes one AWS JSON response (live or fixture) and emits findings. # Pure jq parsing; identical logic for the live `aws ... --output json` output and the canary # fixture, so the canary genuinely exercises the production detectors. # ============================================================================== # cost anomalies: ce get-anomalies. Flag anomalies whose Impact.TotalImpact >= threshold. detect_cost_anomalies() { # json local json="$1" while IFS=$'\t' read -r aid svc impact; do [ -n "$aid" ] || continue add_finding "cost-anomaly-$aid" \ "Cost anomaly: ${svc} (≈\$${impact} impact)" "high" "cost-anomaly" "$aid" \ "ce get-anomalies TotalImpact \$${impact} >= threshold \$${COST_ANOMALY_MIN_IMPACT} (service: ${svc})" done < <(echo "$json" | jq -r --argjson thr "$COST_ANOMALY_MIN_IMPACT" ' (.Anomalies // [])[] | select((.Impact.TotalImpact // 0) >= $thr) | [.AnomalyId, (.DimensionValue // "unknown"), ((.Impact.TotalImpact // 0)|tostring)] | @tsv') } # stopped EC2 still paying for attached EBS: describe-instances, State.Name=="stopped" with EBS. detect_stopped_instances() { # json local json="$1" while IFS=$'\t' read -r iid itype; do [ -n "$iid" ] || continue add_finding "stopped-ec2-$iid" \ "Stopped EC2 instance still incurring EBS cost: $iid ($itype)" "medium" "stopped-instance" "$iid" \ "ec2 describe-instances: State=stopped with attached EBS (storage bills while stopped)" done < <(echo "$json" | jq -r ' (.Reservations // [])[].Instances[] | select((.State.Name // "") == "stopped") | select(((.BlockDeviceMappings // []) | length) > 0) | [.InstanceId, (.InstanceType // "?")] | @tsv') } # unattached EBS: describe-volumes, State=="available". detect_unattached_volumes() { # json local json="$1" while IFS=$'\t' read -r vid size vtype; do [ -n "$vid" ] || continue add_finding "unattached-ebs-$vid" \ "Unattached EBS volume billing idle: $vid (${size}GiB $vtype)" "medium" "unattached-volume" "$vid" \ "ec2 describe-volumes: State=available (no attachment) — billed but unused" done < <(echo "$json" | jq -r ' (.Volumes // [])[] | select((.State // "") == "available") | [.VolumeId, ((.Size // 0)|tostring), (.VolumeType // "?")] | @tsv') } # unassociated EIP: describe-addresses, no AssociationId/InstanceId. detect_unassociated_eips() { # json local json="$1" while IFS=$'\t' read -r alloc ip; do [ -n "$alloc" ] || continue add_finding "unassociated-eip-$alloc" \ "Unassociated Elastic IP (hourly charge): $ip" "low" "unassociated-eip" "$alloc" \ "ec2 describe-addresses: no AssociationId/InstanceId — idle EIPs are billed hourly" done < <(echo "$json" | jq -r ' (.Addresses // [])[] | select((.AssociationId // "") == "" and (.InstanceId // "") == "") | [(.AllocationId // .PublicIp), (.PublicIp // "?")] | @tsv') } # idle NAT gateway: describe-nat-gateways, available + bytes-out below threshold. # Live path injects the CloudWatch-derived bytes-out as _FixtureBytesOutLast14d (same key the # canary fixture uses) before calling this — keeping detector logic identical online/offline. detect_idle_nat() { # json local json="$1" while IFS=$'\t' read -r nid bytes; do [ -n "$nid" ] || continue add_finding "idle-nat-$nid" \ "Idle NAT gateway (≈0 traffic, ~\$32/mo each): $nid" "medium" "idle-nat" "$nid" \ "ec2 describe-nat-gateways: available with ${bytes} bytes out over window (<= ${NAT_IDLE_BYTES_MAX})" done < <(echo "$json" | jq -r --argjson mx "$NAT_IDLE_BYTES_MAX" ' (.NatGateways // [])[] | select((.State // "") == "available") | select((._FixtureBytesOutLast14d // 0) <= $mx) | [.NatGatewayId, ((._FixtureBytesOutLast14d // 0)|tostring)] | @tsv') } # idle ELB: describe-load-balancers, 0 healthy targets. # Live path injects the per-LB healthy-target count as _FixtureHealthyTargetCount (derived from # elbv2 describe-target-health) before calling this — same key the canary fixture uses. detect_idle_elb() { # json local json="$1" while IFS=$'\t' read -r name; do [ -n "$name" ] || continue add_finding "idle-elb-$name" \ "Idle load balancer (0 healthy targets, ~\$16/mo each): $name" "medium" "idle-elb" "$name" \ "elbv2 describe-load-balancers + describe-target-health: 0 healthy targets" done < <(echo "$json" | jq -r ' (.LoadBalancers // [])[] | select((._FixtureHealthyTargetCount // 0) == 0) | [.LoadBalancerName // .LoadBalancerArn] | @tsv') } # idle RDS: describe-db-instances, available + max connections at/below threshold. # Live path injects DatabaseConnections max as _FixtureMaxConnectionsLast14d (from CloudWatch). detect_idle_rds() { # json local json="$1" while IFS=$'\t' read -r dbid class; do [ -n "$dbid" ] || continue add_finding "idle-rds-$dbid" \ "Idle RDS instance (0 connections over window): $dbid ($class)" "high" "idle-rds" "$dbid" \ "rds describe-db-instances: available with 0 connections over window (<= ${RDS_IDLE_CONN_MAX})" done < <(echo "$json" | jq -r --argjson mx "$RDS_IDLE_CONN_MAX" ' (.DBInstances // [])[] | select((.DBInstanceStatus // "") == "available") | select((._FixtureMaxConnectionsLast14d // 1) <= $mx) | [.DBInstanceIdentifier, (.DBInstanceClass // "?")] | @tsv') } # Run every detector over a directory of JSON responses (fixture dir or a collected-live dir). # Missing files are tolerated (a detector with no input simply contributes nothing — never a skip # that alarms; a genuinely uncollected live call is recorded as a SKIP by the live collector). run_detectors_over_dir() { # dir local dir="$1" f f="$dir/cost-anomalies.json"; [ -f "$f" ] && detect_cost_anomalies "$(cat "$f")" f="$dir/describe-instances.json"; [ -f "$f" ] && detect_stopped_instances "$(cat "$f")" f="$dir/describe-volumes.json"; [ -f "$f" ] && detect_unattached_volumes "$(cat "$f")" f="$dir/describe-addresses.json"; [ -f "$f" ] && detect_unassociated_eips "$(cat "$f")" f="$dir/describe-nat-gateways.json";[ -f "$f" ] && detect_idle_nat "$(cat "$f")" f="$dir/describe-load-balancers.json";[ -f "$f" ] && detect_idle_elb "$(cat "$f")" f="$dir/describe-db-instances.json";[ -f "$f" ] && detect_idle_rds "$(cat "$f")" } # ============================================================================== # LIVE COLLECTION (read-only AWS, ONLY when credentials are available + not --no-api/--canary). # Each call is fail-safe: on a transport/permission failure the response is NOT written and the # call is recorded as a SKIP — never a finding (memory feedback_cloudwatch_alarms). # The CloudWatch-derived idle metrics (NAT bytes-out, ELB healthy targets, RDS connections) are # injected into the describe-* JSON under the SAME _Fixture* keys the detectors read, so the live # and canary code paths are identical. # ============================================================================== aws_creds_available() { command -v aws >/dev/null || return 1 aws sts get-caller-identity --region "$AWS_REGION" >/dev/null 2>>"$REPORT_DIR/aws.log" } collect_live() { # out_dir local out="$1"; mkdir -p "$out" # NOTE: this live collector is PROVISIONING-GATED and only reached when real Roles Anywhere # creds exist (aws_creds_available passed). Until step-ca/Roles Anywhere are stood up this path # is never taken; it is written so the checker is complete + ready, not so it runs today. _try() { # outfile aws-args... local of="$1"; shift if aws "$@" --region "$AWS_REGION" --output json >"$of" 2>>"$REPORT_DIR/aws.log"; then return 0 else rm -f "$of"; note_skip "live:$(basename "$of" .json)(aws-call-failed)"; return 1 fi } _try "$out/cost-anomalies.json" ce get-anomalies || true _try "$out/describe-instances.json" ec2 describe-instances || true _try "$out/describe-volumes.json" ec2 describe-volumes || true _try "$out/describe-addresses.json" ec2 describe-addresses || true _try "$out/describe-nat-gateways.json" ec2 describe-nat-gateways || true _try "$out/describe-load-balancers.json" elbv2 describe-load-balancers || true _try "$out/describe-db-instances.json" rds describe-db-instances || true # Idle-metric enrichment (NAT bytes-out / ELB healthy targets / RDS connections from CloudWatch) # is injected here in the live path under the _Fixture* keys before the detectors run. It is a # provisioning-time follow-up — until creds exist this collector is unreachable, so the # enrichment is intentionally a documented seam, not dead code that runs offline. } # ============================================================================== # RESOLVE THE INPUT (fixture / explicit dir / live collection) + DECIDE API MODE # ============================================================================== SCAN_DIR="" SCAN_MODE="none" if [ "$CANARY" -eq 1 ]; then FIXTURE_DIR="$HERE/fixtures/aws-posture" [ -d "$FIXTURE_DIR" ] || die "canary fixture missing: $FIXTURE_DIR" SCAN_DIR="$FIXTURE_DIR"; SCAN_MODE="canary-fixture" log "canary: running detectors against mocked-AWS fixtures in $FIXTURE_DIR (no aws, no network)" elif [ -n "$TARGETS_OVERRIDE" ]; then d="${TARGETS_OVERRIDE/#\~/$HOME}" [ -d "$d" ] || die "--targets dir not found: $d" SCAN_DIR="$d"; SCAN_MODE="explicit-dir" log "explicit targets dir (offline mocked-AWS JSON): $SCAN_DIR" elif [ "$DO_API" -eq 1 ] && aws_creds_available; then COLLECT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/aws-posture-live.XXXXXX")" trap 'rm -rf "$COLLECT_DIR"' EXIT log "live: AWS credentials present — collecting read-only responses into $COLLECT_DIR" collect_live "$COLLECT_DIR" SCAN_DIR="$COLLECT_DIR"; SCAN_MODE="live-aws" else # No creds, or --no-api: SKIP all live calls and note them. NEVER alarm on missing data. if [ "$DO_API" -eq 1 ]; then log "live AWS requested but no usable credentials (Roles Anywhere is PROVISIONING-GATED) — skipping all live calls (no false alarms on missing data)" note_skip "live:all(no-credentials — Roles Anywhere gated; see security-review/iam/)" else log "--no-api: skipping all live AWS calls" note_skip "live:all(--no-api)" fi SCAN_MODE="skipped-no-creds" fi # ============================================================================== # RUN DETECTORS # ============================================================================== if [ -n "$SCAN_DIR" ]; then run_detectors_over_dir "$SCAN_DIR" maybe_judge "" # inert in this phase (future Sonnet-collector/judge seam) fi # ============================================================================== # ASSEMBLE REPORT (JSON + text), mode 600 (identical shape to the other checkers) # ============================================================================== if [ "${#FINDINGS[@]}" -gt 0 ]; then FINDINGS_JSON="$(printf '%s\n' "${FINDINGS[@]}" | jq -cs .)" else FINDINGS_JSON="[]" fi if [ "${#SKIPPED_CHECKS[@]}" -gt 0 ]; then SKIPPED_JSON="$(printf '%s\n' "${SKIPPED_CHECKS[@]}" | jq -R . | jq -cs .)" else SKIPPED_JSON="[]" fi N_FIND="$(echo "$FINDINGS_JSON" | jq 'length')" N_HIGH="$(echo "$FINDINGS_JSON" | jq '[.[]|select(.severity=="high" or .severity=="critical")] | length')" jq -n \ --arg checker "aws-posture" --arg ts "$UTC_STAMP" --arg account "$AWS_ACCOUNT" \ --arg region "$AWS_REGION" --arg mode "$SCAN_MODE" \ --argjson findings "$FINDINGS_JSON" --argjson skipped "$SKIPPED_JSON" \ '{checker:$checker, generated:$ts, account:$account, region:$region, scan_mode:$mode, finding_count:($findings|length), findings:$findings, skipped_checks:$skipped}' > "$REPORT_JSON" { echo "aws-posture report — $UTC_STAMP" echo "account=$AWS_ACCOUNT region=$AWS_REGION scan_mode=$SCAN_MODE" echo "idle/anomalous-spend findings: $N_FIND ($N_HIGH high/critical)" echo echo "$FINDINGS_JSON" | jq -r '.[] | "• [\(.severity)] \(.check): \(.title)\n proof: \(.proof.outcome)"' if [ "$(echo "$SKIPPED_JSON" | jq 'length')" -gt 0 ]; then echo; echo "skipped (missing data — NOT counted as a finding):" echo "$SKIPPED_JSON" | jq -r '.[] | " - \(.)"' fi } > "$REPORT_TXT" chmod 600 "$REPORT_JSON" "$REPORT_TXT" 2>/dev/null || true log "report: $REPORT_JSON ($N_FIND finding(s), mode=$SCAN_MODE)" # ============================================================================== # CANARY ASSERTION (anti-complacency floor, design §6.4) # ============================================================================== if [ "$CANARY" -eq 1 ]; then EXPECT_FILE="$HERE/fixtures/aws-posture/EXPECTED_FINDING_COUNT" [ -f "$EXPECT_FILE" ] || die "canary expected-count file missing: $EXPECT_FILE" EXPECTED="$(tr -dc '0-9' < "$EXPECT_FILE")" log "canary assertion: expected findings=$EXPECTED, got=$N_FIND" if [ "$N_FIND" -ne "$EXPECTED" ]; then echo "[aws-posture] CANARY FAIL: planted-finding count mismatch (expected $EXPECTED, got $N_FIND)" >&2 echo " -> a detector regressed (stopped firing) or the fixture changed. See $REPORT_TXT." >&2 exit 3 fi log "canary PASS: all $EXPECTED planted idle/anomaly findings detected." fi # ============================================================================== # ALARM-ONLY ROUTING (clean = silent; memory feedback_cloudwatch_alarms) # ============================================================================== if [ "$N_FIND" -eq 0 ]; then log "no idle/anomalous spend detected — posting NOTHING to Slack (ALARM-only policy)." exit 0 fi ALARM_BODY="$(echo "$FINDINGS_JSON" | jq -r ' group_by(.check)[] | "*\(.[0].check)*: " + ([.[] | "[\(.severity)] \(.title)"] | join("; "))' | sed 's/^/• /')" SLACK_TEXT=":money_with_wings: *Sea Haven aws-posture — ALARM* ($UTC_STAMP) $N_FIND idle/anomalous-spend finding(s) in account $AWS_ACCOUNT/$AWS_REGION ($N_HIGH high/critical): $ALARM_BODY Scope: idle/anomalous SPEND + idle-resource posture (complements GuardDuty/SecurityHub/Config, mode=$SCAN_MODE) Report (mode 600): \`$REPORT_JSON\` (on R720)" SLACK_TEXT="$(echo "$SLACK_TEXT" | redact)" echo "$SLACK_TEXT" >&2 if [ "$DRY_RUN" -eq 1 ]; then log "DRY-RUN: alarm composed but NOT posted (routing dry-run, design §7 Phase 3)." exit 0 fi post_slack_alarm "$SLACK_TEXT" exit 0 # ============================================================================== # PROVISIONING (NOT DONE HERE — gated, Phase 3 / Phase 6): # - The LIVE AWS calls need credentials vended via IAM Roles Anywhere using a short-lived # step-ca leaf cert. step-ca + the Roles Anywhere trust anchor + the read-only role are NOT # stood up by this script. The reviewed IAM artifacts live in security-review/iam/ (GPT-4.1 # cross-review PASSED 2026-06-18: APPROVE, no BLOCKs). Provisioning happens only after that # review is recorded (design §7, B3) and a VM snapshot is taken (feedback_ec2_replacement_snapshot). # Until then aws_creds_available() returns false and the checker SKIPS all live calls (no # false alarms on missing data) — only --canary / --targets exercise it offline. # - No systemd unit / timer is installed by this script. Wiring it into the live secrev schedule # (weekly cadence, design §4) is provisioning and is gated. # - This script is NOT registered in checker_coordinator.sh; the coordinator registry is # integrated centrally (separate change). # - The LIVE "Sonnet collectors + judge" reasoning layer (design §4) is the only LLM seam; it is # an inert stub here (maybe_judge) and stays off in canary / dry-run / offline. # - Confluence + project_r720_agent_team memory updates are docs-as-you-go obligations for the # build session, tracked outside this script. # ==============================================================================