From 7658ab7ee27f767173da8f47f8f1f8f1b0c36e38 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 02:56:51 +0000 Subject: [PATCH 01/22] microVM JIT mint on the host and attempt-owned jail staging Close jit_mint_http_realization_frontier and jail_jit_device_staging_frontier. - extdeps.auth.jws: RFC 7515 compact JWS signing input + RS256 (RFC 7518 3.3). - extdeps.tools.openssl: enrolled host CLI dependency; openssl dgst -sha256 -sign -hex, signing input on stdin, no secret in argv. - extdeps.github: POST app/installations/{id}/access_tokens and org generate-jitconfig as REST operations on the #10923 performer. - gunbc.github_effect_perform: the effect home; perform_organization_jit_mint signs the App JWT on the host with the controller-custodied key, mints the installation token and the JIT config, and returns a typed performance (commit-ambiguous generate is its own arm). - gunbc.runner_attempt_launch: admits a credential only from a delivered, attempt-bound, floor-to-ceiling mint; the jit device and the jailer are one plan arm, so no admitted credential means no device and no VMM. The device is install -m 0400 -o before any byte, written via the filesystem, NUL-padded to whole sectors, read back. Registration id and runner name are recorded on the launch. - Drop the out-of-jail jit.img path fork (runner_microvm_attempt / runner_jit_mint / runner_jit_perform): the device location has one owner. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/auth/jws.dag | 70 +++ dag/extdeps/exec/command.dag | 5 + dag/extdeps/github/actions_jit_runner.dag | 55 ++- dag/extdeps/github/app.dag | 38 ++ dag/extdeps/github/org_admin_auth.dag | 11 + dag/extdeps/package_managers/apt.dag | 1 + dag/extdeps/tools/gnu_coreutils.dag | 21 +- dag/extdeps/tools/openssl.dag | 112 +++++ dag/extdeps/tools/stat.dag | 14 + dag/gunbc/github_effect_perform.dag | 175 ++++++++ dag/gunbc/host/host_cli_dependency.dag | 7 + dag/gunbc/runner/runner_attempt_launch.dag | 323 ++++++++----- .../runner/runner_bound_attempt_receipt.dag | 7 +- dag/gunbc/runner/runner_jit_mint.dag | 7 +- dag/gunbc/runner/runner_jit_perform.dag | 86 ++-- dag/gunbc/runner/runner_microvm_attempt.dag | 6 - .../github_app_registry_witness_test.dag | 39 +- .../github_effect_perform_witness_test.dag | 116 +++++ .../runner_attempt_launch_witness_test.dag | 424 +++++++++++------- .../runner_microvm_attempt_witness_test.dag | 2 +- docs/plans/ci-per-job-microvm-migration.md | 7 +- 21 files changed, 1159 insertions(+), 367 deletions(-) create mode 100644 dag/extdeps/auth/jws.dag create mode 100644 dag/extdeps/tools/openssl.dag create mode 100644 dag/gunbc/github_effect_perform.dag create mode 100644 dag/test/claim/github_effect_perform_witness_test.dag diff --git a/dag/extdeps/auth/jws.dag b/dag/extdeps/auth/jws.dag new file mode 100644 index 00000000000..ba0ad2643f1 --- /dev/null +++ b/dag/extdeps/auth/jws.dag @@ -0,0 +1,70 @@ +module extdeps.auth.jws + +import std.types { String, NonEmptyStr, Int, List } +import std.integer { UInt8 } +import std.bytes { bytes_octets, utf8_encode_bytes } +import std.encoding { base64_encode, UrlSafe } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "datatracker.ietf.org/doc/html/rfc7515" } +} + +data jwa_rs256_citation: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "datatracker.ietf.org/doc/html/rfc7518#section-3.3" } +} + +// THIS MODULE IS THE SHAPE OF A COMPACT JWS AND NOTHING ELSE. It produces the JWS Signing Input +// (RFC 7515 section 5.1 step 5: BASE64URL(UTF8(protected header)) '.' BASE64URL(payload)) and joins +// a signature the CALLER obtained onto it (section 7.1). It does not sign: the signature is a +// private-key operation, and which realization performs it -- a host CLI, an HSM, a library -- is a +// transport bound to this shape, never a fact about JWS (DESIGN §3). extdeps.auth.jwt describes +// claims and does not verify them; this module is the other direction and does not verify either. +// +// ONE ALGORITHM, RS256, because one is what this fleet signs with. RFC 7518 registers many; +// modeling ones no realization produces would be vocabulary with no consumer (DESIGN §3c). When a +// second signer lands, this row becomes an algorithm coproduct and the functions take it. +data jws_rs256_alg: NonEmptyStr = "RS256" + +// BASE64URL IS RFC 7515 section 2's: the URL-safe alphabet of RFC 4648 section 5 WITH ALL TRAILING +// '=' OMITTED. std.encoding pads, so the padding is cut here rather than a second encoder written. +fn jws_base64url_octets(octets: List) -> String { + join(split(s: base64_encode(octets: octets, variant: UrlSafe), delimiter: "="), "") +} + +fn jws_base64url_text(s: String) -> String { + jws_base64url_octets(octets: bytes_octets(b: utf8_encode_bytes(s: s))) +} + +fn jws_rs256_protected_header_json() -> String { + join(["\{\"alg\":\"", jws_rs256_alg as String, "\",\"typ\":\"JWT\"}"], "") +} + +fn jws_rs256_signing_input(payload_json: String) -> NonEmptyStr { + join([ + jws_base64url_text(s: jws_rs256_protected_header_json()), + ".", + jws_base64url_text(s: payload_json), + ], "") as NonEmptyStr +} + +// THE SIGNATURE IS OCTETS, NOT TEXT. A realization that hands back a printable rendering (hex, +// standard base64) decodes it to octets first; this join then encodes exactly once. Accepting a +// pre-encoded String here would let a standard-alphabet signature through looking valid. +fn jws_compact_serialization(signing_input: NonEmptyStr, signature: List) -> NonEmptyStr { + join([signing_input as String, ".", jws_base64url_octets(octets: signature)], "") as NonEmptyStr +} + +data extdeps_model_scope: ExternalModelScope = ExternalModelScope { + subject: ExternalSubjectRef { + declaration: DeclarationRef { + module_path: "extdeps.auth.jws", + decl_name: "jws_rs256_signing_input", + field: WholeDeclaration + } + }, + first_citation: extdeps.auth.jws.extdeps_external_authority_anchor, + further_citations: [extdeps.auth.jws.jwa_rs256_citation] +} diff --git a/dag/extdeps/exec/command.dag b/dag/extdeps/exec/command.dag index eac4dd24b28..1bfd96ed44f 100644 --- a/dag/extdeps/exec/command.dag +++ b/dag/extdeps/exec/command.dag @@ -155,6 +155,11 @@ fn argv_command(program: NonEmptyStr, arguments: List) -> ArgvCommand decl_ref(module_path: "extdeps.sudo.elevation", decl_name: "sudo_preserve_environment_command"), decl_ref(module_path: "extdeps.tools.stat", decl_name: "stat_file_type_command"), decl_ref(module_path: "extdeps.tools.stat", decl_name: "stat_inode_command"), + decl_ref(module_path: "extdeps.tools.stat", decl_name: "stat_owner_uid_command"), + decl_ref(module_path: "extdeps.tools.stat", decl_name: "stat_mode_command"), + decl_ref(module_path: "extdeps.tools.stat", decl_name: "stat_size_bytes_command"), + decl_ref(module_path: "extdeps.tools.gnu_coreutils", decl_name: "install_empty_file_command"), + decl_ref(module_path: "extdeps.tools.gnu_coreutils", decl_name: "truncate_to_size_command"), decl_ref(module_path: "extdeps.tools.mkdir", decl_name: "mkdir_parents_command_at"), decl_ref(module_path: "extdeps.tools.mkdir", decl_name: "mkdir_exact_command_at"), decl_ref(module_path: "gunbc.claim_executor_cli", decl_name: "claim_executor_command"), diff --git a/dag/extdeps/github/actions_jit_runner.dag b/dag/extdeps/github/actions_jit_runner.dag index b62df115f5b..089247f4154 100644 --- a/dag/extdeps/github/actions_jit_runner.dag +++ b/dag/extdeps/github/actions_jit_runner.dag @@ -1,6 +1,8 @@ module extdeps.github.actions_jit_runner -import std.types { String, NonEmptyStr, Bool, Int, List, HttpStatus } +import std.types { String, NonEmptyStr, Bool, Int, List, HttpStatus, Secret } +import extdeps.github.github { default_api_base } +import extdeps.transports.rest { RestOutcome } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } @@ -123,3 +125,54 @@ fn jit_config_mint_outcome(status: HttpStatus, encoded: String, runner_id: Int, } } } + +// THE MINT IS ONE OPERATION ON GitHub's REST SURFACE, bound to the shape above. The Bearer is an +// INSTALLATION access token (extdeps.github.app github.AppInstallationAccessTokens), taken as a +// Secret input and never an argv word. The body is JitRunnerConfigRequest's wire: labels arrive as +// the JSON array the endpoint documents, projected by jit_runner_label_wire. 201 decodes the runner +// identity and the opaque blob; every other status is RestStatusRefused and is mapped through +// jit_config_mint_outcome, the one authority for what a non-201 means. +type JitConfigCreatedRunner { + id: Int +} + +type JitConfigCreatedBody { + runner: JitConfigCreatedRunner + encoded_jit_config: String +} + +service github.ActionsJitRunners { + config { + endpoint: default_api_base + auth: Bearer + auth_input: installation_token + } + + operation GenerateOrganizationJitConfig { + input { + installation_token: Secret + org: NonEmptyStr + name: NonEmptyStr + runner_group_id: Int + labels: List + work_folder: NonEmptyStr + } + output { + result: JitConfigCreatedBody + outcome: RestOutcome + } + transport rest { + method: POST, + path: "/orgs/\{org\}/actions/runners/generate-jitconfig", + body: { + name: name, + runner_group_id: runner_group_id, + labels: labels, + work_folder: work_folder + } + } + response { + 201 => JitConfigCreatedBody + } + } +} diff --git a/dag/extdeps/github/app.dag b/dag/extdeps/github/app.dag index 4af97cc51e6..25bd427a2ca 100644 --- a/dag/extdeps/github/app.dag +++ b/dag/extdeps/github/app.dag @@ -1260,3 +1260,41 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { extdeps.github.app.webhook_events_citation ] } + +// POST /app/installations/{installation_id}/access_tokens, cited by +// extdeps.github.org_admin_auth installation_token_citation. It authenticates AS THE APP -- the +// Bearer is an RS256 App JWT (extdeps.auth.jws), never an installation or user token -- and answers +// 201 with a token valid for one hour. The token is decoded straight into a Secret so it has no +// String life in this process. A mutation: a transport refusal is commit-ambiguous, and the caller +// classifies it through extdeps.transports.rest classify_rest_outcome as RestMutationExchange. +service github.AppInstallationAccessTokens { + config { + endpoint: default_api_base + auth: Bearer + auth_input: app_jwt + } + + operation Create { + input { + app_jwt: Secret + installation_id: Int + } + output { + token: Secret from "token" + outcome: RestOutcome + } + transport rest { + method: POST, + path: "/app/installations/\{installation_id\}/access_tokens", + body: { } + } + response { + 201 => GitHubInstallationAccessTokenBody + } + } +} + +type GitHubInstallationAccessTokenBody { + token: String + expires_at: String +} diff --git a/dag/extdeps/github/org_admin_auth.dag b/dag/extdeps/github/org_admin_auth.dag index ec0ee5a484c..736a804df36 100644 --- a/dag/extdeps/github/org_admin_auth.dag +++ b/dag/extdeps/github/org_admin_auth.dag @@ -38,6 +38,17 @@ data github_app_jwt_backdate_seconds: Int = 60 data github_app_jwt_lifetime_seconds: Int = 540 +// THE APP JWT'S CLAIMS, as installation_token_citation states them: iss is the App id, iat is +// backdated for clock skew, exp is within ten minutes of issue. One authority for the claim set, so +// a signer anywhere in the tree cannot pick its own bounds. +fn github_app_jwt_claims_json(app_id: Int, now_epoch_secs: Int) -> String { + join([ + "\{\"iat\":", to_string(now_epoch_secs - github_app_jwt_backdate_seconds), + ",\"exp\":", to_string(now_epoch_secs + github_app_jwt_lifetime_seconds), + ",\"iss\":\"", to_string(app_id), "\"}", + ], "") +} + data oauth_flow_citation: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https, locator: "docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps" } } diff --git a/dag/extdeps/package_managers/apt.dag b/dag/extdeps/package_managers/apt.dag index 0a448810142..26a61ac55f7 100644 --- a/dag/extdeps/package_managers/apt.dag +++ b/dag/extdeps/package_managers/apt.dag @@ -38,6 +38,7 @@ data package_curl: NonEmptyStr = "curl" data package_nbdkit: NonEmptyStr = "nbdkit" data package_socat: NonEmptyStr = "socat" data package_sshpass: NonEmptyStr = "sshpass" +data package_openssl: NonEmptyStr = "openssl" data package_mosquitto_clients: NonEmptyStr = "mosquitto-clients" data package_websocat: NonEmptyStr = "websocat" data package_xorriso: NonEmptyStr = "xorriso" diff --git a/dag/extdeps/tools/gnu_coreutils.dag b/dag/extdeps/tools/gnu_coreutils.dag index e4ea10f36f1..ba05b65580b 100644 --- a/dag/extdeps/tools/gnu_coreutils.dag +++ b/dag/extdeps/tools/gnu_coreutils.dag @@ -1,7 +1,7 @@ module extdeps.tools.gnu_coreutils import std.types { NonEmptyStr, FilePath, String, List, Int } -import std.measure { Second, second_count } +import std.measure { Second, second_count, ByteSize, byte_size_count } import extdeps.version { VersionConstraint } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } @@ -42,6 +42,8 @@ data true_program: NonEmptyStr = "true" data whoami_program: NonEmptyStr = "whoami" data readlink_program: NonEmptyStr = "readlink" data ls_program: NonEmptyStr = "ls" +data install_program: NonEmptyStr = "install" +data truncate_program: NonEmptyStr = "truncate" // -1 ALONE, AND THE NAME CARRIES IT FOR THE SAME REASON rm_force_command's DOES. `-1` forces ONE // ENTRY PER LINE, which is what makes the output splittable by the caller: ls(1) columnates when @@ -62,6 +64,23 @@ fn cp_command(source: String, destination: String) -> ArgvCommand { argv_command(program: cp_program, arguments: [source, destination]) } +// install(1) COPYING /dev/null CREATES AN EMPTY FILE WITH ITS FINAL OWNER AND MODE IN ONE STEP. +// The point is the ORDER it makes possible: a file that will later hold a secret exists with its +// restrictive mode before a single secret byte is written, so there is no window in which the bytes +// sit under the default umask. Mode is an octal spelling because that is what -m documents. +fn install_empty_file_command(path: String, octal_mode: NonEmptyStr, uid: Int, gid: Int) -> ArgvCommand { + argv_command( + program: install_program, + arguments: ["-m", octal_mode as String, "-o", to_string(uid), "-g", to_string(gid), "/dev/null", path], + ) +} + +// truncate(1) -s N EXTENDS a shorter file with NUL bytes (and would CUT a longer one -- the caller +// owns ensuring N is not below the content it keeps). Used to pad a payload to a block boundary. +fn truncate_to_size_command(path: String, size: ByteSize) -> ArgvCommand { + argv_command(program: truncate_program, arguments: ["-s", to_string(byte_size_count(b: size)), path]) +} + // -f ALONE, NEVER -rf, AND THAT IS THE POINT OF THE NAME. -f makes a missing file a success, which // is what an unlink-if-present means; it does NOT recurse, so a path that turns out to be a // directory refuses instead of destroying its contents. The recursive form is a different operation diff --git a/dag/extdeps/tools/openssl.dag b/dag/extdeps/tools/openssl.dag new file mode 100644 index 00000000000..7bc65eebd52 --- /dev/null +++ b/dag/extdeps/tools/openssl.dag @@ -0,0 +1,112 @@ +module extdeps.tools.openssl + +import std.types { String, NonEmptyStr, Int, Bool, List } +import std.integer { UInt8 } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } +import extdeps.uri { Uri, Https } +import extdeps.tools { CliTool, SourceApt } +import extdeps.apt { package_openssl } +import extdeps.numeric.base16 { base16_decode_lower } +import v2.std.optional { Absent, Present } +import std.algebra { trim } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "docs.openssl.org/3.0/man1/openssl-dgst/" } +} + +data openssl_binary_name: NonEmptyStr = "openssl" + +// ENROLLED THE WAY extdeps.tools.sshpass IS: the binary this module execs and the binary +// gunbc.host_cli_dependency probes for are one declaration, so a probe cannot pass while the exec +// fails. min_version is none: `dgst -sign -hex` is present in every OpenSSL this fleet runs (1.1 and +// 3.x), and a floor nothing measured would be an invented constraint. +data openssl_cli_tool: CliTool = CliTool { + name: openssl_binary_name, + min_version: none, + installable_via: [SourceApt { package: package_openssl }], +} + +// `openssl dgst -sha256 -sign KEY -hex` IS RSASSA-PKCS1-v1_5 OVER SHA-256 -- RS256 as RFC 7518 +// section 3.3 defines it -- and it is exactly what the fleet's live mint executes today. +// +// WHY THE ARGV CARRIES A PATH AND NEVER A SECRET. The private key is read by openssl from a file +// the argv names; the data to sign arrives on stdin; the signature leaves on stdout. Nothing another +// process can read from /proc//cmdline is secret -- the class gunbc.credential_argv_exposure +// measures. -hex rather than -binary because a transport stdout is text, and an octet stream +// through a String is a lossy decode the caller could not detect; hex round-trips exactly. +service openssl.Dgst { + operation SignSha256Hex { + input { key_file: NonEmptyStr, signing_input: NonEmptyStr } + output { + exit_code: Int from "exit_code" + stdout: String from "stdout" + stderr: String from "stderr" + } + readonly + transport shell { + argv: [openssl_binary_name, "dgst", "-sha256", "-sign", "{key_file}", "-hex"] + stdin: signing_input + } + exit { + 0 => Unit + nonzero => Unit + } + } +} + +// THE -hex RENDERING IS `