Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-099 — Milestone item 2: the KVM re-check in CI via GitHub OIDC (design sketch, inert)

  • Status: Proposed — design only; since 2026-10-13 also the exact spec for ONE supervised pilot run (sponsor decision 2026-10-13 20:49 CEST, DSO relay). Not recurring: manual dispatch only, no schedule. See Pilot spec.
  • Original status line: Nothing in this ADR is implemented. No AWS call was made and no AWS resource was created or changed to write it. The workflow it adds, .github/workflows/kvm-recheck.yml, is inert (workflow_dispatch only, not scheduled, not a required check, fails closed while its role variable is unset). The sponsor and DSO decide; do not self-merge (ADR-002).
  • Date: 2026-10-02 (CEST).
  • Follows: ADR-085 (first KVM run), ADR-090 (evidence class), ADR-091 (Accepted; its sentence is not touched), ADR-093 (no identifiers in public text), ADR-095 (G3: the manual run this automates).
  • Not in this ADR: no src/, user/, libctos/ or Cargo.* change; _start (0x4008_0000) untouched; smoke.yml, pages.yml and the in-tree scripts/b2-metal-userdata.sh untouched; the three required checks are unchanged; CloudAgent not used.
  • No threat-model bump. security.md on main was v1.72 when this ADR was written (ADR-098, PR #151, merged by DSO on the sponsor’s instruction on 2026-10-02 13:01 CEST); #149 and #150 have merged since (v1.71, v1.73), and the next free number is taken by the ADR-091 revision 4 PR (#153). A bump here would collide with those numbers. The risk table below is written so DSO can attach it to the sponsor record; folding it into security.md is a follow-up after those PRs merge.
  • Public-identifier rule (ADR-093): every account, role, instance, image, subnet, security-group and zone identifier is a <PLACEHOLDER> here and in the workflow. Real values belong in repository variables set by the sponsor-approved path, never in repo text, commits, PR bodies or logs.

Context

ADR-095 proved by hand (one c7g.metal Graviton3 Spot instance, about $0.04, reduced b2-serror profile, markers el0: serror + b2: taken) that the taken lower-EL SError still happens under KVM. That run is a point in time. Milestone item 2 asks whether the same check can run from CI so the claim can be re-checked on a later commit without a person at an AWS console.

GitHub-hosted runners cannot run KVM for this profile on Graviton metal, so CI would have to start a short-lived AWS instance. That needs AWS credentials in CI. The credential rule (rule 4) forbids stored keys. The sponsor approved one exception, tracked as PX0: a GitHub OIDC role with no stored keys (DSO relay, 2026-09-28 21:23 CEST). The exception is conditional. Before the role is created or used:

  • (a) a CloudTrail trail is on;
  • (b) the ctos branch protection on main is enforced for admins;
  • (c) how test results leave the instance is decided before asking for any extra permission such as iam:PassRole.

Condition (c) is answered in this ADR (section 2). The conditions above are taken from the DSO relay text. The decision record in the DSO vault (01-second-brain/decisions/2026-09-28-ctos-ci-oidc-exception.md) was not read while writing this ADR: the file exists on the DSO machines, but the executor’s file reader refused a path outside its allowed root, and that refusal was not worked around. Nothing here quotes or paraphrases the record, and the text of credential rule 4 is not reproduced. DSO must reconcile this ADR with the record before it goes to the sponsor.

Observations made while writing (GitHub read-only, 2026-10-02 about 13:00 CEST; no AWS)

ItemObservedMeaning
Branch protection on main, enforce for adminsOnCondition (b) is verified today. Re-verify at role-creation time.
Required checks on mainThe three smoke checks (ubuntu-24.04-arm, ubuntu-24.04, NMI pin)The new workflow is not one of them and must not become one.
Required approving reviews on mainNone (only status checks are required); strict is offA PR that passes the three checks can be merged by anyone with merge rights. In practice the sponsor merges (ADR-002), but the control is by convention, not by GitHub. Listed as a residual risk below.
Force-push and branch deletion on mainNot allowedGood.
Actions environmentsOnly copilot and github-pages. No KVM environment exists.A workflow that names an environment that does not exist makes GitHub create it with no protection. The environment must be created (reviewers, main-only branch policy) before the role exists.
Repository variablesOnly one build variable; no KVM_* variableThe workflow is inert: its guard fails on the unset role variable.
OIDC sub claim customisationThe repository uses the default template with an immutable subject; the subject prefix carries the numeric owner and repository IDsThe sub GitHub emits is probably not the plain repo:<owner>/<repo>:… form that most AWS examples show. A trust policy written from an example would never match (fails closed, but also never works). The exact string must come from the documentation and the customisation setting at role-creation time, and be confirmed in the supervised pilot from the CloudTrail AssumeRoleWithWebIdentity event. Not verified here.
Repository visibilityPublicActions logs are public. See “Identifier leakage” in the risk table.

Decision (proposed)

Add an opt-in, manual, inert workflow that, once the sponsor path enables it, assumes a least-privilege OIDC role, launches one Spot c7g.metal instance, reads the result from the serial console only, and terminates the instance in an always() step, with three independent teardown layers and a hard cost cap. Only a result produced by a probe on the instance is ever reported as PASS.

1. Trust policy and permission policy (placeholders only)

1.1 Trust policy shape

StringEquals only. No StringLike, no wildcard. The audience is fixed, the subject names this repository and exactly one of: the main branch or one named environment. The environment form is recommended, because the environment can also require a human reviewer and a main-only branch policy.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GitHubOidcOnlyThisRepoOnlyThisEnvironment",
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:artofdream@<OWNER_ID>/ctos@<REPO_ID>:environment:kvm-pilot"
        }
      }
    }
  ]
}

<SUB_FOR_THIS_REPO_AND_ENVIRONMENT> is one exact string. Two shapes, by the claim template in force:

Template in forceEnvironment form (recommended)Branch form (alternative)
Classicrepo:<OWNER>/<REPO>:environment:<ENV_NAME>repo:<OWNER>/<REPO>:ref:refs/heads/main
Immutable subject (what this repository reports) [unverified format]repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:environment:<ENV_NAME>repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:refs/heads/main

<OWNER_ID> and <REPO_ID> are GitHub’s numeric IDs, read from the repository settings by the person creating the role, and not written to the repo. Do not “fix” a mismatch by widening to StringLike or *. A mismatch is a fail-closed AccessDenied; fix the string.

Other properties: role maximum session 3600 s; the OIDC identity provider for token.actions.githubusercontent.com must already exist in the account (precondition P4); the audience is the default that aws-actions/configure-aws-credentials requests. Optional hardening: customise the subject or add a job_workflow_ref condition so only kvm-recheck.yml on main matches [verify the claim and the customisation in the AWS and GitHub docs before relying on it].

Why the environment form: a pull request from a fork, or from any branch, produces a different sub (a pull_request subject, or another ref) and does not match. A workflow_dispatch from main bound to the environment matches only after the environment’s protection rules pass.

1.2 Permission policy (least privilege)

Placeholders (region is eu-north-1, the one used in ADR-095; formerly <REGION>), <ACCOUNT_ID>, <AMI_ID> (pinned Ubuntu 24.04 arm64 image), <SUBNET_ID_1> … <SUBNET_ID_n> (one subnet per AZ), <SG_ID> (a security group pre-created by DSO: tagged, no ingress).

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RunInstancesNewInstanceOnly",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:instance/*",
      "Condition": {
        "StringEquals": {
          "ec2:InstanceType": "c7g.metal",
          "ec2:InstanceMarketType": "spot",
          "ec2:MetadataHttpTokens": "required",
          "aws:RequestTag/project": "ctos",
          "aws:RequestTag/component": "kvm-ci-pilot"
        },
        "NumericLessThanEquals": { "ec2:MetadataHttpPutResponseHopLimit": 1 },
        "ForAllValues:StringEquals": { "aws:TagKeys": ["project", "component", "run"] }
      }
    },
    {
      "Sid": "RunInstancesNewChildResourcesMustBeTagged",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": [
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:volume/*",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:network-interface/*",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:spot-instances-request/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:RequestTag/project": "ctos",
          "aws:RequestTag/component": "kvm-ci-pilot"
        }
      }
    },
    {
      "Sid": "RunInstancesPinnedInputsOnly",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": [
        "arn:aws:ec2:eu-north-1::image/<AMI_ID>",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:subnet/<SUBNET_ID_1>",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:subnet/<SUBNET_ID_n>"
      ]
    },
    {
      "Sid": "RunInstancesTaggedNoIngressSecurityGroupOnly",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:security-group/<SG_ID>",
      "Condition": { "StringEquals": { "ec2:ResourceTag/project": "ctos" } }
    },
    {
      "Sid": "TagOnCreateOnly",
      "Effect": "Allow",
      "Action": "ec2:CreateTags",
      "Resource": [
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:instance/*",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:volume/*",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:network-interface/*",
        "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:spot-instances-request/*"
      ],
      "Condition": { "StringEquals": { "ec2:CreateAction": "RunInstances" } }
    },
    {
      "Sid": "TerminateAndReadConsoleOfOurTaggedInstancesOnly",
      "Effect": "Allow",
      "Action": ["ec2:TerminateInstances", "ec2:GetConsoleOutput"],
      "Resource": "arn:aws:ec2:eu-north-1:<ACCOUNT_ID>:instance/*",
      "Condition": {
        "StringEquals": {
          "ec2:ResourceTag/project": "ctos",
          "ec2:ResourceTag/component": "kvm-ci-pilot"
        }
      }
    },
    {
      "Sid": "DescribeInstancesForPollAndSweep",
      "Effect": "Allow",
      "Action": "ec2:DescribeInstances",
      "Resource": "*",
      "Condition": { "StringEquals": { "aws:RequestedRegion": "eu-north-1" } }
    },
    {
      "Sid": "DenyOtherRegions",
      "Effect": "Deny",
      "Action": "ec2:*",
      "Resource": "*",
      "Condition": { "StringNotEquals": { "aws:RequestedRegion": "eu-north-1" } }
    },
    {
      "Sid": "DenyIamS3SsmExplicitly",
      "Effect": "Deny",
      "Action": ["iam:*", "s3:*", "ssm:*"],
      "Resource": "*"
    }
  ]
}

(The last Deny is belt and braces: nothing here allows those services, but an explicit Deny keeps a later, careless policy attachment from widening the role. It deliberately does not name kms:*: if account-default EBS encryption uses a customer-managed key, the launch may need KMS permissions, and that is a decision for the sponsor, not a hidden Deny.)

Exact actions to launch, tag-scope and terminate one Spot instance:

ActionNeeded forCan the project=ctos tag restrict it?
ec2:RunInstanceslaunch the one instance (with Spot market options, in one call)Partly. On instance, volume, network-interface, spot-instances-request through aws:RequestTag/project (and component), i.e. the request must tag what it creates. On the security group through ec2:ResourceTag/project. Not on the image or the subnets (they are not tagged by us; pinned by ARN instead).
ec2:CreateTagstag at creationScoped, not by the tag value: ec2:CreateAction = RunInstances means tags can only be written during a launch, never onto an existing resource.
ec2:TerminateInstancesteardownYes, ec2:ResourceTag/project + component on the instance.
ec2:GetConsoleOutputthe result channelYes, same condition.
ec2:DescribeInstancespoll state, Spot interruption reason, sweeperNo. Describe* has no resource-level scope; it is limited by region only. It leaks nothing the account’s EC2 console would not already show the role holder.
iam:PassRolean instance profileNot requested. There is no instance profile.
iam:CreateServiceLinkedRolefirst-ever Spot useNot requested. The Spot service-linked role must pre-exist (P8, unverified).
ec2:CreateSecurityGroup, ec2:Authorize*, key pairs, SSM, S3, KMS—Not requested. The group is pre-created.
ec2:DescribeImages, DescribeSubnets, DescribeSpotPriceHistory—Not requested. Image and subnets are pinned; MaxPrice is the cap.

Service-reference check (public AWS documentation, fetched 2026-10-02, not an AWS API call): TerminateInstances and GetConsoleOutput act on the instance resource type, which supports ec2:ResourceTag/*; RunInstances touches the instance, volume, network-interface, spot-instances-request, security-group, subnet and image types; Describe* have no resource types. IAM condition behaviour is not exercised until a supervised pilot (P13); expect one or two adjustments, each of which goes back to the sponsor as a change to this ADR.

Unverified, flagged for the pilot: if the account’s default EBS encryption uses a customer-managed key, RunInstances may need extra KMS permissions; if so, that is a new permission request and goes to the sponsor under condition (c) logic, it is not added silently.

2. Results-out decision (condition (c))

The question: how does “the smoke passed” leave a box that has no credentials, no key pair and no inbound port?

OptionWhat it needsWhat it avoids / costsVerdict
A. Serial console, ec2:GetConsoleOutputOne tag-scoped Get on the instance (already in the policy). No instance profile.Avoids iam:PassRole, any instance profile, any bucket, any agent, any credential on the instance. The instance cannot send anything except text to its own console. Cost: nothing. Limits: last 64 KiB, may lag, text only, boot noise on the console can carry the host name or addresses (so the workflow prints only ^CTOSB2: lines).Recommended. It is exactly what ADR-085 and ADR-095 used.
B. SSM (Run Command / session output)An instance profile with SSM core permissions, hence iam:PassRole, plus ssm:SendCommand, ssm:GetCommandInvocation, an agent on the box and an output path.Adds a new principal on the instance, a new service, a bigger role.Rejected. It buys a bigger channel than a one-line verdict needs.
C. S3 bucket (instance PUTs the result)Either an instance profile (iam:PassRole) or a pre-signed PUT URL minted by the role (no PassRole, but s3:PutObject and a bucket).A new resource class (bucket, lifecycle, public-block settings, cost), a URL that is a bearer secret on the instance and in user-data, a read path.Not now. Kept as a fallback if console output proves unreliable in the pilot; that would be a new sponsor decision under (c).
D. Instance pushes to GitHub (status, artefact, comment, self-hosted runner)A GitHub token (or runner registration token) on the instance.Violates the credential rule’s intent: a long-lived or minted secret on a machine that downloads and builds third-party code.Rejected.

Sponsor accept (2026-10-02): the sponsor accepted this section, results out via tag-scoped ec2:GetConsoleOutput, no iam:PassRole, no instance profile, in writing on 2026-10-02. It is recorded in the DSO vault and was relayed by DSO. Only this section is accepted; the rest of the ADR is still a proposal.

Recommendation: A, console output. It needs the least (two tag-scoped EC2 read/terminate actions the role needs anyway), avoids the most (PassRole, instance profile, S3, SSM, any credential on the instance), and was already proven by hand twice.

How the workflow stays honest with a text-only channel:

  • Result contract. The user-data prints CTOSB2: result smoke-rc=<rc> sha=<commit> nonce=<random> last (rc starts at 99 = “never ran”; a setup failure prints 98). The nonce is generated by the workflow and substituted into the user-data, so a stale console or another run’s text cannot be mistaken for this run.
  • PASS needs all of: the result line with rc=0, the exact commit and nonce; an anchored CTOSB2: b2-kvm-smoke: PASS taken lower-EL SError observed under accel=kvm line (anchored, so a set -x echo of the same words does not count); /dev/kvm=present; uname reporting aarch64; the KVM API-version line. Anything else is FAIL. No result line before the deadline is INCONCLUSIVE.
  • Live vs after termination (unverified). It is not verified here whether the console is readable live or only after the instance stops. The design handles both: the instance lingers about 90 s after printing the result; the workflow polls live, and if the instance is already terminated it makes a few more reads before giving up as INCONCLUSIVE.
  • Local test of the parser (no AWS): the parse block was run against the real ADR-095 console excerpt plus synthetic result lines. PASS only with the right commit, nonce, smoke line and markers; wrong nonce, wrong commit, rc≠0, missing smoke line, KVM absent, an unanchored echo and a setup failure all give FAIL; no result gives INCONCLUSIVE. This tests the parser, not AWS.
  • Public logs. The workflow prints only the filtered marker lines and AWS error codes; it never prints raw console text or raw AWS stderr. Instance IDs are masked with ::add-mask:: as soon as they exist.

3. Fail-safe teardown and cost cap

Four layers, so no single failure leaves a metal instance running:

  1. Pre-flight refusal. If any project=ctos instance is active, the run stops with REFUSED-active-instance before spending anything. Concurrency group kvm-recheck (cancel-in-progress: false) serialises runs.
  2. On the instance: shutdown -h +<LIFETIME_MIN> is the first line of the user-data (default 45 min; the guard refuses anything over 60), shutdown behaviour is terminate, and the script powers off after a 90 s linger. This works even if the runner dies.
  3. Always-run terminate step. if: always(), with fresh credentials (a second configure-aws-credentials, guarded by the first one’s success, because the first session may have expired or been cancelled). It finds the run’s instances by tag (run=<run id>), so a launch that died after RunInstances but before returning an ID is still cleaned up, terminates them, and waits for terminated.
  4. Tag-based sweeper check (same step). After polling up to 20 min for terminated, counts the instances this run launched (project=ctos, component=kvm-ci-pilot, run=<run id>). Any still pending/running/stopping/stopped gives LEAK; any still shutting-down gives TEARDOWN-SLOW (red, not LEAK). Other active project=ctos instances (for example the one a REFUSED-active-instance pre-flight found) only raise a warning; they never turn this run’s verdict into LEAK (that informational count cannot fail the step) (Bugbot finding, fixed 2026-10-13). This is a check inside the run, not a scheduled sweeper: a schedule would use the role with no human present and needs its own sponsor decision.

Job timeout-minutes is 60; the poll limit is 40 min.

Hard cost cap per run. Spot is billed at the market price, never above MaxPrice. The guard computes cap = MAX_PRICE_USD_HR × (LIFETIME_MIN + 5) / 60 and refuses to run if it exceeds RUN_COST_CAP_USD, or if the price is above the ceiling the sketch allows. Defaults: MaxPrice $0.40/h (the observed Spot price in ADR-095 was $0.2476/h, the manual run used a $0.60 ceiling), lifetime 45 min, cap $0.50; the computed worst case is about $0.33 per run [estimate]. EBS (30 GB gp3, minutes) adds under $0.01 [estimate]. A price above MaxPrice interrupts the instance, which ends the spend and gives INCONCLUSIVE, not PASS.

Typical cost [estimate]: $0.03–0.07 per run (ADR-095: about 8 minutes of metal at about $0.25/h, about $0.04; build time varies with Spot price and apt mirrors).

ScenarioRuns / monthMonthly cost [estimate]
Manual, as needed4–8about $0.12–0.56
Weekly schedule (not enabled, not proposed here)about 4–5about $0.12–0.35
After every merge to main (not proposed)about 20about $0.6–1.4
Worst case: every run hits the full capnabout $0.34 × n

CloudTrail cost for one management-events trail is small [unverified]. An AWS Budget alarm at a low monthly figure is recommended as a last backstop (P9, optional, unverified).

Residual: if the GitHub runner dies hard and the user-data failed to start, the instance runs until MaxPrice interrupts it or someone notices (up to about $0.40 per hour). The in-instance timer, the pre-flight refusal on the next run, and the budget alarm are the mitigations.

4. Spot capacity-failure story

KVM_SUBNET_IDS is a space-separated list, one subnet per AZ, tried in order. On each failed RunInstances the workflow reads only the AWS error code:

Code classExamplesAction
CapacityInsufficientInstanceCapacity, SpotMaxPriceTooLow, capacity-not-availabletry the next AZ
Quota / limitMaxSpotInstanceCountExceeded, VcpuLimitExceededstop: SKIPPED-no-capacity (reason: quota)
Anything else (for example AccessDenied)FAIL immediately; a permissions problem is not “no capacity”
All AZs exhaustedSKIPPED-no-capacity

A skip is never green. The job exits non-zero with a step summary that says “not a pass”. There is no on-demand fallback: a metal on-demand instance would break the cost cap. A mid-run Spot interruption (StateReason of Spot termination) is INCONCLUSIVE and is not retried automatically. The ledger records the last PASS commit and date; a skipped or inconclusive run never updates it.

Result words: PASS (the only exit 0), FAIL, INCONCLUSIVE, SKIPPED-no-capacity, REFUSED-active-instance, LEAK, TEARDOWN-SLOW (this run’s instance still shutting down when the teardown wait ended), ABORTED-after-aws-call (cancelled or crashed after the role was requested or an instance launched, with no result written; teardown still runs), NOT-RUN (guard refused or an earlier step failed; no AWS call was attempted).

5. Honesty: what a PASS proves

A PASS proves only this: at the printed commit on main, on one Graviton3 .metal host with /dev/kvm, under QEMU v10.0.0 + patches 0001/0002 with accel=kvm, the reduced b2-serror profile printed el0: serror and b2: taken (the lower-EL SError was taken).

It does not prove: the default image boots or behaves the same under KVM; the identity inventory, reach walk or syscall-pointer probes; anything about real hardware beyond this one profile; or that “EL0 isolated” is wider than the accepted ADR-091 sentence (that sentence is not changed here). It is also not a security claim. Status words in the summary and the ledger come from the probe’s result line only, never from the workflow’s own opinion. The code under test controls its own console output; that is inherent to the method, so every run records the commit it tested.

6. Precondition checklist (DSO and sponsor)

Nothing below is done by this PR. “State” is the state in this PR’s evidence; all items must be re-checked at the time they matter.

#PreconditionOwnerState (2026-10-02)
P1(a) A CloudTrail trail is on (management events, all regions or at least eu-north-1)DSOMET (verified by DSO from the arm64 workstation, 2026-10-13 about 20:56 CEST: a multi-region trail, home region eu-north-1; the agent made no AWS call)
P2(b) Branch protection on main enforced for adminsDSO / sponsorVerified via the GitHub API. Note: no required approving review; strict is off.
P3(c) Results-out decided before any extra permissionSponsor accepts this ADRDecided in this ADR (console output). Section 2 was accepted by the sponsor in writing on 2026-10-02, recorded in the DSO vault (relayed by DSO; the verbatim wording is in the vault and is not reproduced here). No PassRole requested.
P4The GitHub OIDC identity provider exists in the accountDSOExists (DSO, 2026-10-13): already present and shared; managed by another project’s Terraform, not by ctos.
P5A GitHub environment (name kvm-pilot since 2026-10-13; was placeholder kvm-recheck) with required reviewers and a main-only deployment branch policy, created before the roleSponsor / DSOCreated by DSO 2026-10-13 22:12-22:16 CEST (sponsor approval 22:11). Sponsor decision 22:15: sole required reviewer = the sponsor, prevent_self_review off, deployments from main only, admin bypass off.
P6The role created via a sponsor-approved path with exactly the trust and permission policies above, adjusted for the immutable subjectSponsor / DSOCreated by DSO 2026-10-13 22:12-22:16 CEST on the sponsor’s 22:11 approval (supersedes the earlier “not by an agent” wording). Tagged project=ctos, component=kvm-ci-pilot; inline policy follows this ADR incl. the hop-limit fix (NumericLessThanEquals). Not verified by this agent (no AWS call).
P7Pinned inputs set as repository variables (never repo text): image, no-ingress tagged security group, one subnet per AZ, region, role ARNDSONot done (all unset)
P8Spot service-linked role and Spot vCPU quota for one c7g.metalDSONOT VERIFIED
P9AWS Budget alarm (optional backstop)DSOSet by DSO 2026-10-13: budget ctos-monthly-5, USD 5/month, filtered to project=ctos, alerts at 80% actual and 100% forecast.
P10Credential rule 4 and the PX0 exception record referenced in the PR that enables the workflowDSORecord not read for this ADR; rule text not reproduced here
P11KVM_ROLE_ARN is set lastDSOUnset, so the workflow is inert (verified)
P12Third-party actions pinned by full commit SHAAuthor, before enablingNot done (tags in the sketch)
P13A supervised pilot run, then DSO reviews CloudTrail (who assumed what, which calls, which denies)DSO + sponsorNot done
P14The exact immutable-subject sub string confirmedDSONOT VERIFIED
P15Public-log hygiene confirmed on the pilot (no identifier in the log or summary)DSONot done

7. Risk and impact evaluation (for the sponsor record)

Impact is “what an attacker or a mistake could cost”. Residual is after the listed mitigations, assuming P1–P15 are met.

#ThreatLikelihood × impact (before)MitigationsResidual risk
1Another repo, branch, fork or pull request assumes the roleLow × HighExact aud and sub (no wildcard); environment with required reviewers and main-only branch policy; fork PRs get a different sub and no id-token for secretsLow. Depends on the exact sub being right (P14) and the environment existing first (P5).
2A PR edits the workflow to abuse the roleMedium × HighDispatch runs only the default-branch file; guard refuses any ref but main; environment gate; enforce_admins is onLow–Medium. main requires no approving review (observed), so the control is the sponsor merging by convention. DSO may ask the sponsor to require one review on main or a code-owner rule for .github/workflows/.
3Role is over-permissioned (miner, wrong instance type, other region)Medium × HighConditions on instance type, Spot market, IMDSv2, tags and region; pinned image/subnets/SG; 1 h session; no IAM, no PassRole; Spot quota; budget alarmLow. IAM conditions are untested until the pilot (P13).
4Tag spoofing (a resource tagged project=ctos to dodge the sweeper, or the terminate scope abused)Low × LowTags only at creation (CreateAction); terminate only matches tags the role itself applied; the sweeper counts the tag broadlyLow. A person with other credentials could still tag things; out of scope for this role.
5Privilege escalation through the instanceLow × HighNo instance profile, no iam:PassRole, explicit Deny on iam:*; IMDSv2 required, hop limit 1; no credentials on the boxLow.
6Secret in user-dataLow × MediumUser-data holds only a commit, a nonce and a timer; no keys, tokens or URLs with secretsVery low.
7Identifier leakage in public logs (instance ID, account ID, host name, IP)Medium × Low–MediumAccount-ID masking; instance IDs masked at creation; only ^CTOSB2: lines and error codes are printed; no raw console or stderr; identifier guard on repo textLow. Confirm on the pilot (P15).
8Leaked running instance (cost, exposure)Medium × LowPre-flight refusal; in-instance shutdown -h +45 and terminate-on-shutdown; always-run terminate with fresh credentials, by tag; wait for terminated; sweeper gives LEAK; budget alarmLow. Worst case about $0.40/h until noticed if the runner and the user-data both fail.
9Console text is forged or replayedLow × MediumPer-run nonce and commit in the result line; anchored PASS line; KVM and arch markers; terminate-by-tag so one run’s console is read onceLow. The code under test can print its own PASS (inherent; the commit is recorded and the tested commit must be on main).
10Silent green on a skip or timeoutMedium × High (trust)Only PASS exits 0; SKIPPED, INCONCLUSIVE, REFUSED, LEAK, TEARDOWN-SLOW, ABORTED-after-aws-call, NOT-RUN are red; guard fails (not skips) on an unset variableLow.
11Spot capacity or interruptionHigh × LowMulti-AZ retry; honest SKIPPED / INCONCLUSIVE; no on-demand fallbackAccepted: the check is sometimes unavailable.
12Third-party action supply chain (configure-aws-credentials, checkout)Low × HighPin by full commit SHA before enabling (P12); permissions: {} at top level; the job only gets id-token: write and contents: readLow–Medium until pinned.
13Supply chain on the instance (apt, the rustup install script, git clone, QEMU source)Medium × LowQEMU is built from the pinned v10.0.0 source plus the repo’s patches; the repo is cloned at a commit already on main; the instance holds no credentials, has no ingress, IMDSv2 hop limit 1, and lives under an hourLow. Open egress remains (needed for apt/rustup/git).
14Repo rename or transfer (subject reuse by a new owner)Low × HighThe immutable subject (owner and repo IDs) is what the trust policy pinsLow once P14 is confirmed.
15Cost runawayLow × LowCost-cap guard; MaxPrice; lifetime; concurrency 1; budget alarmLow; about $0.33 worst case per run [estimate].
16CloudTrail off, so no audit trailUnknown × HighCondition (a) is a hard precondition before the role is created or usedP1 met 2026-10-13 (multi-region trail, DSO-verified).
17Unprotected environment auto-created by the workflow’s first referenceMedium × HighP5: create the environment with protections first; order mattersLow if the order is followed.
18Unknown account facts (Spot service-linked role, quota, EBS default key)Medium × LowP8; pilot; any new permission goes to the sponsor, not added silentlyLow.
19Credential rule 4 is only relaxed for this roleLow × MediumOne role, one repository, one environment, no stored key, 1 h tokens; the exception record is referenced in the enabling PR (P10)Low. Any second role or a schedule needs its own approval.

8. The inert workflow

.github/workflows/kvm-recheck.yml and scripts/kvm-recheck-userdata.sh are the sketch.

  • Triggers: workflow_dispatch only (one optional input, the commit to test, which must be on main). No schedule. It is not, and must not become, a required check.
  • Fails closed: the first step makes no AWS call and fails (red, summary “NOT-RUN”) unless the repository, the ref (refs/heads/main), the role variable (and image, group, subnets, region variables), the role ARN shape and the cost arithmetic are all in order. While KVM_ROLE_ARN is unset, it cannot do anything else.
  • Permissions: permissions: {} at the top; the job gets id-token: write and contents: read only.
  • Pinned inputs come from variables, not repo text.
  • Pre-run checks done locally (no AWS): YAML parse, actionlint with shellcheck, bash -n and shellcheck on the user-data, the guard run with simulated variables (unset, wrong ref, wrong repo, bad ARN, over-cap, hostile input), and the console parser run on the real ADR-095 excerpt (see section 2).
  • Sketch status: the workflow has never run; the ubuntu-24.04 runner’s AWS CLI behaviour, the aws-actions inputs, and every AWS call are untested.

User-data is derived from the ADR-095 variant of scripts/b2-metal-userdata.sh: same QEMU build, same cargo build --features b2-serror, same scripts/b2-kvm-smoke.py with CTOS_B2_TIMEOUT=90 and CTOS_B2_STALL=15, up to three attempts; plus the timer first, the uname -srm (no host name), KVM API line, CTOSB2: result line with commit and nonce, and the linger-then-poweroff.

Consequences

  • If accepted and later enabled: a re-check of the reduced profile can run on demand without a stored AWS key, with the role limited to one region, one instance type, one pinned image, tagged resources and no IAM, at roughly $0.03–0.07 per run [estimate].
  • Now: nothing changes. The workflow is a file that fails closed. The honesty ledger gets a Planned row (documented sketch, not implemented), not Implemented.
  • Cost of the choice: the console channel is narrow and text only; Spot capacity makes the check sometimes unavailable; the immutable-subject format and several account facts have to be verified by DSO before the first run.
  • Not decided here: a schedule, a per-merge run, making it a required check, an S3 or SSM result channel, an on-demand fallback, a standalone sweeper. Each needs its own sponsor decision.

Pilot spec (one-off, supervised; 2026-10-13)

Sponsor decision 2026-10-13 20:49 CEST (DSO relay): prepare this PR as the exact spec for one supervised pilot run. Not recurring: the workflow stays workflow_dispatch only, with no schedule, and it is not a required check. The PR stays draft. Nothing here creates an AWS or GitHub resource.

Order of operations (DSO / sponsor; the agent does none of these):

  1. CloudTrail on (P1): met, verified by DSO from the arm64 workstation, 2026-10-13 about 20:56 CEST: a multi-region trail, home region eu-north-1.
  2. Create the GitHub environment kvm-pilot (below), before the role.
  3. Create the OIDC provider (if absent) and the role through the sponsor-approved path, with exactly the trust policy and permission policy in section 1 and role MaxSessionDuration = 3600 s.
  4. Set the repository variables KVM_AMI_ID, KVM_SG_ID (pre-created, tagged project=ctos, no ingress), KVM_SUBNET_IDS, and last KVM_ROLE_ARN.
  5. One supervised dispatch from main with pilot_ack = supervised-one-off-pilot; a reviewer approves the environment deployment; DSO watches the run.
  6. DSO reviews CloudTrail for the session, then unsets KVM_ROLE_ARN (back to inert) unless the sponsor decides otherwise.

Exact sub (environment-scoped, immutable-subject form): repo:artofdream@<OWNER_ID>/ctos@<REPO_ID>:environment:kvm-pilot. The repository reports use_default: true, use_immutable_subject: true (read-only GitHub API, 2026-10-13); its subject prefix carries the numeric owner and repository IDs. <OWNER_ID> and <REPO_ID> are those numeric IDs: values supplied to DSO out of band, not written here. StringEquals, no wildcard.

Environment kvm-pilot (GitHub setting, $0; to be created by DSO / the sponsor):

SettingValue
Namekvm-pilot (the workflow’s environment: and the sub must match exactly)
Required reviewersthe sponsor (and/or DSO’s account); at least one; “Prevent self-review” on
Wait timer0
Deployment branches and tagsSelected branches and tags, one rule: branch main (no tags)
Allow administrators to bypassoff
Environment secrets / variablesnone (no stored keys; repository variables above are enough)

Pinned actions (full commit SHA, version in the comment; looked up from the release tags 2026-10-13):

ActionTagCommit SHA
actions/checkoutv7.0.13d3c42e5aac5ba805825da76410c181273ba90b1
aws-actions/configure-aws-credentialsv6.3.0e1253824e5c10ff9df46874f81ed3ec929e19cfd

Region: eu-north-1 (hard-coded in the workflow and in the policies; not an identifier value).

Tags: project=ctos, component=kvm-ci-pilot, run=<run id> on the instance, volume, network interface and Spot request (RunInstances request-tag conditions); TerminateInstances and GetConsoleOutput require ec2:ResourceTag/project=ctos and ec2:ResourceTag/component=kvm-ci-pilot; CreateTags only with ec2:CreateAction=RunInstances.

Limits enforced by IAM condition keys: ec2:InstanceType=c7g.metal, ec2:InstanceMarketType=spot, ec2:MetadataHttpTokens=required, ec2:MetadataHttpPutResponseHopLimit ≤ 1 (NumericLessThanEquals), region via aws:RequestedRegion. Not enforceable by IAM: the Spot price. EC2 has no condition key for the Spot max price (AWS service reference, checked 2026-10-13), so the price cap is the workflow’s MaxPrice (MAX_PRICE_USD_HR, guard refuses above $0.60/h and above the per-run cap), plus the in-instance timer, plus an AWS Budget alarm (P9). Session: role MaxSessionDuration 3600 s (a role property, not a policy condition) and role-duration-seconds: 3600 in the workflow.

Results out: tag-scoped ec2:GetConsoleOutput only; no iam:PassRole, no instance profile (sponsor accepted 2026-10-02, recorded in the DSO vault).

CloudTrail (P1): met (verified by DSO from the arm64 workstation, 2026-10-13 about 20:56 CEST: a multi-region trail, home region eu-north-1). Still unverified: OIDC provider, Spot service-linked role and quota, EBS default key, live-vs-terminated console readability, IAM condition behaviour (first exercised in the pilot).

Setup recorded 2026-10-13 (DSO, sponsor-approved; docs only)

Relayed by DSO; this agent made no AWS call and set no repo variable. Text only, no identifier values.

  • Who/when: on the sponsor’s approval at 22:11 CEST, DSO created the kvm-pilot environment and the OIDC role between 22:12 and 22:16 CEST on 2026-10-13. This supersedes P6’s earlier “not by an agent” line.
  • Role: tagged project=ctos and component=kvm-ci-pilot; inline policy follows this ADR, including the hop-limit fix.
  • OIDC provider: already existed; shared, managed by another project’s Terraform.
  • Image: Ubuntu 24.04 arm64 AMI dated 20261004 - not the image ADR-095 used, so a pilot result is not a byte-identical re-run of ADR-095.
  • Security group: no ingress.
  • Environment protection (sponsor decision 22:15): sole reviewer is the sponsor, prevent_self_review off, deployments from main only, admin bypass off.
  • Budget: ctos-monthly-5, USD 5/month, filtered to project=ctos, alerts at 80% actual and 100% forecast.
  • Still open: P7/P11 repository variables unset (workflow inert), P8, P10, P12-P15.

Pilot run 2026-10-13 (relayed by DSO)

  • The sponsor set the repository variables and dispatched the one supervised pilot, workflow run 38468490030, at main 069fe83.
  • Smoke: PASS under accel=kvm on c7g.metal (el0: serror + b2: taken; run nonce 685780b675853a95).
  • The run’s final verdict was red, from a false LEAK: teardown’s 5-minute wait for terminated timed out while the metal instance was still shutting down, and the sweep counted it. DSO verified the instance reached terminated. Fixed by the slow-termination change below (item 4).
  • RunInstances succeeded with the policy as written (no snapshot resource).
  • Relayed facts; this agent made no AWS call. No instance or account identifiers here.

Review fixes 2026-10-13 (Cursor Bugbot findings on PR #152, plus pilot findings)

  1. High (Bugbot) - snapshot resource for RunInstances: kept out. AWS docs list snapshot as a RunInstances resource type (Service Authorization Reference; account-less ARN form), but the live pilot run launched without it (the AMI is public and its snapshot is not in this account). The policy is unchanged. Add a snapshot resource only if a concrete UnauthorizedOperation denial on a snapshot appears.
  2. Medium - a pre-flight refusal was reported as LEAK. The sweeper counted every active project=ctos instance, so the instance that triggered REFUSED-active-instance made teardown fail and the verdict became LEAK. The sweeper now counts only instances this run launched (tag run=<run id>); others only warn.
  3. Slow metal termination (pilot finding). The pilot’s teardown waited only 5 minutes for terminated; a c7g.metal was still shutting down, and the sweep reported a false LEAK. Teardown now polls up to TERM_WAIT_MIN (20 min; job timeout 75 min). At the end: a run-tagged instance still pending/running/stopping/stopped is LEAK; one still shutting-down is TEARDOWN-SLOW (red, not a pass, not LEAK; the smoke result stays in the summary). A later run’s pre-flight still refuses on any instance that never terminates.
  4. Subnet list parsing. KVM_SUBNET_IDS may be separated by spaces, commas, or both; an empty list after parsing fails the launch step.
  5. Low - NOT-RUN after an AWS call. The verdict defaulted to NOT-RUN whenever no result file existed, including a cancel or crash after assume-role or launch. New word ABORTED-after-aws-call: used when the credential step ran (success, failure or cancelled) and no result was written. NOT-RUN now means no AWS call was attempted. Teardown is unchanged (if: always() after credentials succeeded).

No identifier values. No AWS call, no repository variable set, no dispatch by the agent.

Open reconciliation points (for the enabling PR)

  1. CloudTrail (P1, condition (a)): met — verified by DSO from the arm64 workstation, 2026-10-13 about 20:56 CEST: a multi-region trail, home region eu-north-1.

DSO’s reconciliation notes, 2026-10-02. They are open points for the PR that would enable this workflow, not done here. This sketch does not close any of them.

  1. Exact immutable-subject string (P14): stated 2026-10-13 in the Pilot spec, with the numeric IDs supplied to DSO out of band; still to be confirmed against the pilot’s CloudTrail event. This repository’s sub claim uses the immutable-subject form (numeric owner and repository IDs), so the trust policy’s sub must be copied from what GitHub actually emits, then confirmed in the supervised pilot from the CloudTrail event. Never widened to a wildcard.
  2. The protected environment must be created first (P5): required reviewers and a main-only deployment branch policy, before the role and its trust policy exist. Today no such environment exists.
  3. Actions must be SHA-pinned (P12): done 2026-10-13 (see Pilot spec). Re-check against the tags before the run.
  4. Region: eu-north-1. Since 2026-10-13 the policies and the workflow state it directly; the region is eu-north-1, the one the G3 re-check (ADR-095) used. The region name is not an identifier value. AZ, subnet, image and security-group values stay in repository variables.
  5. Results-out (section 2) accepted. The sponsor accepted section 2 in writing on 2026-10-02 (results out via tag-scoped ec2:GetConsoleOutput, no iam:PassRole, no instance profile), recorded in the DSO vault, relayed by DSO. This satisfies condition (c) for that section only. Anything beyond it (S3, SSM, a PassRole) needs a new decision.
  6. Still open from the checklist and not touched by DSO’s notes: the OIDC provider (P4), the Spot service-linked role and quota (P8), the DSO vault decision record (P10; not read here), the supervised pilot (P13).

Merge notes

This PR is draft and must stay a draft until DSO and the sponsor decide. It was cut from main at 3c47c0c, then merged with main (a normal merge, no force-push) to stay mergeable after #149 and #150 merged. #149, #150 and #151 were merged by DSO on the sponsor’s instruction. The ADR-091 revision 4 PR (#153) also appends to docs/SUMMARY.md, research/moc.md and docs/framework/honesty-ledger.md; if it merges first, expect trivial append-only conflicts there: keep both sides.

Reproducibility

# Syntax and lint (no AWS)
python3 -c "import yaml;yaml.safe_load(open('.github/workflows/kvm-recheck.yml'))"
actionlint .github/workflows/kvm-recheck.yml    # needs shellcheck on PATH
bash -n scripts/kvm-recheck-userdata.sh
sh scripts/check-public-ids.sh --self-test && sh scripts/check-public-ids.sh   # hits=0