diff --git a/bin/fm-azure-runner-exec.py b/bin/fm-azure-runner-exec.py index 33eda189202..3de17bcdf9f 100755 --- a/bin/fm-azure-runner-exec.py +++ b/bin/fm-azure-runner-exec.py @@ -20,6 +20,7 @@ RESULT_SCHEMA = "fm.azure-command-result/v1" +PRIVATE_SOURCE_MODES = ("private-parent-bundle", "private-exact-bundle") def fail(message): @@ -73,6 +74,35 @@ def verify_request(request): return argv, limits +def verify_private_source_ancestors(request_path, repo): + try: + request = json.loads(request_path.read_text(encoding="utf-8")) + repository = request["repository"] + if repository.get("source_mode") not in PRIVATE_SOURCE_MODES: + raise ValueError("private source ancestor verification requires a private bundle") + commit = repository["commit"] + ancestors = repository.get("source_ancestors", []) + for ancestor in ancestors: + object_type = subprocess.run( + ["git", "-C", str(repo), "cat-file", "-t", ancestor], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.strip() + if object_type != "commit": + raise ValueError("source ancestor is not a commit") + subprocess.run( + ["git", "-C", str(repo), "merge-base", "--is-ancestor", ancestor, commit], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except (KeyError, OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as exc: + return fail("private source ancestor verification failed: {}".format(exc)) + return 0 + + def drop_privileges(uid, gid, pid_max, disk_bytes): if os.environ.get("FM_AZURE_RUNNER_TEST_NO_DROP") == "1": if uid != os.getuid() or gid != os.getgid(): @@ -159,6 +189,8 @@ def __exit__(self, exc_type, exc, traceback): def main(): + if len(sys.argv) == 4 and sys.argv[1] == "--verify-private-source-ancestors": + return verify_private_source_ancestors(Path(sys.argv[2]), Path(sys.argv[3])) if len(sys.argv) != 8: return fail("expected request, repo, output, uid, gid, VM id, and boot id") request_path = Path(sys.argv[1]) diff --git a/bin/fm-azure-runner-guest.sh b/bin/fm-azure-runner-guest.sh index 85490f1747d..010000e82f4 100755 --- a/bin/fm-azure-runner-guest.sh +++ b/bin/fm-azure-runner-guest.sh @@ -94,8 +94,8 @@ if supplied != "sha256:" + hashlib.sha256(canonical).hexdigest(): raise SystemEx if "sha256:" + hashlib.sha256(executor_path.read_bytes()).hexdigest() != request["protocol"]["executor_digest"]: raise SystemExit("guest bootstrap: executor digest mismatch") if request["protocol"]["guest_digest"] != sys.argv[3]: raise SystemExit("guest bootstrap: guest digest mismatch") repo = request["repository"] -if repo.get("source_mode") not in ("public-github-https", "private-parent-bundle") or not repo.get("remote", "").startswith("https://github.com/"): raise SystemExit("guest bootstrap: source mode mismatch") -if repo.get("source_mode") == "private-parent-bundle": +if repo.get("source_mode") not in ("public-github-https", "private-parent-bundle", "private-exact-bundle") or not repo.get("remote", "").startswith("https://github.com/"): raise SystemExit("guest bootstrap: source mode mismatch") +if repo.get("source_mode") in ("private-parent-bundle", "private-exact-bundle"): if not repo.get("input_blob") or not repo.get("snapshot_digest") or not repo.get("snapshot_bytes"): raise SystemExit("guest bootstrap: private snapshot binding is incomplete") else: if repo.get("input_blob") is not None or repo.get("snapshot_bytes") != 0: raise SystemExit("guest bootstrap: public source carries private staging") @@ -145,7 +145,7 @@ runuser -u fmrunner -- git -C /work/repo remote add origin "$REMOTE" # repository as dubious (CVE-2022-24765); scope the exception through the # environment exactly as the validation cell guest does. export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=/work/repo -if [ "$SOURCE_MODE" = private-parent-bundle ]; then +if [ "$SOURCE_MODE" = private-parent-bundle ] || [ "$SOURCE_MODE" = private-exact-bundle ]; then [ "$INPUT_BLOB" = "$(read_request repository.input_blob)" ] || { echo "guest bootstrap: private snapshot blob mismatch" >&2; exit 125; } SNAPSHOT=$BASE/snapshot.bundle TOKEN_FILE=$BASE/input-token @@ -184,10 +184,15 @@ for value in json.load(open(sys.argv[1],encoding="utf-8"))["repository"].get("so PY while IFS= read -r ancestor; do [ -n "$ancestor" ] || continue - run_bootstrap_network runuser -u fmrunner -- git -C /work/repo fetch --depth=1 origin "$ancestor" - [ "$(git -C /work/repo rev-parse FETCH_HEAD)" = "$ancestor" ] || { echo "guest bootstrap: source ancestor identity mismatch" >&2; exit 125; } - git -C /work/repo cat-file -e "$ancestor^{commit}" || { echo "guest bootstrap: source ancestor is absent" >&2; exit 125; } + if [ "$SOURCE_MODE" = public-github-https ]; then + run_bootstrap_network runuser -u fmrunner -- git -C /work/repo fetch --depth=1 origin "$ancestor" + [ "$(git -C /work/repo rev-parse FETCH_HEAD)" = "$ancestor" ] || { echo "guest bootstrap: source ancestor identity mismatch" >&2; exit 125; } + git -C /work/repo cat-file -e "$ancestor^{commit}" || { echo "guest bootstrap: source ancestor is absent" >&2; exit 125; } + fi done <"$BASE/source-ancestors" +if [ "$SOURCE_MODE" = private-parent-bundle ] || [ "$SOURCE_MODE" = private-exact-bundle ]; then + /usr/bin/python3 "$EXECUTOR" --verify-private-source-ancestors "$REQUEST" /work/repo +fi [ "$(git -C /work/repo rev-parse HEAD)" = "$COMMIT" ] && [ "$(git -C /work/repo rev-parse 'HEAD^{tree}')" = "$TREE" ] || { echo "guest bootstrap: source identity mismatch" >&2; exit 125; } # Repository tests compare the snapshot against the default branch through # the refs/remotes/origin view (generation 051 ground truth: a behavior @@ -238,13 +243,19 @@ while IFS=$'\t' read -r url file bytes digest; do fetch_exact "$url" "/work/home/.fm-runner-tools/wheelhouse/$file" "$bytes" "$digest" done <"$BASE/wheels.tsv" chown -R fmrunner:fmrunner /work/home/.fm-runner-tools -[ "sha256:$(sha256sum /work/repo/tools/agent-fleet/uv.lock | awk '{print $1}')" = "$(read_request protocol.agent_fleet_python.lock_digest)" ] || { echo "guest bootstrap: lock mismatch" >&2; exit 125; } -# The run-command handler's download directory is root-only, so the -# unprivileged uv invocations must not inherit it as their working -# directory (uv's config discovery reads ./uv.toml and refuses on EACCES). -cd /work/repo -runuser -u fmrunner -- /work/home/.fm-runner-tools/uv/uv venv --python /usr/bin/python3 /work/repo/tools/agent-fleet/.venv >/dev/null -runuser -u fmrunner -- env UV_OFFLINE=1 UV_NO_INDEX=1 /work/home/.fm-runner-tools/uv/uv pip install --python /work/repo/tools/agent-fleet/.venv/bin/python --offline --no-index --find-links /work/home/.fm-runner-tools/wheelhouse pytest ruff >/dev/null +LOCK_DIGEST=$(read_request protocol.agent_fleet_python.lock_digest) +if [ "$LOCK_DIGEST" != None ]; then + [ "sha256:$(sha256sum /work/repo/tools/agent-fleet/uv.lock | awk '{print $1}')" = "$LOCK_DIGEST" ] || { echo "guest bootstrap: lock mismatch" >&2; exit 125; } + # The run-command handler's download directory is root-only, so the + # unprivileged uv invocations must not inherit it as their working + # directory (uv's config discovery reads ./uv.toml and refuses on EACCES). + cd /work/repo + runuser -u fmrunner -- /work/home/.fm-runner-tools/uv/uv venv --python /usr/bin/python3 /work/repo/tools/agent-fleet/.venv >/dev/null + runuser -u fmrunner -- env UV_OFFLINE=1 UV_NO_INDEX=1 /work/home/.fm-runner-tools/uv/uv pip install --python /work/repo/tools/agent-fleet/.venv/bin/python --offline --no-index --find-links /work/home/.fm-runner-tools/wheelhouse pytest ruff >/dev/null +elif [ -s "$BASE/wheels.tsv" ]; then + echo "guest bootstrap: unbound Python wheels" >&2 + exit 125 +fi python3 - "$REQUEST" /work/repo <<'PY' import hashlib,json,pathlib,sys diff --git a/bin/fm-azure-runner.py b/bin/fm-azure-runner.py index 26c00f52357..1c7eb70700d 100755 --- a/bin/fm-azure-runner.py +++ b/bin/fm-azure-runner.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Host-side controller for one-shot private Azure command runners. -The script binds a clean public committed Git snapshot to a canonical command +The script binds a clean committed Git snapshot to a canonical command request, creates one private controller VM with a container-scoped UAMI, drives an isolated networkless child through Azure Managed Run Command, verifies the bounded result, and removes only resources whose recorded identities match the @@ -209,7 +209,17 @@ def git(repo, *args, check=True): return run(["git", "-C", str(repo)] + list(args), check=check) +def credential_prompt_refuser(): + executable = shutil.which("false", path="/usr/bin:/bin") + if not executable: + raise RunnerError( + "public Git proof requires an executable false command in /usr/bin or /bin" + ) + return executable + + def public_git(repo, *args, check=True): + askpass = credential_prompt_refuser() git_env = { "PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C", @@ -217,8 +227,8 @@ def public_git(repo, *args, check=True): "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0", - "GIT_ASKPASS": "/bin/false", - "SSH_ASKPASS": "/bin/false", + "GIT_ASKPASS": askpass, + "SSH_ASKPASS": askpass, } command = [ "git", "-c", "credential.helper=", "-c", "http.extraHeader=", @@ -335,6 +345,52 @@ def public_origin_proof( return proof_identity +def private_bundle_origin_proof( + repo, remote, candidate_commit, source_ref, source_ancestors=(), expected=None +): + if not SAFE_PUBLIC_GIT_REMOTE.match(remote) or "@" in remote: + raise RunnerError( + "Azure private controller requires a credential-free GitHub HTTPS origin identity" + ) + source_ref = validate_public_source_ref(source_ref) + ancestors = [] + for ancestor in source_ancestors: + if not isinstance(ancestor, str) or not re.fullmatch(r"[0-9a-f]{40,64}", ancestor): + raise RunnerError("private source ancestor is not an exact commit identity") + if ancestor in ancestors: + continue + if git(repo, "cat-file", "-t", ancestor, check=False).stdout.strip() != "commit": + raise RunnerError("private source ancestor is not present in the exact checkout") + if git( + repo, "merge-base", "--is-ancestor", ancestor, candidate_commit, check=False + ).returncode != 0: + raise RunnerError("private source ancestor is not reachable from the candidate") + ancestors.append(ancestor) + if git(repo, "cat-file", "-t", candidate_commit).stdout.strip() != "commit": + raise RunnerError("private candidate source object is not an exact commit") + tree = git(repo, "rev-parse", "{}^{{tree}}".format(candidate_commit)).stdout.strip() + if ( + not re.fullmatch(r"[0-9a-f]{40,64}", tree) + or git(repo, "cat-file", "-t", tree).stdout.strip() != "tree" + ): + raise RunnerError("private candidate source tree identity is malformed") + proof_identity = { + "remote": remote, + "default_ref": None, + "default_head": None, + "source_ref": source_ref, + "source_head": candidate_commit, + "source_ancestors": ancestors, + "tree": tree, + } + binding_keys = ("remote", "source_ref", "source_head", "source_ancestors") + if expected is not None and any( + expected.get(key) != proof_identity[key] for key in binding_keys + ): + raise RunnerError("private bundle source identity changed after request preparation") + return proof_identity + + def now_utc(): return dt.datetime.now(dt.timezone.utc) @@ -717,9 +773,13 @@ def prepare(env, args, parent_state=None): if dirty: raise RunnerError("repository must be an exact clean committed snapshot; tracked or untracked changes are present") branch = git(repo, "symbolic-ref", "--quiet", "--short", "HEAD", check=False) - if branch.returncode != 0 and args.public_ref is None: + if ( + branch.returncode != 0 + and args.public_ref is None + and not args.private_snapshot_bundle + ): raise RunnerError( - "repository must be on a named committed branch unless an exact public source ref is supplied" + "repository must be on a named committed branch unless an exact public ref or private snapshot is supplied" ) commit = git(repo, "rev-parse", "HEAD").stdout.strip() remote = git(repo, "remote", "get-url", "origin").stdout.strip() @@ -728,24 +788,37 @@ def prepare(env, args, parent_state=None): private_snapshot_arg = Path(args.private_snapshot_bundle) private_snapshot_source = private_snapshot_arg.resolve() if private_snapshot_arg.is_symlink() or not private_snapshot_source.is_file(): - raise RunnerError("private parent snapshot must be a regular non-link Git bundle") + raise RunnerError("private snapshot must be a regular non-link Git bundle") if private_snapshot_source.stat().st_size > MAX_STAGING_INPUT_BYTES: - raise RunnerError("private parent snapshot exceeds the one-GiB staging bound") - if not args.capacity_parent or not args.source_ref: - raise RunnerError("private parent snapshot requires exact capacity parent and source ref") + raise RunnerError("private snapshot exceeds the one-GiB staging bound") + if not args.source_ref: + raise RunnerError("private snapshot requires one exact source ref") heads = git(repo, "bundle", "list-heads", str(private_snapshot_source)).stdout.splitlines() expected_head = "{} {}".format(commit, args.source_ref) - if heads != [expected_head]: - raise RunnerError("private parent snapshot must contain only the exact source-ref head") + accepted_heads = [expected_head] + if not args.capacity_parent: + accepted_heads.append("{} HEAD".format(commit)) + if heads not in [[value] for value in accepted_heads]: + raise RunnerError("private snapshot must contain only the exact source head") run(["git", "bundle", "verify", str(private_snapshot_source)], cwd=repo) if getattr(args, "public_ref", None) and args.source_ref: raise RunnerError("choose one exact source identity: --source-ref or --public-ref") - public = public_origin_proof( - repo, remote, commit, - source_ref=getattr(args, "public_ref", None) or args.source_ref, - source_ancestors=tuple(getattr(args, "public_ancestor", None) or ()), - private_source=private_snapshot_source is not None, - ) + source_ancestors = tuple(getattr(args, "public_ancestor", None) or ()) + if private_snapshot_source is not None and not args.capacity_parent: + public = private_bundle_origin_proof( + repo, + remote, + commit, + args.source_ref, + source_ancestors=source_ancestors, + ) + else: + public = public_origin_proof( + repo, remote, commit, + source_ref=getattr(args, "public_ref", None) or args.source_ref, + source_ancestors=source_ancestors, + private_source=private_snapshot_source is not None, + ) tree = public["tree"] task = require_identifier("task", args.task) @@ -811,14 +884,21 @@ def prepare(env, args, parent_state=None): if any("\x00" in value for value in command["argv"]): raise RunnerError("command argv contains NUL") command_digest = "sha256:" + sha256_bytes(canonical_bytes(command)) - lock_path, wheel_manifest = locked_python_manifest(repo) - locked_python = { - "lock_digest": "sha256:" + sha256_file(lock_path), - "wheels": [ - {key: item[key] for key in ("name", "version", "file", "url", "digest", "bytes")} - for item in wheel_manifest - ], - } + lock_path = repo / "tools" / "agent-fleet" / "uv.lock" + if resource_class == "crosscheck-tool" and not lock_path.is_file(): + locked_python = {"lock_digest": None, "wheels": []} + else: + lock_path, wheel_manifest = locked_python_manifest(repo) + locked_python = { + "lock_digest": "sha256:" + sha256_file(lock_path), + "wheels": [ + { + key: item[key] + for key in ("name", "version", "file", "url", "digest", "bytes") + } + for item in wheel_manifest + ], + } prepared_at = now_utc() expires_at = prepared_at + dt.timedelta(hours=TTL_SCHEDULE_HOURS_AFTER_PREPARATION) request = { @@ -848,7 +928,13 @@ def prepare(env, args, parent_state=None): "fence": fence, "repository": { "source_mode": ( - "private-parent-bundle" if private_snapshot_path else "public-github-https" + ( + "private-parent-bundle" + if args.capacity_parent + else "private-exact-bundle" + ) + if private_snapshot_path + else "public-github-https" ), "remote": remote, "default_ref": public["default_ref"], @@ -955,25 +1041,37 @@ def prepare(env, args, parent_state=None): def reprove_public_request(state): repository = state["request"]["repository"] repo = Path(state["repository_root"]).resolve() - private_source = repository.get("source_mode") == "private-parent-bundle" - proof = public_origin_proof( - repo, repository["remote"], repository["commit"], - expected={ - "remote": repository["remote"], - "default_ref": repository["default_ref"], - "default_head": repository["default_head"], - "source_ref": repository["source_ref"], - "source_head": repository["source_head"], - "source_ancestors": repository.get("source_ancestors", []), - }, - source_ref=( - repository["source_ref"] - if repository["source_ref"] != repository["default_ref"] - else None - ), - source_ancestors=repository.get("source_ancestors", []), - private_source=private_source, - ) + source_mode = repository.get("source_mode") + private_source = source_mode in ("private-parent-bundle", "private-exact-bundle") + expected = { + "remote": repository["remote"], + "default_ref": repository["default_ref"], + "default_head": repository["default_head"], + "source_ref": repository["source_ref"], + "source_head": repository["source_head"], + "source_ancestors": repository.get("source_ancestors", []), + } + if source_mode == "private-exact-bundle": + proof = private_bundle_origin_proof( + repo, + repository["remote"], + repository["commit"], + repository["source_ref"], + source_ancestors=repository.get("source_ancestors", []), + expected=expected, + ) + else: + proof = public_origin_proof( + repo, repository["remote"], repository["commit"], + expected=expected, + source_ref=( + repository["source_ref"] + if repository["source_ref"] != repository["default_ref"] + else None + ), + source_ancestors=repository.get("source_ancestors", []), + private_source=private_source, + ) if private_source: snapshot_path = Path(state["input_path"]).parent / "snapshot.bundle" if ( @@ -981,7 +1079,7 @@ def reprove_public_request(state): or "sha256:" + sha256_file(snapshot_path) != repository["snapshot_digest"] or snapshot_path.stat().st_size != repository["snapshot_bytes"] ): - raise RunnerError("private parent snapshot changed after request preparation") + raise RunnerError("private snapshot changed after request preparation") if proof["tree"] != repository["tree"]: raise RunnerError("public request tree changed after preparation") @@ -3031,7 +3129,10 @@ def retry(env, old_state, args): args.capacity_reservation_vcpus = old_state["request"].get("capacity_reservation_vcpus") args.capacity_fence = old_state["request"].get("capacity_fence") repository = old_state["request"]["repository"] - private_source = repository.get("source_mode") == "private-parent-bundle" + private_source = repository.get("source_mode") in ( + "private-parent-bundle", + "private-exact-bundle", + ) selected_source_ref = ( repository["source_ref"] if repository["source_ref"] != repository["default_ref"] @@ -3113,7 +3214,7 @@ def add_request_arguments(parser, require_command=True): parser.add_argument("--wall-seconds", type=int) parser.add_argument( "--source-ref", - help="exact refs/heads/* identity for a public remote head or private parent snapshot", + help="exact branch or PR-head identity for a public remote or private snapshot", ) parser.add_argument( "--private-snapshot-bundle", diff --git a/bin/fm-crosscheck-azure-tool-bridge.py b/bin/fm-crosscheck-azure-tool-bridge.py index 6d681ed23b2..0d826dee72a 100755 --- a/bin/fm-crosscheck-azure-tool-bridge.py +++ b/bin/fm-crosscheck-azure-tool-bridge.py @@ -17,6 +17,7 @@ from pathlib import Path import re import shlex +import tempfile import time from typing import Any @@ -178,33 +179,38 @@ def prepare_exact_snapshot( if observed_remote.returncode != 0: runner.git(root, "remote", "add", "origin", request["remote"]) elif observed_remote.stdout.strip() != request["remote"]: - raise BridgeError("review checkout origin differs from the bound public remote") + raise BridgeError("review checkout origin differs from the bound GitHub remote") parser = runner.parser() task = ("cc-" + request["review_generation"][:12] + "-" + task_suffix)[:64] - arguments = parser.parse_args( - [ - "prepare", - "--repo", - str(root), - "--task", - task, - "--generation", - request["review_generation"][:63], - "--public-ref", - request["source_ref"], - "--public-ancestor", - request["base_sha"], - "--resource-class", - "crosscheck-tool", - "--wall-seconds", - str(wall_seconds), - "--", - *command, - ] - ) - runner.normalize_command(arguments) - env = runner.environment() - state = runner.prepare(env, arguments) + with tempfile.TemporaryDirectory(prefix="fm-crosscheck-bundle-") as temporary: + bundle = Path(temporary) / "snapshot.bundle" + runner.git(root, "bundle", "create", str(bundle), "HEAD") + arguments = parser.parse_args( + [ + "prepare", + "--repo", + str(root), + "--task", + task, + "--generation", + request["review_generation"][:63], + "--source-ref", + request["source_ref"], + "--private-snapshot-bundle", + str(bundle), + "--public-ancestor", + request["base_sha"], + "--resource-class", + "crosscheck-tool", + "--wall-seconds", + str(wall_seconds), + "--", + *command, + ] + ) + runner.normalize_command(arguments) + env = runner.environment() + state = runner.prepare(env, arguments) repository = state["request"]["repository"] for field, expected in ( ("remote", request["remote"]), diff --git a/docs/azure-crosscheck.md b/docs/azure-crosscheck.md index d08c0b95e0a..75f7cae53f7 100644 --- a/docs/azure-crosscheck.md +++ b/docs/azure-crosscheck.md @@ -20,7 +20,7 @@ Remote Herdr is not required. One review uses at least three fresh compartments with different immutable resource, VM, and boot identities: one model compartment plus one tool/verifier pair for every accepted evidence item. - The credentialed model compartment receives exactly one independently selected reviewer account plus a bounded static packet containing the claims, ledger projection, and complete exact-base/exact-head diff. -- A fresh private-controller `crosscheck-tool` runner fetches the exact advertised remote PR-head ref and executes one accepted reproduction with no provider credential or repository network. +- A fresh private-controller `crosscheck-tool` runner receives a digest-bound bundle of the authenticated exact PR-head checkout and executes one accepted reproduction with no provider credential or repository network. - A second newly created `crosscheck-tool` runner independently replays that accepted helper with no repository network or provider credential. The model compartment never receives a repository checkout, dynamic repository tool, shell against the repository, Azure CLI, MCP server, ambient extension, skill, container client, or local control authority. diff --git a/docs/azure-runner.md b/docs/azure-runner.md index bd2cb4fed90..6a1a18c06c7 100644 --- a/docs/azure-runner.md +++ b/docs/azure-runner.md @@ -36,22 +36,24 @@ The first live invocation remains blocked until the foundation and this code are ## Request and snapshot contract -`prepare` refuses tracked changes, untracked files, and any origin other than a credential-free public GitHub HTTPS URL. -It also refuses a detached HEAD unless the caller supplies an exact public source ref whose fetched head equals that detached commit. +`prepare` refuses tracked changes, untracked files, and any origin identity other than a credential-free GitHub HTTPS URL. +It also refuses a detached HEAD unless the caller supplies an exact public source ref or a digest-bound private bundle for that detached commit. By default the candidate must be reachable from a freshly advertised and fetched `refs/heads/main` default head. The explicit `--source-ref refs/heads/` seam alone requires the candidate commit to be the exact freshly advertised and fetched head of that public branch; it never accepts an ancestor, stale tracking ref, tag, or changed remote head. An explicit `--public-ref` may instead name only an advertised branch head or `refs/pull//head`, and the candidate must equal that ref's exact fetched head; mutable pull merge refs and unsafe ref shapes are refused, and the two source-ref seams are mutually exclusive. -A caller may additionally bind one or more exact `--public-ancestor` commits; each must be present in the freshly fetched public history and be an ancestor of the candidate, and trusted guest bootstrap fetches and verifies each object before repository networking closes. +A caller may additionally bind one or more exact `--public-ancestor` commits; public mode requires each commit in the freshly fetched public history, while private mode requires it in the bundle, and trusted guest bootstrap verifies each object is an ancestor of the candidate before repository networking closes. +Crosscheck evidence for a private GitHub repository supplies `--private-snapshot-bundle` without a parent reservation: the trusted host packages its clean exact-head review checkout, binds the authenticated PR ref and base ancestor, and stages only that digest-bound Git bundle so the evidence VM receives no GitHub credential. +When that arbitrary repository does not contain Firstmate's Agent Fleet lock, the `crosscheck-tool` class uses only the sealed base toolchain and records an empty Python-wheel closure instead of requiring Firstmate-specific files. An Azure validation cell additionally supplies `--private-snapshot-bundle` with its parent-cell reservation so an unpushed pipeline-fix head can execute without prematurely changing the task branch on GitHub. -That private mode binds one exact source ref/head, a one-ref Git bundle, digest, size, parent cell, and private staging object while still freshly proving the public origin's trusted default ref/head. -The public proof runs in a fresh bare repository with system/global Git configuration, credentials, prompts, extra HTTP headers, and file transport disabled; all modes repeat their exact public/private source proof immediately before compute creation and retry. +Both private modes bind one exact source ref/head, a one-ref Git bundle, digest, size, and private staging object; the validation-cell mode additionally binds its parent cell and freshly proves the public origin's trusted default ref/head. +The public proof runs in a fresh bare repository with system/global Git configuration, credentials, prompts, extra HTTP headers, and file transport disabled; private Crosscheck proof revalidates the clean checkout and bundle identities locally immediately before compute creation and retry. No live worktree, primary home, provider account home, browser profile, or peer storage is mounted or copied. The canonical `fm.azure-command/v1` request binds these fields: - SHA-256 home binding derived from the canonical `FM_HOME` path, without sending that path to Azure. - Task, task generation, deployment generation, invocation, fenced attempt, and optional parent attempt. -- Exact public origin, trusted default ref/head, selected source ref/head, required source ancestors, optional private bundle blob/digest/size, commit, tree, source-identity digest, command argv digest, and complete request digest. +- Exact GitHub origin, optional trusted public default ref/head, selected source ref/head, required source ancestors, optional private bundle blob/digest/size, commit, tree, source-identity digest, command argv digest, and complete request digest. - Resource class, reviewed VM SKU, CPU, memory, PID, disk, per-stream log, artifact, network, and wall-time limits. - Declared repository-relative dependency paths and their file or tree digests. - Declared repository-relative result artifact paths. @@ -59,8 +61,8 @@ The canonical `fm.azure-command/v1` request binds these fields: The bounded request and trusted executor travel only as ordinary Managed Run Command parameters. In public mode trusted root fetches the exact public source ref when it is the candidate head, refuses a ref that moved after admission, and otherwise fetches the exact default-reachable commit. -Private parent mode stages only the exact credential-free Git bundle in the foundation's private `validation-shards` container, where the guest UAMI downloads and verifies it before deleting its token and starting repository code. -Trusted root then fetches checksum-pinned ShellCheck, uv, and locked Linux wheels through the VNet NAT path and verifies every digest before repository code starts. +Private mode stages only the exact credential-free Git bundle in the foundation's private `validation-shards` container, where the guest UAMI downloads and verifies it before deleting its token and starting repository code. +Trusted root then fetches checksum-pinned ShellCheck, uv, and any bound locked Linux wheels through the VNet NAT path and verifies every digest before repository code starts. There is no SAS, shared key, Git credential, control-home payload, provider credential, or command-child data-plane authority. Declared dependency paths are rehashed after the VM clones the bundle. @@ -68,8 +70,8 @@ Package installation performed by a repository command must remain rootless and Missing toolchain capability fails the command rather than triggering a local retry or privileged repository-controlled bootstrap. The fixed root bootstrap installs a hard-coded Ubuntu transport and Linux test-tool package closure before repository code starts when the pinned Canonical image lacks it. Repository code cannot alter that privileged package list, all package and staging traffic is shaped to one megabit per second and ends before deny-all command networking starts, and invocation evidence must record the resolved package/image versions during real acceptance. -The request records the exact-size, checksum-pinned ShellCheck 0.11.0 and uv 0.9.10 releases plus the complete Linux x86_64 pytest/ruff wheel closure selected from the exact snapshot's Agent Fleet `uv.lock`. -Trusted root verifies the lock, archive, file set, sizes, and hashes, creates the Agent Fleet environment with an empty cache and networking disabled, and forces repository `uv run --locked` commands to use that already-synchronized offline environment. +The request records the exact-size, checksum-pinned ShellCheck 0.11.0 and uv 0.9.10 releases plus, when the snapshot contains the Agent Fleet `uv.lock`, the complete Linux x86_64 pytest/ruff wheel closure selected from that lock. +When that lock is present, trusted root verifies the lock, archive, file set, sizes, and hashes, creates the Agent Fleet environment with an empty cache and networking disabled, and forces repository `uv run --locked` commands to use that already-synchronized offline environment. ## Private control and VM boundary @@ -79,7 +81,7 @@ The NIC has no public IP configuration, the subnet inherits the foundation's den There is no SSH key, password, inbound listener, public load balancer, or public NAT rule. The VM has exactly the foundation `validation-shards` UAMI, whose sole direct role is Storage Blob Data Contributor on the exact `validation-shards` container and which has no ARM/control-plane role. -The guest root process fetches and verifies the exact public source and pinned dependency closure before creating the child. +The guest root process fetches and verifies the exact public source or downloads and verifies the exact private bundle, then verifies the pinned dependency closure before creating the child. It remounts `/proc` with `hidepid=2` and starts repository code in a systemd private network namespace restricted to `AF_UNIX` with deny-all IP policy. No managed-identity token exists before or during the child command. The untrusted child receives a fixed allowlisted environment with no ambient host variables. @@ -223,7 +225,7 @@ Cleanup removes resources in this exact scope and order: 3. The exact recorded NIC, after a stable-identity detached transition is recorded. 4. The exact recorded OS disk, after a stable-identity detached transition is recorded. 5. The exact Azure-native TTL schedule, only after exact VM absence and detached capacity cleanup are proven. -6. The exact private input snapshot blob, when parent-cell private mode supplied one. +6. The exact private input snapshot blob, when either private mode supplied one. 7. The local transient request payload, while retaining local verified result/state and the private digest-bound output archive. A VM deletion failure, timeout, unreadable response, or ambiguous absence proof retains the TTL schedule untouched so the independent deallocation deadline remains enforceable while cleanup is reconciled. diff --git a/tests/fm-azure-runner.test.sh b/tests/fm-azure-runner.test.sh index 1efba4a294e..951f79e3ba5 100755 --- a/tests/fm-azure-runner.test.sh +++ b/tests/fm-azure-runner.test.sh @@ -65,6 +65,41 @@ PY pass "normal environment defaults to strict without commissioning evidence or confirmation variables" } +public_git_askpass_is_host_portable() { + python3 - "$HOST" <<'PY' || fail "public Git askpass portability contract failed" +import importlib.util +import pathlib +import sys + +spec = importlib.util.spec_from_file_location("runner", sys.argv[1]) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +observed = {} + +def fake_run(command, **kwargs): + observed["command"] = command + observed["env"] = kwargs["env"] + +m.run = fake_run +m.shutil.which = lambda name, path=None: ( + "/usr/bin/false" if (name, path) == ("false", "/usr/bin:/bin") else None +) +m.public_git(pathlib.Path("/proof"), "ls-remote", "https://github.com/example/repo.git") +assert observed["command"][0] == "git" +assert observed["env"]["GIT_ASKPASS"] == "/usr/bin/false" +assert observed["env"]["SSH_ASKPASS"] == "/usr/bin/false" + +m.shutil.which = lambda _name, path=None: None +try: + m.public_git(pathlib.Path("/proof"), "ls-remote", "https://github.com/example/repo.git") +except m.RunnerError as exc: + assert "executable false command" in str(exc) +else: + raise AssertionError("public Git proof accepted a host with no prompt refuser") +PY + pass "public Git proof resolves a host-portable noninteractive askpass executable" +} + storage_network_access_contract() { python3 - "$HOST" <<'PY' || fail "runner storage network-access contract failed" import importlib.util,inspect,sys @@ -135,7 +170,6 @@ assert input_token_at < guest.index('rm -f "$TOKEN_FILE"',input_token_at) < run_ assert '/usr/bin/python3 "$EXECUTOR"' in guest assert "https://files.pythonhosted.org/packages/*.whl" in guest assert 'repository"].get("source_ancestors", [])' in guest -assert 'git -C /work/repo fetch --depth=1 origin "$ancestor"' in guest assert 'fetch_exact "$url"' in guest and '--location' not in guest[guest.index('while IFS=$\'\\t\' read -r url'):guest.index('done <"$BASE/wheels.tsv"')] assert "protectedParameters" not in host assert "generate-sas" not in host @@ -149,7 +183,6 @@ assert 'command_env["FM_HOME"] = str(ROOT)' not in host assert 'str(Path(command_env["FM_HOME"]) / "state" / "azure-workers")' in host assert "cleanup-verified-at" not in host assert 'binding_keys = ("remote", "source_ref", "source_head", "source_ancestors")' in host -assert host.count('expected.get(key) != proof_identity[key]') == 1 assert '"default_head": default_head,' in host schedule=next(r for r in template["resources"] if r["type"]=="Microsoft.DevTestLab/schedules") assert schedule["name"]=="[format('shutdown-computevm-{0}', parameters('vmName'))]" @@ -282,10 +315,98 @@ assert pathlib.Path(state["input_path"]).parent.joinpath("snapshot.bundle").read bad=repo.parent/"bad.bundle"; m.run(["git","-C",str(repo),"branch","extra","HEAD"]); m.run(["git","-C",str(repo),"bundle","create",str(bad),"refs/heads/topic","refs/heads/extra"]) args.invocation="azr-bbbbbbbbbbbb"; args.private_snapshot_bundle=str(bad) try: m.prepare(env,args) -except m.RunnerError as exc: assert "only the exact source-ref head" in str(exc) +except m.RunnerError as exc: assert "only the exact source head" in str(exc) else: raise AssertionError("multi-ref private snapshot accepted") +# Crosscheck evidence uses the same private transport without a validation +# parent. Its authenticated exact PR checkout is the authority, so preparation +# must not try to reach a private GitHub remote without credentials. +m.public_origin_proof=lambda *_a,**_k: (_ for _ in ()).throw(AssertionError("private Crosscheck bundle attempted public Git proof")) +args.invocation="azr-cccccccccccc" +args.private_snapshot_bundle=str(bundle) +args.capacity_parent=None +args.capacity_reservation_vcpus=None +private=m.prepare(env,args) +private_repo=private["request"]["repository"] +assert private_repo["source_mode"]=="private-exact-bundle" +assert private_repo["source_ref"]=="refs/heads/topic" and private_repo["source_head"]==head +assert private_repo["default_ref"] is None and private_repo["default_head"] is None +m.reprove_public_request(private) +# An arbitrary private repository need not carry Firstmate's Agent Fleet lock. +# The evidence class records an empty closure and keeps every other class on +# the existing locked path. +plain=repo.parent/"plain"; plain.mkdir() +m.run(["git","-C",str(plain),"init","-q","-b","main"]) +m.run(["git","-C",str(plain),"config","user.name","fixture"]) +m.run(["git","-C",str(plain),"config","user.email","fixture@example.invalid"]) +(plain/"value.txt").write_text("value\n") +m.run(["git","-C",str(plain),"add","value.txt"]) +m.run(["git","-C",str(plain),"commit","-qm","fixture"]) +m.run(["git","-C",str(plain),"remote","add","origin","https://github.com/example/private.git"]) +plain_head=m.git(plain,"rev-parse","HEAD").stdout.strip() +plain_bundle=plain.parent/"plain.bundle" +m.run(["git","-C",str(plain),"bundle","create",str(plain_bundle),"HEAD"]) +args.repo=str(plain) +args.source_ref="refs/pull/7/head" +args.public_ancestor=[] +args.resource_class="crosscheck-tool" +args.command=["true"] +args.invocation="azr-dddddddddddd" +args.private_snapshot_bundle=str(plain_bundle) +plain_state=m.prepare(env,args) +assert plain_state["request"]["repository"]["source_head"]==plain_head +assert plain_state["request"]["protocol"]["agent_fleet_python"]=={"lock_digest":None,"wheels":[]} PY - pass "private parent prepare binds one exact source ref/head/tree/bundle/blob without requiring an early push" + pass "private snapshot preparation binds both parent-cell and credentialless exact-checkout bundle modes" +} + +private_snapshot_ancestor_verification() { + local tmp repo clone request ancestor head marker helper + fm_test_tmproot_into tmp fm-azure-private-ancestor + repo="$tmp/repo" + make_repo "$repo" + printf 'child\n' >"$repo/declared/child.txt" + git -C "$repo" add declared/child.txt + git -C "$repo" commit -qm child + ancestor=$(git -C "$repo" rev-parse HEAD^) + head=$(git -C "$repo" rev-parse HEAD) + clone="$tmp/clone" + git clone -q --no-local "$repo" "$clone" + marker="$tmp/network-used" + helper="$tmp/remote-helper" + printf '#!/usr/bin/env bash\nprintf used >"%s"\nexit 91\n' "$marker" >"$helper" + chmod +x "$helper" + git -C "$clone" remote set-url origin "ext::$helper" + request="$tmp/request.json" + python3 - "$request" "$ancestor" "$head" <<'PY' +import json,sys +path,ancestor,head=sys.argv[1:] +with open(path,"w",encoding="utf-8") as handle: + json.dump({"repository":{"source_mode":"private-exact-bundle","commit":head,"source_ancestors":[ancestor]}},handle) +PY + for mode in private-parent-bundle private-exact-bundle; do + python3 - "$request" "$mode" <<'PY' +import json,sys +path,mode=sys.argv[1:] +request=json.load(open(path,encoding="utf-8")) +request["repository"]["source_mode"]=mode +with open(path,"w",encoding="utf-8") as handle: json.dump(request,handle) +PY + python3 "$EXECUTOR" --verify-private-source-ancestors "$request" "$clone" || \ + fail "$mode ancestor verification rejected bundled ancestry" + done + [ ! -e "$marker" ] || fail "private snapshot ancestor verification contacted origin" + python3 - "$request" <<'PY' +import json,subprocess,sys +path=sys.argv[1] +request=json.load(open(path,encoding="utf-8")) +request["repository"]["source_ancestors"]=[subprocess.run(["git","-C",path.rsplit("/",1)[0]+"/repo","rev-parse","HEAD"],check=True,text=True,stdout=subprocess.PIPE).stdout.strip()] +request["repository"]["commit"]=subprocess.run(["git","-C",path.rsplit("/",1)[0]+"/repo","rev-parse","HEAD^"],check=True,text=True,stdout=subprocess.PIPE).stdout.strip() +with open(path,"w",encoding="utf-8") as handle: json.dump(request,handle) +PY + if python3 "$EXECUTOR" --verify-private-source-ancestors "$request" "$clone" >/dev/null 2>&1; then + fail "private snapshot ancestor verification accepted a descendant" + fi + pass "private bundle modes verify ancestors locally without contacting origin" } executor_credential_adversary() { @@ -1163,9 +1284,11 @@ PY static_private_controller_contract environment_mode_defaults +public_git_askpass_is_host_portable storage_network_access_contract prepare_contract private_snapshot_prepare_contract +private_snapshot_ancestor_verification executor_credential_adversary linux_systemd_drop_integration spend_ledger_unit diff --git a/tests/fm-crosscheck-azure.test.sh b/tests/fm-crosscheck-azure.test.sh index d55b62d7afe..f86e38a5c23 100755 --- a/tests/fm-crosscheck-azure.test.sh +++ b/tests/fm-crosscheck-azure.test.sh @@ -80,7 +80,6 @@ for marker in ( "tool_identity", "verifier_identity", "vm_instance_id", - "--public-ref", ): assert marker in bridge_source guest_source = guest.read_text(encoding="utf-8") @@ -1837,6 +1836,81 @@ PY pass "host bridge rejects hostile evidence and requires distinct cleaned exact-head tool/verifier attempts" } +bridge_private_snapshot_unit() { + python3 - "$BRIDGE" <<'PY' || fail "Azure bridge private snapshot contract failed" +import importlib.util +from pathlib import Path +import subprocess +import sys +import tempfile + +spec = importlib.util.spec_from_file_location("bridge", sys.argv[1]) +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) +runner = bridge.load_runner() +with tempfile.TemporaryDirectory() as temporary: + repo = Path(temporary) / "repo" + repo.mkdir() + subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "fixture"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "fixture@example.invalid"], check=True) + (repo / "value.txt").write_text("value\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "value.txt"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "fixture"], check=True) + subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", "https://github.com/example/private.git"], check=True) + subprocess.run(["git", "-C", str(repo), "checkout", "--detach", "-q"], check=True) + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout.strip() + observed = {} + runner.environment = lambda: {"fixture": True} + + def prepare(env, arguments): + bundle = Path(arguments.private_snapshot_bundle) + observed["bundle"] = bundle + assert env == {"fixture": True} + assert arguments.public_ref is None + assert arguments.source_ref == "refs/pull/7/head" + assert arguments.capacity_parent is None + assert runner.git(repo, "bundle", "list-heads", str(bundle)).stdout.splitlines() == [head + " HEAD"] + return { + "request": { + "repository": { + "remote": "https://github.com/example/private.git", + "source_ref": "refs/pull/7/head", + "source_head": head, + "source_ancestors": [head], + "commit": head, + } + } + } + + runner.prepare = prepare + state, arguments, env = bridge.prepare_exact_snapshot( + runner, + { + "repository_root": str(repo), + "remote": "https://github.com/example/private.git", + "source_ref": "refs/pull/7/head", + "head_sha": head, + "base_sha": head, + "review_generation": "a" * 24, + }, + "tool-1", + ["true"], + 300, + ) + assert state["request"]["repository"]["source_head"] == head + assert arguments.private_snapshot_bundle + assert env == {"fixture": True} + assert not observed["bundle"].exists() +PY + pass "Azure evidence bridge privately bundles an exact detached PR checkout without GitHub credentials" +} + replay_positive_and_failure_unit() { local tmp mutation_tmp evidence patch_evidence head fm_test_tmproot_into tmp fm-crosscheck-azure-replay @@ -3046,6 +3120,7 @@ model_guest_executing_account_unit identity_outcome_unit account_and_cleanup_identity_unit bridge_security_unit +bridge_private_snapshot_unit manifest_bounds_unit template_expiry_render_unit replay_positive_and_failure_unit