Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,28 @@ Both the promoter and independent observer download the exact two raw provider
review envelopes and require the immutable parent Kernel to reproduce the
candidate's `ALLOW` or `DENY` permit byte for byte; a candidate-authored summary
cannot stand in for reduction evidence.
Successor ratification structurally parses the candidate workflow with
Ruby/Psych into an AST-preserving projection before it evaluates authority
semantics. That preserves YAML scalar spellings (including the `on` trigger
key) and rejects duplicate keys, aliases, merge keys, and custom tags. The
previous immutable workflow is the complete parent-owned allowlist: candidate
top-level keys, triggers, permissions, absence of defaults/concurrency,
known-job graph, job execution fields, ordered steps, action SHAs, inputs,
environment, scripts, and artifact paths must all match it exactly. The only
permitted successor differences are the three declared Kernel checkout refs
and the matching `prepare` `KERNEL_SHA`. The approval chain is also modeled
explicitly: its immutable broker checkout and verifier build remain ordered,
the approval-only App action SHA and step ID are exact, and that App token has
one consumer only—the exact-head approval step. Comments, repeated text, or
an unreviewed execution surface cannot constitute authority evidence.
The parent verifier also inventories the complete `.github/workflows` Git tree:
the candidate must retain the same paths and regular-file modes as its
immutable parent. Only `ci.yml` is semantically reviewed under the four
declared Kernel substitutions; every other workflow blob is byte-identical.
Added, deleted, nested, symlinked, gitlink, mode-changed, or altered workflows
fail closed. The authority repository's source-owned gate profile repeats that
inventory before it runs a candidate command, so an extra workflow cannot hide
outside the `ci.yml` semantic exception.
Credentialed jobs also bind the pinned token action's live App slug and
installation ID before use; the approval broker independently checks exact
repository scope and the persisted GitHub review actor.
Expand Down
2 changes: 1 addition & 1 deletion config/autonomous-release-authority.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"adversarial_corpus_sha256": "a5572c4fcd50bc331043d90e6e97f7cd383baeb278c9d653b173019eeee41d5f",
"gate_profiles_sha256": "56ddccd1f312599a828ca53cb0d78189bba2b75f2346722ef904ccc4314d1720",
"gate_profiles_sha256": "2de164ead3ea1b2680468a781810b6bee64688d9c2ca17399ae6f780d5937bed",
"generation": 2,
"kernel_sha": "83cc3eeb1cf512bed44b560254b11a342cee5b15",
"parent": {
Expand Down
30 changes: 25 additions & 5 deletions config/autonomous-release-gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
["make", "test"]
],
"protected_files": {
".github/workflows/ci.yml": "a4e14a559ed51489d4afa39cceb24fee4a4ad2adf8ceebb3ee3255c9d66717e9",
".github/workflows/promote-authority.yml": "097460ffa3f2673f7f2d67a45a57bbb963acb231da3131a929a48d84aa6c6c39",
"Makefile": "4559934af490cb54f61405c8471fca1e9d287ea5c719ff90192d33c1ec180925",
"config/autonomous-release-bootstrap-v1.json": "d5b2012cadd4fbf8e24bfe4f8ed69c620cf9d6481dc9f488852aa855fe1712b6",
"config/autonomous-release-control-plane.json": "62332956bdad7554e4b093c8abb95cf984a300c55f10381dee906f1466818992",
Expand All @@ -17,9 +15,9 @@
"scripts/bootstrap_authority.py": "8ab8500dd56e6bc295e260fec5d30915b79de39e8d9c67bf3837d8b162d66cf9",
"scripts/configure_machine_approval_gates.py": "ba5109198c69e2d2a0ce56bef18e43c0a201e2eba0f79c4ef5c7a4a759e4e923",
"scripts/observe_authority_promotion.py": "04a4767e4a641fdcca809e6a185a502dcb53cdd6c47519df979a0f08fdcf773a",
"scripts/run_autonomous_release_gates.py": "e9ab0ed2d91923e93df3e7c148f2ea15381cff9ceb6f4360d3b1fd258ae26f31",
"scripts/run_autonomous_release_gates.py": "80a21f602748b39310f74c56e1ca6ccb2466dfab91d7de4ffd8120ad56b40839",
"scripts/submit_machine_approval.py": "1a2743342026928c6a346df88ad0864b034155ce9320e6e45a598146398c986f",
"scripts/verify_authority_promotion.py": "4d5f079ffc21ee350799eb93374a7ce95e0926b151a10b2015105642c7622f5b",
"scripts/verify_authority_promotion.py": "3afa53d363fbab363c19e481954895bf5270e16b97c5df1f0919e012ddfce8b2",
"scripts/verify_control_plane.py": "78ad97bffd7f065737c6839ea66475a98ca4583dfbedb60673648f86ef893416",
"scripts/wait_for_authority_canary.py": "6a6524639590e5dac432f0bc25a135712310a4fa1a87bc2f3aff19cc3f31662f",
"scripts/wait_for_authority_suite.py": "b97933597c2b5d9f132a488a3a63a6b3ccec299bbe80ad3d2e085ad549343613",
Expand All @@ -31,12 +29,34 @@
"tests/test_observe_authority_promotion.py": "3ea6c9824b7c3d20738b2c31d198d618a59a985c688dfa39694713756fb5aadd",
"tests/test_configure_machine_approval_gates.py": "645d2acdb754fe77da9e10920e0bc9cf65a421e742c426f540e3c2f5b2a2cf6d",
"tests/test_submit_machine_approval.py": "0a2887be1d996f83d66e6b2ce29da4915d21c16d2badb614e0bc016d74e8d38d",
"tests/test_verify_authority_promotion.py": "9c5e4e8732984faa2375738df908273614f40dcb49d127a0f96a8be3acf503af",
"tests/test_run_autonomous_release_gates.py": "2d031593af88385e0813a0b5e0cb73114131791065fecf59379b6c5fb68f4fdf",
"tests/test_verify_authority_promotion.py": "2e80ff9bf49abeabfc422fe6df22586c5f81a1e6696cd9113fdb3c2db0bb0291",
"tests/test_verify_control_plane.py": "36620d9e7486582c9786d2c05479491e731243298338b8fe0e8302c31979ac88",
"tests/test_wait_for_authority_canary.py": "6ec843374d9e2adb7063e15baa41b7a9bad2a49ec9c0f6a760ad17f39f449ce2",
"tests/test_wait_for_authority_suite.py": "28297e2d191f21a85d46d5abe4c6403663a942e560299e8c0d549576176369b1",
"tools/offline-attest/attest.mjs": "21c544e689f472039cbd5fd621fab34b801bdd40b92fb68745948253ce621144",
"tools/offline-attest/package-lock.json": "a4b898bac65ba7ccbf1b6871d5ec5c6fdcf83d8d38fa976b52ad11bad29ac135"
},
"workflow_inventory": {
"root": ".github/workflows",
"files": {
".github/workflows/ci.yml": {
"mode": "100644",
"semantic": true
},
".github/workflows/docs-truth-public.yml": {
"mode": "100644",
"sha256": "5422a1d0f49f9edf711e5c96ca7c98ebb423cbeb71ef9e57c8a935744b547ea3"
},
".github/workflows/docs-truth.yml": {
"mode": "100644",
"sha256": "2cc9bb7b1271b554664ce2f9e8a426b98528b7022187eb6feed9714230a0be2d"
},
".github/workflows/promote-authority.yml": {
"mode": "100644",
"sha256": "097460ffa3f2673f7f2d67a45a57bbb963acb231da3131a929a48d84aa6c6c39"
}
}
}
},
"Mindburn-Labs/app-helm-console": {
Expand Down
31 changes: 30 additions & 1 deletion docs/adr/0002-autonomous-release-permit.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ no command-discovery or Makefile fallback. The profile names an exact command
vector and SHA-256 digests for every build file that can alter those commands;
missing, changed, non-regular, or symlinked protected files fail before either
model runs. Authority-critical profile changes use the same two-generation
ratification rule as workflow changes.
ratification rule as workflow changes. The authority repository additionally
uses a closed `.github/workflows` inventory before any candidate command: each
allowed path has an exact regular-file mode, `ci.yml` is the sole semantic
entry, and every other workflow has a source-owned SHA-256 digest. An added,
deleted, nested, non-regular, mode-changed, or digest-drifting workflow fails
before a candidate gate command or model review.

Every context also binds `config/autonomous-release-authority.json`. The
manifest names a monotonically increasing generation, the exact Kernel SHA,
Expand All @@ -79,6 +84,30 @@ the immediately preceding workflow generation. Generation 1 is the explicit
bootstrap; every later generation must name exactly generation N-1 and cannot
name its own workflow SHA as its parent.

The parent-generation promotion verifier structurally parses the candidate
workflow with Ruby/Psych into an AST-preserving projection before evaluating
it. It rejects malformed YAML, duplicate mapping keys, aliases, merge keys,
and custom tags while preserving scalar spellings, including the `on` trigger
key that YAML 1.1 loaders can otherwise coerce. The previous immutable
workflow is the complete parent-owned allowlist: the candidate must match its
exact top-level keys and triggers, top-level permissions and absence of
defaults/concurrency, all known job IDs and dependencies, every job execution
field, and every ordered step, action SHA, input, environment value, shell
script, and artifact path. The only permitted successor substitutions are the
three declared Kernel checkout refs and the matching `prepare` `KERNEL_SHA`.
It also reads the complete workflow directory directly from the immutable
parent and candidate Git trees. Their path and mode inventories must be
identical; only the regular `ci.yml` blob proceeds to the semantic check, while
every other workflow blob must be byte-identical to the parent. Thus an added,
deleted, nested, symlinked, gitlink, mode-changed, or otherwise altered
workflow cannot create an authority surface outside the `ci.yml` exception.
The machine-approval chain is part of that closed-world profile: the immutable
approval-broker checkout and source-owned verifier build remain ordered, the
approval-only App action SHA and step ID remain exact, and its minted token has
exactly one consumer, the exact-head approval step. Comments, repeated
substrings, alternate steps, or any new execution surface do not constitute
authority evidence.

The existing `local-validation` check name and push-to-`main` validation remain
intact while the permit is evaluated. The permit records GitHub's actual
`workflow_ref` and immutable `workflow_sha`; it must not assert a task-branch
Expand Down
16 changes: 16 additions & 0 deletions docs/runbooks/autonomous-release-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ The local credential may execute those inputs but cannot make a DENY acceptable.
providers.
- `release-permit-verify` was built from the exact Kernel SHA named by the
generation-1 authority manifest.
- The candidate `.github/workflows/ci.yml` passes the parent verifier's
Ruby/Psych closed-world check. Its AST-preserving parse rejects duplicate
keys, aliases, merge keys, and custom tags without coercing the `on` trigger
key. It must match the previous immutable workflow's complete execution
profile: top-level triggers, permissions, and no defaults/concurrency; all
known jobs and dependencies; every execution field; and every ordered step,
action SHA, input, environment value, shell script, and artifact path. Only
the three declared Kernel checkout refs and matching `prepare` `KERNEL_SHA`
may change. The ordered approval-broker checkout/build and sole
approval-step use of the isolated App token are included in that profile.
The verifier also compares the full parent and candidate
`.github/workflows` Git-tree inventories: paths and regular modes are exact,
`ci.yml` is the only semantic exception, and every other workflow blob must
be byte-identical. Added, deleted, nested, symlinked, gitlink, mode-changed,
or altered workflows fail. Comments and repeated text do not satisfy this
check.
- The permit, Sigstore bundle, and trusted context were downloaded from the
same GitHub Actions run.
- The output directory does not exist. Evidence directories are immutable.
Expand Down
133 changes: 128 additions & 5 deletions scripts/run_autonomous_release_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
from typing import Any
Expand Down Expand Up @@ -67,6 +68,55 @@ def validate_protected_path(value: Any, *, label: str) -> str:
return value


def validate_workflow_inventory(value: Any, *, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise GateProfileError(f"{label} must be an object")
require_exact_keys(value, {"root", "files"}, label)
root = validate_protected_path(value["root"], label=f"{label} root")
if root != ".github/workflows":
raise GateProfileError(f"{label} root must be .github/workflows")
files = value["files"]
if not isinstance(files, dict) or not files or len(files) > 32:
raise GateProfileError(f"{label} must declare 1-32 workflow files")

validated_files: dict[str, dict[str, Any]] = {}
semantic_paths: list[str] = []
prefix = f"{root}/"
for path, entry in files.items():
validated_path = validate_protected_path(path, label=f"{label} workflow path")
if not validated_path.startswith(prefix):
raise GateProfileError(f"{label} workflow path escapes its root: {validated_path}")
if not isinstance(entry, dict):
raise GateProfileError(f"{label} entry for {validated_path} must be an object")
mode = entry.get("mode")
if mode != "100644":
raise GateProfileError(
f"{label} entry for {validated_path} must declare regular mode 100644",
)
entry_keys = set(entry)
if entry_keys == {"mode", "semantic"}:
if entry["semantic"] is not True:
raise GateProfileError(
f"{label} semantic entry for {validated_path} must be true",
)
validated_files[validated_path] = {"mode": mode, "semantic": True}
semantic_paths.append(validated_path)
elif entry_keys == {"mode", "sha256"}:
digest = entry["sha256"]
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest):
raise GateProfileError(
f"{label} digest for {validated_path!r} is invalid",
)
validated_files[validated_path] = {"mode": mode, "sha256": digest}
else:
raise GateProfileError(
f"{label} entry for {validated_path} must be semantic or digest-locked",
)
if semantic_paths != [f"{root}/ci.yml"]:
raise GateProfileError(f"{label} may make only {root}/ci.yml semantic")
return {"root": root, "files": validated_files}


def load_profiles(path: Path) -> dict[str, dict[str, Any]]:
document = parse_json_strict(path)
if not isinstance(document, dict):
Expand All @@ -83,11 +133,15 @@ def load_profiles(path: Path) -> dict[str, dict[str, Any]]:
raise GateProfileError(f"invalid profile repository: {repository!r}")
if not isinstance(profile, dict):
raise GateProfileError(f"profile {repository} must be an object")
require_exact_keys(
profile,
{"commands", "protected_files"},
f"profile {repository}",
)
profile_keys = set(profile)
required_keys = {"commands", "protected_files"}
permitted_keys = required_keys | {"workflow_inventory"}
if profile_keys != required_keys and profile_keys != permitted_keys:
raise GateProfileError(
f"profile {repository} keys invalid; "
f"missing={sorted(required_keys - profile_keys)}, "
f"unexpected={sorted(profile_keys - permitted_keys)}",
)
commands = profile["commands"]
if not isinstance(commands, list) or not commands or len(commands) > 32:
raise GateProfileError(f"profile {repository} must contain 1-32 commands")
Expand Down Expand Up @@ -130,9 +184,16 @@ def load_profiles(path: Path) -> dict[str, dict[str, Any]]:
f"profile {repository} protected digest for {validated_path!r} is invalid",
)
validated_protected_files[validated_path] = digest
workflow_inventory = None
if "workflow_inventory" in profile:
workflow_inventory = validate_workflow_inventory(
profile["workflow_inventory"],
label=f"profile {repository} workflow inventory",
)
profiles[repository] = {
"commands": validated_commands,
"protected_files": validated_protected_files,
"workflow_inventory": workflow_inventory,
}
return profiles

Expand All @@ -152,6 +213,66 @@ def verify_protected_files(target: Path, protected_files: dict[str, str]) -> Non
)


def verify_workflow_inventory(target: Path, inventory: dict[str, Any]) -> None:
root = inventory["root"]
workflow_root = target.joinpath(*root.split("/"))
try:
root_status = os.lstat(workflow_root)
except OSError as exc:
raise GateProfileError(f"workflow inventory root is unavailable: {root}") from exc
if not stat.S_ISDIR(root_status.st_mode):
raise GateProfileError(f"workflow inventory root is missing or not a directory: {root}")

observed: dict[str, tuple[int, str]] = {}
for directory, directories, filenames in os.walk(workflow_root, followlinks=False):
current = Path(directory)
for name in directories:
candidate = current / name
if stat.S_ISLNK(os.lstat(candidate).st_mode):
raise GateProfileError(
"workflow inventory contains a symlinked directory: "
f"{candidate.relative_to(target).as_posix()}",
)
for name in filenames:
candidate = current / name
relative_path = candidate.relative_to(target).as_posix()
try:
candidate_status = os.lstat(candidate)
except OSError as exc:
raise GateProfileError(
f"workflow inventory file is unavailable: {relative_path}",
) from exc
if not stat.S_ISREG(candidate_status.st_mode):
raise GateProfileError(
f"workflow inventory file is not regular: {relative_path}",
)
observed[relative_path] = (
candidate_status.st_mode,
hashlib.sha256(candidate.read_bytes()).hexdigest(),
)

expected_files = inventory["files"]
expected_paths = set(expected_files)
observed_paths = set(observed)
if observed_paths != expected_paths:
raise GateProfileError(
"workflow inventory differs from its source-owned profile; "
f"missing={sorted(expected_paths - observed_paths)}, "
f"unexpected={sorted(observed_paths - expected_paths)}",
)
for path, expected in expected_files.items():
observed_mode, observed_digest = observed[path]
if f"{observed_mode:06o}" != expected["mode"]:
raise GateProfileError(
f"workflow inventory file mode changed before its source-owned profile: {path}",
)
expected_digest = expected.get("sha256")
if expected_digest is not None and observed_digest != expected_digest:
raise GateProfileError(
f"workflow inventory file changed before its source-owned profile: {path}",
)


def resolve_commands(
repository: str,
target: Path,
Expand All @@ -164,6 +285,8 @@ def resolve_commands(
"repository has no immutable source-owned gate profile; Makefile fallback is forbidden",
)
profile = profiles[repository]
if profile["workflow_inventory"] is not None:
verify_workflow_inventory(target, profile["workflow_inventory"])
verify_protected_files(target, profile["protected_files"])
return "explicit", profile["commands"]

Expand Down
Loading