From f784f017b0822f34c785f0b34c7c55fcf39c9d99 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 21:30:47 +0200 Subject: [PATCH] Publish canon-derived build-mode guardrail source and evaluator Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- Makefile | 2 +- .../validation.json | 28 ++++ docs/publication-contract.md | 36 ++++++ publication.json | 9 ++ source-inventory.json | 6 + tests/test_guardrails.py | 120 ++++++++++++++++++ tools/build_site.py | 19 +++ tools/guardrails.py | 98 ++++++++++++++ tools/render.py | 6 + tools/verify_release.py | 6 + ...WP-0006-build-mode-change-authorization.md | 33 ++++- 11 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/2026-10-11-build-mode-guardrail/validation.json create mode 100644 tests/test_guardrails.py create mode 100644 tools/guardrails.py diff --git a/Makefile b/Makefile index e9c87ff..335e196 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ build: check: python3 -m unittest discover -s tests -p 'test_*.py' - python3 -m py_compile tools/render.py tools/build_site.py tools/check_currency.py tools/verify_release.py tools/source_inventory.py tools/fetch_sources.py tools/adr_review_ledger.py + python3 -m py_compile tools/render.py tools/build_site.py tools/check_currency.py tools/verify_release.py tools/source_inventory.py tools/fetch_sources.py tools/adr_review_ledger.py tools/guardrails.py $(MAKE) source-audit git diff --check diff --git a/docs/evidence/2026-10-11-build-mode-guardrail/validation.json b/docs/evidence/2026-10-11-build-mode-guardrail/validation.json new file mode 100644 index 0000000..a2d9f0f --- /dev/null +++ b/docs/evidence/2026-10-11-build-mode-guardrail/validation.json @@ -0,0 +1,28 @@ +{ + "schema_version": "policy-nexus-guardrail-validation/v1", + "validated_at": "2026-10-11T19:08:06.581967+00:00", + "workplan": "PNEX-WP-0006", + "native_publication": false, + "task_closures": [], + "tests": { + "command": "python3 -m unittest discover -s tests -p 'test_*.py'", + "passed": 60, + "failed": 0 + }, + "compile": "passed", + "diff_whitespace": "passed", + "local_build": { + "document_count": 74, + "artifact_equals_canon_block": true, + "expiry_visible_in_header": true, + "source_sha256": "7135ad8e36041f6dc9633c029109b4751be275f912cec44fc462c9f55befaab8", + "artifact_sha256": "a86462a57bb1df5804d3088881af22569082d43ff68bcab2b67cbe56b6fc46cd" + }, + "source_audit": { + "status": "failed_closed", + "unreviewed_source": "net-kingdom/canon/standards/security-layer-model_v0.9.md", + "unrelated_inventory_refreshed": false + }, + "publication_lane": "red", + "pending_daily_batch": "PNEX-WP-0006: public page and canon-derived JSON artifact" +} diff --git a/docs/publication-contract.md b/docs/publication-contract.md index a650213..4489b98 100644 --- a/docs/publication-contract.md +++ b/docs/publication-contract.md @@ -139,3 +139,39 @@ matching source; publication stays one explicit entry per document. | `net-kingdom-architecture` | `net-kingdom` | `net-kingdom` | `docs/architecture/net-kingdom_v0.1.md` | | `state-hub-architecture` | `state-hub` | `state-hub` | `docs/architecture/state-hub_v0.1.md` | | `policy-nexus-architecture` | `policy-nexus` | `policy-nexus` | `docs/architecture/policy-nexus_v0.1.md` | + +## Machine-readable build-mode authorization + +The build-mode standard's publication entry declares +`guardrail_path: guardrails/build-mode-change-authorization.json`. The builder +extracts its single machine-checkable YAML block from the canonical Markdown +and emits JSON at that stable path. No second policy copy is maintained. The +release manifest binds the artifact digest to the document's source provenance; +release verification rejects missing, changed or unsupported rule artifacts. +`expires_on` is shown in the document header. + +A consumer can evaluate a change record with the stdlib-only checker: + +```sh +python3 tools/guardrails.py build/guardrails/build-mode-change-authorization.json change.json --build-mode +``` + +```json +{ + "categories": [], + "diff_ref": "reviewed.patch", + "rollback": "Restore the previous deployment image and source revision", + "abort_owner": "owning-repo", + "receipt_path": "execution-receipt.json" +} +``` + +Category declarations are mandatory, even when empty. The checker returns +`blue` only with all four nonempty preparation references and confirmed build +mode. Missing references, unknown categories or unconfirmed/expired build mode +return `incomplete`. Red overrides orange when both are declared. Only `blue` +returns exit code zero. Classification does not verify the referenced review or +grant credentials, autopilot, public publication approval or runtime authority. +Public publication goes in the founder's daily red-lane batch; orange identity +acts go in the attended-window checklist. Source generation and local checks can +proceed before publication approval. diff --git a/publication.json b/publication.json index 46c2faf..8872626 100644 --- a/publication.json +++ b/publication.json @@ -666,6 +666,15 @@ "canonical_path": "methods/risk-dependencies/v1/index.html", "revision_path": "methods/risk-dependencies/v1/revisions/{revision}/index.html", "review_interval": "6m" + }, + { + "id": "canon-build-mode-change-authorization", + "source_repo": "the-custodian", + "source_path": "canon/standards/build-mode-change-authorization_v0.1.md", + "canonical_path": "standards/build-mode-change-authorization/v0.1/index.html", + "revision_path": "standards/build-mode-change-authorization/v0.1/revisions/{revision}/index.html", + "review_interval": "3m", + "guardrail_path": "guardrails/build-mode-change-authorization.json" } ], "published_history": [ diff --git a/source-inventory.json b/source-inventory.json index fbf5591..751befa 100644 --- a/source-inventory.json +++ b/source-inventory.json @@ -913,6 +913,12 @@ "source_path": "canon/standards/autonomy-lanes_v0.1.md", "source_repo": "the-custodian" }, + { + "source_repo": "the-custodian", + "source_path": "canon/standards/build-mode-change-authorization_v0.1.md", + "disposition": "published", + "reason": "Published through an explicit publication.json document entry." + }, { "disposition": "metadata-pending", "reason": "In scope; awaits explicit publication addressing and owner/revision/review metadata.", diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..fa5e6dd --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import hashlib +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from guardrails import extract_rule, evaluate +from build_site import build, load_manifest +from test_publication import _fixture +from test_release import _release +from verify_release import verify + +CANON_FIXTURE = '---\nid: canon-build-mode-change-authorization\ntitle: Build-Mode Change Authorization\nstatus: accepted\nrevision: accepted-1\nowner: the-custodian\nlast_reviewed: 2026-10-11\nreview_interval: 3m\nexpires_on: production-close-down\n---\n\n# Build-Mode Change Authorization\n\n```yaml\nrule: build-mode-change-authorization\nversion: 0.1\napplies_while: build-mode\nblue_lane_requires: # every field must be present on the change record\n - diff_ref # path or commit of reviewed diff / dry-run output\n - rollback # command or procedure reference\n - abort_owner # repo or agent name\n - receipt_path # where the execution receipt is written\nred_lane_categories:\n - destructive-deletion\n - access-scope-expansion\n - secret-scope-expansion\n - spend-ceiling\n - public-publication\norange_lane_categories:\n - founder-identity-act\n - platform-admin-login\n```\n' + + +class GuardrailTest(unittest.TestCase): + def setUp(self): + self.source = CANON_FIXTURE + self.rule = extract_rule(self.source) + self.record = {'categories': [], 'diff_ref': 'reviewed.patch', 'rollback': 'revert source/image', + 'abort_owner': 'policy-nexus', 'receipt_path': 'receipt.json'} + + def test_canon_block_contract(self): + self.assertEqual(self.rule, { + 'rule': 'build-mode-change-authorization', 'version': 0.1, + 'applies_while': 'build-mode', + 'blue_lane_requires': ['diff_ref', 'rollback', 'abort_owner', 'receipt_path'], + 'red_lane_categories': ['destructive-deletion', 'access-scope-expansion', + 'secret-scope-expansion', 'spend-ceiling', 'public-publication'], + 'orange_lane_categories': ['founder-identity-act', 'platform-admin-login']}) + + def test_blue_requires_complete_record_and_confirmed_build_mode(self): + self.assertEqual(evaluate(self.rule, self.record, build_mode=True), {'lane': 'blue'}) + self.assertEqual(evaluate(self.rule, self.record, build_mode=False)['lane'], 'incomplete') + for key in self.rule['blue_lane_requires']: + for invalid in ['', ' ', None, True, [], {}]: + with self.subTest(key=key, value=invalid): + result = evaluate(self.rule, self.record | {key: invalid}, build_mode=True) + self.assertEqual(result['lane'], 'incomplete') + self.assertIn(key, result['missing']) + + def test_all_red_and_orange_categories_and_red_precedence(self): + for lane in ('red', 'orange'): + for category in self.rule[f'{lane}_lane_categories']: + self.assertEqual(evaluate(self.rule, {'categories': [category]}, build_mode=True)['lane'], lane) + self.assertEqual(evaluate(self.rule, {'categories': ['public-publication', 'platform-admin-login']}, build_mode=True)['lane'], 'red') + + def test_unknown_or_undeclared_categories_cannot_grant_blue(self): + for categories in [None, 'public-publication', [False], ['typo']]: + self.assertEqual(evaluate(self.rule, self.record | {'categories': categories}, build_mode=True)['lane'], 'incomplete') + + def test_unsupported_yaml_duplicate_fields_and_duplicate_blocks_refused(self): + block = self.source[self.source.index('```yaml'):self.source.index('```', self.source.index('```yaml') + 7) + 3] + for source in [self.source + '\n' + block + '\n', self.source.replace('version: 0.1', 'version: 0.1\nversion: 0.1'), + self.source.replace(' - rollback', ' - rollback: &alias'), + self.source.replace('version: 0.1', 'version: 0.2')]: + with self.assertRaises(ValueError): + extract_rule(source) + + def test_build_published_artifact_equals_canon_and_expiry_is_prominent(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = _fixture(root) + data = json.loads(manifest.read_text()) + document = data['documents'][0] + source = root / 'canon-repo' / document['source_path'] + source.write_text(self.source) + document.update(id='canon-build-mode-change-authorization', guardrail_path='guardrails/build-mode-change-authorization.json') + manifest.write_text(json.dumps(data)) + records = build(manifest, root / 'build') + artifact = root / 'build' / document['guardrail_path'] + self.assertEqual(json.loads(artifact.read_text()), extract_rule(source.read_text())) + page = (root / 'build' / document['canonical_path']).read_text() + self.assertIn('Expires on: production-close-down', page[:page.index('')]) + self.assertIn('blue_lane_requires:', page) + self.assertIn('guardrail_digest', records[0]) + self.assertIn('Build-Mode Change Authorization', (root / 'build/index.html').read_text()) + + def test_release_verifier_rejects_missing_tampered_or_invalid_guardrail(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = _release(root) + manifest_path = output / 'publication-manifest.json' + manifest = json.loads(manifest_path.read_text()) + artifact = output / 'guardrails/rule.json' + artifact.parent.mkdir() + good = (json.dumps(self.rule) + '\n').encode() + artifact.write_bytes(good) + document = manifest['documents'][0] + document.update(guardrail_path='guardrails/rule.json', guardrail_digest=hashlib.sha256(good).hexdigest()) + manifest_path.write_text(json.dumps(manifest)) + verify(output) + artifact.write_bytes(good + b' ') + with self.assertRaisesRegex(ValueError, 'guardrail artifact digest'): + verify(output) + artifact.unlink() + with self.assertRaises(OSError): + verify(output) + invalid = json.dumps(self.rule | {'version': 99}).encode() + artifact.write_bytes(invalid) + document['guardrail_digest'] = hashlib.sha256(invalid).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, 'unsupported machine rule'): + verify(output) + + def test_guardrail_path_collision_and_traversal_refused(self): + with tempfile.TemporaryDirectory() as directory: + manifest = _fixture(Path(directory)) + original = json.loads(manifest.read_text()) + for path in ['../outside.json', '/outside.json', 'example.html']: + data = json.loads(json.dumps(original)) + data['documents'][0]['guardrail_path'] = path + manifest.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + load_manifest(manifest) diff --git a/tools/build_site.py b/tools/build_site.py index 18dec82..a6ec52c 100644 --- a/tools/build_site.py +++ b/tools/build_site.py @@ -17,6 +17,7 @@ import tempfile from typing import Any from render import STYLE, render_page, split_frontmatter +from guardrails import extract_rule SOURCE_REVISION = re.compile( @@ -111,6 +112,7 @@ def load_manifest(path: Path) -> dict[str, Any]: document["canonical_path"], document["revision_path"].replace("{revision}", "revision"), *document.get("legacy_paths", []), + *([document["guardrail_path"]] if document.get("guardrail_path") else []), *(document["revision_path"].format(revision=h["revision"]) for h in document.get("historical_revisions", [])), ): @@ -397,6 +399,22 @@ def build( target.parent.mkdir(parents=True, exist_ok=True) canonical_url = "/" + canonical.as_posix() target.write_text(_redirect(canonical_url, meta["title"]), encoding="utf-8") + guardrail_metadata = {} + if document.get("guardrail_path"): + rule = extract_rule(source.read_text(encoding="utf-8")) + artifact_path = _safe_path(document["guardrail_path"]) + if artifact_path.suffix != ".json": + raise ValueError("guardrail artifact must use a JSON path") + if artifact_path in archived_paths: + raise ValueError("guardrail conflicts with archived immutable path") + artifact_bytes = (json.dumps(rule, indent=2, sort_keys=True) + "\n").encode() + target = temporary / artifact_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(artifact_bytes) + guardrail_metadata = { + "guardrail_path": artifact_path.as_posix(), + "guardrail_digest": hashlib.sha256(artifact_bytes).hexdigest(), + } records.append( { "id": document["id"], @@ -414,6 +432,7 @@ def build( "source_path": document["source_path"], "source_revision": source_revision, "source_digest": source_digest, + **guardrail_metadata, } ) diff --git a/tools/guardrails.py b/tools/guardrails.py new file mode 100644 index 0000000..83b82b8 --- /dev/null +++ b/tools/guardrails.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Publish and evaluate the restricted machine-checkable canon rule, fail closed. + +This deliberately accepts only scalar mappings and block lists used by the canon +contract. Unsupported YAML, unknown categories and malformed records never grant +execution. Lane classification is not a credential or runtime approval token. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re + +from render import split_frontmatter + + +def extract_rule(source: str) -> dict: + _meta, body = split_frontmatter(source) + blocks = re.findall(r'^```yaml\n(.*?)^```\s*$', body, re.M | re.S) + candidates = [b for b in blocks if re.search(r'^rule: build-mode-change-authorization\s*$', b, re.M)] + if len(candidates) != 1: + raise ValueError('expected exactly one build-mode authorization YAML block') + rule = {} + current = None + for line in candidates[0].splitlines(): + text = line.split('#', 1)[0].rstrip() + if not text.strip(): + continue + item = re.fullmatch(r' - ([a-z][a-z0-9_-]*)', text) + if item and current and isinstance(rule[current], list): + rule[current].append(item[1]) + continue + field = re.fullmatch(r'([a-z][a-z0-9_]*):(?: ([a-z0-9_.-]+))?', text) + if not field or field[1] in rule: + raise ValueError('unsupported or duplicate machine rule YAML field') + current = field[1] + value = field[2] + rule[current] = [] if value is None else float(value) if re.fullmatch(r'\d+\.\d+', value) else value + validate_rule(rule) + return rule + + +def validate_rule(rule: dict) -> None: + fields = {'rule', 'version', 'applies_while', 'blue_lane_requires', 'red_lane_categories', 'orange_lane_categories'} + if not isinstance(rule, dict) or set(rule) != fields: + raise ValueError('machine rule fields differ from supported contract') + if (rule['rule'] != 'build-mode-change-authorization' or rule['version'] != 0.1 + or rule['applies_while'] != 'build-mode'): + raise ValueError('unsupported machine rule identity, version or lifetime') + for name in ('blue_lane_requires', 'red_lane_categories', 'orange_lane_categories'): + values = rule[name] + if (not isinstance(values, list) or not values + or any(not isinstance(v, str) or not re.fullmatch(r'[a-z][a-z0-9_-]*', v) for v in values) + or len(values) != len(set(values))): + raise ValueError(f'invalid {name}') + if set(rule['red_lane_categories']) & set(rule['orange_lane_categories']): + raise ValueError('lane categories overlap') + + +def evaluate(rule: dict, record: dict, *, build_mode: bool) -> dict: + validate_rule(rule) + if not isinstance(record, dict): + raise ValueError('change record must be an object') + if not build_mode: + return {'lane': 'incomplete', 'reason': 'standing grant expired or build mode unconfirmed'} + categories = record.get('categories') + if not isinstance(categories, list) or any(not isinstance(c, str) for c in categories): + return {'lane': 'incomplete', 'reason': 'categories must be an explicit list'} + known = set(rule['red_lane_categories']) | set(rule['orange_lane_categories']) + unknown = sorted(set(categories) - known) + if unknown: + return {'lane': 'incomplete', 'unknown_categories': unknown} + for lane in ('red', 'orange'): + declared = sorted(set(categories) & set(rule[f'{lane}_lane_categories'])) + if declared: + return {'lane': lane, 'categories': declared} + missing = [key for key in rule['blue_lane_requires'] + if not isinstance(record.get(key), str) or not record[key].strip()] + return {'lane': 'incomplete', 'missing': missing} if missing else {'lane': 'blue'} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('rule', type=Path, help='published JSON rule artifact') + parser.add_argument('record', type=Path, help='JSON change record') + parser.add_argument('--build-mode', action='store_true', help='confirm build mode has not closed down') + args = parser.parse_args() + try: + result = evaluate(json.loads(args.rule.read_text()), json.loads(args.record.read_text()), build_mode=args.build_mode) + except (ValueError, OSError, TypeError) as exc: + parser.error(str(exc)) + print(json.dumps(result, sort_keys=True)) + return 0 if result['lane'] == 'blue' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/render.py b/tools/render.py index 5d01653..5cbd8cd 100644 --- a/tools/render.py +++ b/tools/render.py @@ -379,6 +379,11 @@ def render_page( if review_due else "" ) + expiry_line = ( + '

Expires on: ' + f'{html.escape(meta["expires_on"])}

' + if meta.get("expires_on") else "" + ) lifecycle = publication.get("lifecycle", "active") successor = publication.get("successor", "") lifecycle_notice = "" @@ -428,6 +433,7 @@ def render_page( + (f'

{html.escape(subtitle)}

' if subtitle else "") + source_line + review_line + + expiry_line + '
' f'' f"
{lifecycle_notice}{currency_notice}{body}" diff --git a/tools/verify_release.py b/tools/verify_release.py index 2c14315..cd24a7f 100644 --- a/tools/verify_release.py +++ b/tools/verify_release.py @@ -121,6 +121,12 @@ def verify(build: Path) -> dict[str, Any]: raise ValueError( f"{document_id}: immutable revision page was not built from a clean Git commit" ) + if document.get("guardrail_path"): + artifact = build / _safe_relative(document["guardrail_path"]) + if hashlib.sha256(artifact.read_bytes()).hexdigest() != document.get("guardrail_digest"): + raise ValueError(f"{document_id}: guardrail artifact digest differs from manifest") + from guardrails import validate_rule + validate_rule(json.loads(artifact.read_text(encoding="utf-8"))) verified.append(document_id) return { diff --git a/workplans/PNEX-WP-0006-build-mode-change-authorization.md b/workplans/PNEX-WP-0006-build-mode-change-authorization.md index baa657a..cbcd513 100644 --- a/workplans/PNEX-WP-0006-build-mode-change-authorization.md +++ b/workplans/PNEX-WP-0006-build-mode-change-authorization.md @@ -4,7 +4,7 @@ type: workplan title: "Publish the build-mode change authorization guardrail" domain: infotech repo: policy-nexus -status: ready +status: blocked owner: codex topic_slug: custodian created: "2026-10-11" @@ -23,8 +23,10 @@ repo's intent: publication only, the content stays in canon. ```task id: PNEX-WP-0006-T01 -status: todo +status: wait priority: high +needs_human: true +intervention_note: "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request." state_hub_task_id: "ee3c6f88-91b5-5dac-94e7-d1b05b0276bc" ``` @@ -35,8 +37,10 @@ machine-checkable YAML block intact. Show `expires_on: production-close-down` pr ```task id: PNEX-WP-0006-T02 -status: todo +status: wait priority: high +needs_human: true +intervention_note: "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request." state_hub_task_id: "92247888-82ad-52ca-962f-1a578adeb9f7" ``` @@ -44,3 +48,26 @@ Serve the YAML block as a stable JSON/YAML artifact (for example `/guardrails/build-mode-change-authorization.json`). CI checks in other repos and access-engine can then fetch and evaluate it deterministically. Add a test that the published artifact equals the canon block. + +## Implementation and publication boundary (2026-10-11) + +T01/T02 source implementation is prepared: canonical document inventory/index +entry, visible expiry, canon YAML-derived JSON artifact, digest-bound release +verification, and fail-closed local evaluator. Local checks pass; the native +public page/artifact have not been published and neither task is closed. +Validation receipt: `docs/evidence/2026-10-11-build-mode-guardrail/validation.json`. + +Daily red-lane package: publish only the new build-mode standard and its derived +JSON artifact after founder batch disposition and clean source provenance. +Expected addresses: `/standards/build-mode-change-authorization/v0.1/` and +`/guardrails/build-mode-change-authorization.json`. Abort owner: policy-nexus. +Rollback: restore the previously deployed image/source revision; retain all +previous immutable publication paths. Verification: canonical page expiry/YAML, +JSON equality with the approved canon block, artifact digest and installed +source revision. No native action, access-scope change or new spend is authorized +by local preparation. + +Full source audit also detects the unrelated unreviewed +`net-kingdom/canon/standards/security-layer-model_v0.9.md`. Its owning source +publication disposition must be resolved before the release gate passes; this +workplan does not silently admit it or edit another owner's standard.