diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index 953ac02..44cc74b 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -5,8 +5,9 @@ GovOPlaN releases. It belongs to the `govoplan` meta repository because it works across all local checkouts, release scripts, module manifests, migration audits, catalog files, Git state, and signing keys. -The current implementation has a read-only dashboard plus guarded local -candidate/publish actions: +The current implementation has a read-only dashboard, preview-only legacy +controls, and a bounded durable executor for release steps whose inputs and +effects can be verified safely: - inspect repositories from `repositories.json` - show dirty, ahead, behind, missing, no-HEAD, and tag state @@ -19,10 +20,12 @@ candidate/publish actions: - select the repositories that should advance through checkboxes - generate dry-run selective release plans for independently versioned packages - freeze a selective plan as a durable, resumable local release run -- create annotated source release tags for selected clean, version-aligned - repositories and publish each branch/tag pair atomically -- generate signed catalog candidates for the selected release units -- preview/apply/push reviewed catalog candidates behind explicit confirmations +- durably preflight a creation-time-bound repository, create its annotated tag, + and publish its branch/tag pair atomically +- build selected Python wheels and generate a private, signed, receipt-bound + catalog candidate +- publish that exact candidate through a verified website commit and immutable + tag after explicit confirmation Start it from the meta repository: @@ -30,8 +33,32 @@ Start it from the meta repository: ./.venv/bin/python tools/release/release-console.py ``` -The server binds to `127.0.0.1` by default and prints a URL containing a local -API token. Open that URL in a browser on the same machine. +The launcher accepts only a numeric loopback address, binds to `127.0.0.1` by +default, always creates a fresh API token, and prints that token only in the URL +fragment. Open that URL in a browser on the same machine. A deliberately +tokenless embedded app is read-only: non-GET `/api/` requests fail with `403`. +Non-loopback operation needs a separately deployed authenticated TLS boundary; +the local launcher will not expose the mutation API that way. + +Run durable release mutation only against an operator-private workspace. +Repository roots, every path ancestor, critical and nested Git metadata, and +tracked worktree files must be owned by the console process UID (or root where +appropriate) and must not be group/world writable. Symlink/special Git +metadata, object alternates, and grafts are rejected. The console pins +`/usr/bin/git`, `/usr/bin/ssh`, a fixed system `PATH`, disabled hooks, isolated +Git configuration, and no replace objects. Shared or `nfsnobody`-owned +checkouts remain usable for read-only planning, but every durable executor +fails closed there; clone the registered origins into a private workspace +before releasing. + +The runtime itself is part of the authority boundary. Durable run creation +verifies the meta checkout, release/check tooling, repository registry, Python +environment, loaded `govoplan_core` and cryptography packages, and Git/SSH +executables. It then binds a clean meta-repository HEAD and named branch that +exactly match the registered `origin`. Every execution and reconciliation +rechecks that receipt. Permission checks cannot prove that code was safe before +it entered a writable tree, so release from a fresh operator-private clone or a +separately verified installed console artifact. The web UI starts with a repository table. Each repository can be checked independently and assigned its own target version. Repositories without version @@ -57,6 +84,12 @@ into a versioned local run record. The server rebuilds the selective plan and requires the plan to resolve exactly the requested repositories and target versions; the browser cannot submit or replace the plan snapshot. The input and plan are then immutable and covered by a canonical SHA-256 integrity digest. +Every executable repository step also carries its creation-time full HEAD, +branch, worktree state, target tag, and a SHA-256 over both the fetch and push +URLs of `origin`. Both URLs must exactly equal the remote registered in +`repositories.json`; a changed HEAD, branch, worktree, remote, or push URL +requires a new run rather than silently retargeting the frozen compatibility +decision. The complete record also has a checksum so a valid-looking manual edit to its mutable state fails closed. File permissions remain the authority boundary; these digests detect accidental or manual corruption, not an attacker who can @@ -101,8 +134,10 @@ checks it. An alternate workspace therefore cannot list or resume another workspace's runs. This remains true for an embedding/test `run_state_root` override: the server always appends `workspace-/release-runs/` rather than treating the override as a -shared record directory. A corrupt record from one workspace therefore cannot -leak even its identifier or an integrity error into another workspace. +shared record directory. Durable candidates use its private sibling +`release-candidates/` directory, never a checkout-local runtime path. A corrupt +record from one workspace therefore cannot leak even its identifier or an +integrity error into another workspace. Each frozen plan step has an explicit `pending`, `running`, `succeeded`, `failed`, or `interrupted` state. Plan order remains a prerequisite: a later @@ -140,6 +175,27 @@ effect, and `unresolved` keeps the run blocked. Each outcome emits a bounded, code-only state event. A known failed attempt can likewise be prepared for retry. The UI keeps unavailable controls visible and disabled. +Supported executors durably claim the step before invoking an effect. Exact +attempt replays return the recorded outcome and never invoke the executor a +second time. Successful repository preflight, tag, and push steps persist a +bounded repository-state receipt. Tag reconciliation independently requires an +annotated local tag at the frozen HEAD; push reconciliation additionally +requires both the remote annotated tag object and remote branch to match. +Catalog generation persists +only its server-issued opaque candidate ID and canonical catalog SHA-256, then +re-resolves and re-hashes that private candidate before publication. + +The safe baseline is intentionally narrower than the complete dry-run plan. +Dirty worktrees and version changes still show commit/version steps, but those +steps have no durable executor because the frozen run does not bind the exact +proposed file content. A run selecting Core together with one or more modules +begins with a disabled dependency-ordering barrier: local module tags, Core +release-lock regeneration/commit, Core tagging, alignment verification, and +pushes need an explicit DAG executor before that composition can mutate. +Operators must prepare and review those changes outside the console and create +a new run from the resulting clean HEAD. The console never skips these steps or +claims an end-to-end release succeeded. + The browser likewise retains the request identifier for an uncertain resume/retry/reconciliation response and replays it after reload. A successful replay selects the returned run state. Transport and server failures retain the @@ -151,9 +207,10 @@ other `/api/` route: - `POST /api/release-runs` requires `request_id`, then idempotently rebuilds and freezes a selective plan only when that creation is not already known. -- `GET /api/release-runs` lists bounded summaries ordered by verified - `updated_at`, then `created_at` and run identifier. `next_cursor` advances a - stable descending traversal without offset duplicates. Unreadable entries +- `GET /api/release-runs` lists bounded summaries ordered by immutable + `created_at` and run identifier. `next_cursor` advances a stable descending + traversal even while older runs are updated, without offset duplicates or + skips. Unreadable entries remain a deterministic final section and are therefore reachable through pagination instead of displacing newer verified runs. - `GET /api/release-runs/{run_id}` reads and verifies one exact record. @@ -162,16 +219,28 @@ other `/api/` route: read-only interrupted step for another attempt. - `POST /api/release-runs/{run_id}/steps/{step_id}/reconcile` records a confirmed observed outcome for an interrupted mutating step. +- `POST /api/release-runs/{run_id}/steps/{step_id}/execute` claims and invokes + only the narrow executor declared by the immutable plan step. Durable release + execution accepts only the registered `origin`, never a caller-selected + remote. +- `POST /api/release-runs/{run_id}/steps/{step_id}/preview` provides the + non-mutating preview for receipt-bound catalog publication. Run-storage errors are confined to the Durable Run State card; dashboard and release-preview collection continue and show the bounded storage remediation. -This foundation tracks state only. It deliberately does not run a plan step or -infer success from a button click. Existing preview and executor controls stay -separate and continue to require `COMMIT`, `TAG`, `PUBLISH`, `APPLY`, or `PUSH` -at their existing narrow boundaries. Wiring confirmed executors to claim and -finish run steps, plus package/install verification, remains follow-up work; -until then the console must not present a run as execution evidence. +The run record is execution evidence only for a supported step whose durable +claim and bounded result receipt were persisted. The console never infers +success from a button click or process exit alone. An executor exception or a +lost result write leaves the attempt interrupted and non-retriable until +explicit recovery. Catalog publication persists the candidate and keyring +hashes, exact website commit, annotated tag object and peeled commit, branch, +tag name, and registered-origin digest. A successful reconciliation revalidates +the candidate against the trust anchor in the frozen website parent, requires +the exact deterministic catalog/keyring/module blobs and full commit delta, a +sole frozen parent, and matching local and remote branch/tag identities. If any +part cannot be proved, the run remains interrupted and may only be recorded as +`unresolved`. Dashboard collection is also fail closed. Unreadable Core version metadata or a malformed module contract is returned as a bounded `collection_errors` entry @@ -198,11 +267,12 @@ Plain repository pushes are separate from catalog publication. `Preview Push` shows the selected repository push commands. `Push Selected` requires `PUSH` in the repository push confirmation field. -The source release panel closes the former gap between a release plan and a -catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags` -requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the -selected branch and annotated tag. The gate requires an aligned target version, -a clean named branch with a HEAD, and a checkout that is not behind. Existing +The source release panel retains `Preview Tag + Publish` as a non-mutating +inspection. Its legacy `Create Tags` and `Publish Tags` controls stay visible +but disabled; the corresponding mutation endpoint rejects apply requests. +Creation and atomic branch/tag publication use the `TAG` and `PUBLISH` +confirmations on the durable run steps. The gate requires an aligned target +version, a clean named branch with a HEAD, and a checkout that is not behind. Existing local or remote tags must resolve to the selected HEAD and are never moved. The local and remote annotated tag objects must also be identical, not merely point at the same commit. Before any source tag is created, the console loads @@ -210,15 +280,17 @@ the cross-repository module registry. This release gate also rejects every user-facing workflow documentation topic that has no scope condition, or has an alternative condition without `required_scopes` or `any_scopes`. -The catalog workflow panel can also operate on the same selected rows: +The catalog workflow panel can also operate on the same selected rows for +generation and preview. Its legacy apply/push controls stay disabled; durable +publication consumes only the candidate receipt recorded by that run: - `Generate` creates a signed candidate in the operator's private XDG state directory and records only an opaque candidate ID plus the canonical catalog SHA-256 in durable run state. - `Preview` validates a candidate and shows what would be copied into the website repository. -- `Apply + Website Tag` requires `APPLY` in the confirmation field. -- `Push Website Release` requires `PUSH` in the confirmation field. +- `Apply + Website Tag` remains visible but disabled outside a durable run. +- `Push Website Release` remains visible but disabled outside a durable run. Source release tags belong to Core or module repositories. Website catalog publication creates a separate catalog tag in the website repository; the UI @@ -243,14 +315,29 @@ cannot be applied or published while any referenced source tag is absent or inconsistent; the preview and API response list each repository and missing or invalid ref so the operator can repair the exact source releases first. -Every selected Python release must also supply its exact built wheel. Candidate -generation computes the archive SHA-256 and an install-stable payload identity -from one bounded, regular-file descriptor and signs those values into -`release.artifacts`. Updating a Python version without a matching wheel removes -the stale identity. A source-only preview can still explain the gap, but apply, -commit, tag, and push fail closed until every selected Python unit has a matching -built-artifact identity. Repositories selected only for a meta/source tag do not -need a Python artifact. +Every selected Python release must also supply its exact built wheel. Durable +generation first verifies that the receipt-bound annotated tag is the same +object locally and on the registered origin. It clones an isolated checkout at +the receipt's exact commit and builds from that checkout, never from the mutable +live worktree. Every authenticated base-catalog source is likewise cloned from +its registered origin at an annotated release tag; only selected repositories +contribute synthesized fields. The base catalog and keyring are read as one +authenticated, exact private snapshot before synthesis. + +Python wheels are built by a required Bubblewrap worker with no network, +private temporary/home directories, a read-only exact source mount, no host +home or file keys, cleared environment, fixed system tools, and CPU/address +space/process/file-size limits. If a trusted Bubblewrap launcher is unavailable, +generation fails closed. The worker must return one bounded regular wheel; the +console copies it through no-follow descriptors into a fresh private file, +`fsync`s it, then validates its package identity. Generation computes the +archive SHA-256 and an install-stable +payload identity from one bounded, regular-file descriptor and signs those +values into `release.artifacts`. Updating a Python version without a matching +wheel removes the stale identity. A source-only preview can still explain the +gap, but apply, commit, tag, and push fail closed until every selected Python +unit has a matching built-artifact identity. Repositories selected only for a +meta/source tag do not need a Python artifact. Candidate directories and files are operator-owned `0700`/`0600` state. Before any later release step consumes one, the executor re-resolves the opaque handle @@ -258,6 +345,14 @@ below the configured root and re-hashes the signed catalog against its persisted receipt. Shared roots, symlinks, channel path fragments, altered candidates, and unowned files are rejected. +The current same-host worker is a containment baseline, not the final hostile +build-service boundary. `RLIMIT_FSIZE` is per file, and the worker does not yet +have a size-limited filesystem/cgroup quota, a fresh kernel keyring, or a +seccomp profile denying keyctl/request-key/ptrace/mount operations. A malicious +backend could therefore exhaust scratch disk/inodes or target same-UID kernel +facilities. Closing that denial-of-service/isolation gap requires a dedicated +quota/cgroup worker with a fresh keyring and seccomp policy. + The server-owned wheel builds remain under the private candidate's `artifacts/` directory. This slice signs their identities but does **not** upload the wheel or add a download URL to the public catalog; the existing Git `python_ref` is source @@ -274,7 +369,10 @@ configured Git remote. The default signing key is `$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is -entered in the UI. +entered in the UI. Signing-key files must be regular, owned by the operator, and +inaccessible to group/other users. The browser sends a configured key path only +on the initial execution request; it never persists signing material in session +storage, and request-ID recovery replays no key path. Generate a selective release plan from the terminal: diff --git a/tests/test_release_console_security.py b/tests/test_release_console_security.py index 451e61b..301df1d 100644 --- a/tests/test_release_console_security.py +++ b/tests/test_release_console_security.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess import sys import unittest from pathlib import Path @@ -36,6 +37,30 @@ class ReleaseConsoleSecurityTests(unittest.TestCase): self.assertIn("window.location.hash.slice(1)", webui) self.assertIn("history.replaceState", webui) + def test_tokenless_embedding_is_read_only(self) -> None: + with TestClient(create_app(workspace_root=META_ROOT)) as client: + read_response = client.get("/api/health") + write_response = client.post("/api/selective-plan", json={}) + + self.assertEqual(200, read_response.status_code) + self.assertEqual(403, write_response.status_code) + self.assertIn("read-only", write_response.json()["detail"]) + + def test_launcher_rejects_non_loopback_and_tokenless_modes(self) -> None: + launcher = RELEASE_ROOT / "release-console.py" + for arguments in (("--host", "0.0.0.0"), ("--no-token",)): + with self.subTest(arguments=arguments): + result = subprocess.run( + (sys.executable, str(launcher), *arguments), + cwd=META_ROOT, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + ) + self.assertEqual(2, result.returncode) + if __name__ == "__main__": unittest.main() diff --git a/tools/release/release-console.py b/tools/release/release-console.py index afc93fc..329728c 100644 --- a/tools/release/release-console.py +++ b/tools/release/release-console.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import ipaddress from pathlib import Path import secrets import sys @@ -11,12 +12,31 @@ import sys from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT +def loopback_host(value: str) -> str: + try: + address = ipaddress.ip_address(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "release console host must be a numeric loopback address" + ) from exc + if not address.is_loopback: + raise argparse.ArgumentTypeError( + "release console refuses non-loopback hosts without an authenticated " + "TLS deployment boundary" + ) + return value + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.") + parser.add_argument( + "--host", + type=loopback_host, + default="127.0.0.1", + help="Numeric loopback bind address. Defaults to 127.0.0.1.", + ) parser.add_argument("--port", type=int, default=8765, help="Bind port. Defaults to 8765.") parser.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT) - parser.add_argument("--no-token", action="store_true", help="Disable the local API token guard.") args = parser.parse_args() try: @@ -28,13 +48,13 @@ def main() -> int: from server.app import create_app workspace_root = args.workspace_root.expanduser().resolve() - token = None if args.no_token else secrets.token_urlsafe(24) + token = secrets.token_urlsafe(24) app = create_app(workspace_root=workspace_root, token=token) - url = f"http://{args.host}:{args.port}/" - if token: - # URL fragments are never sent in HTTP requests. The WebUI transfers - # this one-time bootstrap token to sessionStorage and clears the hash. - url = f"{url}#token={token}" + display_host = f"[{args.host}]" if ":" in args.host else args.host + url = f"http://{display_host}:{args.port}/" + # URL fragments are never sent in HTTP requests. The WebUI transfers + # this one-time bootstrap token to sessionStorage and clears the hash. + url = f"{url}#token={token}" print("GovOPlaN release console") print(f" workspace: {workspace_root}") print(f" url: {url}")