diff --git a/.github/main-protection.json b/.github/main-protection.json new file mode 100644 index 0000000..9ecbc01 --- /dev/null +++ b/.github/main-protection.json @@ -0,0 +1,94 @@ +{ + "schema": "goal.main-protection/v1", + "repository": "semcod/goal", + "rulesets": [ + { + "name": "goal-main-required-gates", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": true, + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "merge" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "test (3.12)", + "integration_id": 15368 + }, + { + "context": "test (3.13)", + "integration_id": 15368 + }, + { + "context": "governance / remote lifecycle", + "integration_id": 15368 + }, + { + "context": "governance / enforce", + "integration_id": 15368 + } + ] + } + } + ] + }, + { + "name": "goal-main-trusted-publisher", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 4344831, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "update", + "parameters": { + "update_allows_fetch_and_merge": false + } + } + ] + } + ] +} diff --git a/.github/scripts/check_main_protection.py b/.github/scripts/check_main_protection.py new file mode 100644 index 0000000..a59bf8f --- /dev/null +++ b/.github/scripts/check_main_protection.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Read-only verification of Goal's declared and active main protection.""" + +import argparse +import json +from pathlib import Path +import re +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def required_contexts(root): + declaration = json.loads((root / '.governance/required-checks.json').read_text()) + names = set() + for item in declaration['requiredChecks']: + if item == {'name': 'test', 'workflowFile': '.github/workflows/ci.yml'}: + workflow = (root / item['workflowFile']).read_text() + matrix = re.findall(r'^\s*python-version:\s*(\[[^\n]+\])\s*$', workflow, re.M) + require(len(matrix) == 1, 'Expected one explicit Python CI matrix') + versions = json.loads(matrix[0]) + require(versions and all(isinstance(v, str) for v in versions), 'Invalid Python matrix') + names.update(f'test ({version})' for version in versions) + else: + names.add(item['name']) + return names + + +def validate_policy(policy, contexts): + require(policy['schema'] == 'goal.main-protection/v1', 'Unknown policy schema') + require(policy['repository'] == 'semcod/goal', 'Unexpected repository') + rows = policy['rulesets'] + require(len(rows) == 2, 'Expected independent gate and publisher rulesets') + by_name = {row['name']: row for row in rows} + require(set(by_name) == {'goal-main-required-gates', 'goal-main-trusted-publisher'}, 'Unexpected ruleset names') + for row in rows: + require(row['target'] == 'branch' and row['enforcement'] == 'active', 'Rules must be active') + require(row['conditions'] == {'ref_name': {'include': ['refs/heads/main'], 'exclude': []}}, 'Rules must cover exactly main') + gates = by_name['goal-main-required-gates'] + require(gates['bypass_actors'] == [], 'Required gates must have no bypass') + rules = {rule['type']: rule for rule in gates['rules']} + require(len(gates['rules']) == 4 and set(rules) == {'deletion', 'non_fast_forward', 'pull_request', 'required_status_checks'}, 'Missing or duplicate gate') + review = rules['pull_request']['parameters'] + require(review['required_approving_review_count'] >= 1, 'Independent review is required') + require(review['dismiss_stale_reviews_on_push'] and review['require_last_push_approval'], 'Approval must cover the latest push') + require(review['required_review_thread_resolution'], 'Review threads must be resolved') + checks = rules['required_status_checks']['parameters'] + require(checks['strict_required_status_checks_policy'] and not checks['do_not_enforce_on_create'], 'Checks must cover the current base') + entries = checks['required_status_checks'] + require({entry['context'] for entry in entries} == contexts and len(entries) == len(contexts), 'Required check names differ from the workflow declaration/matrix') + require(all(entry['integration_id'] == 15368 for entry in entries), 'Checks must come from GitHub Actions') + publisher = by_name['goal-main-trusted-publisher'] + require(publisher['bypass_actors'] == [{'actor_id': 4344831, 'actor_type': 'Integration', 'bypass_mode': 'always'}], 'Only the protected Validator App may publish') + require(publisher['rules'] == [{'type': 'update', 'parameters': {'update_allows_fetch_and_merge': False}}], 'Publisher exception must apply only to the update restriction') + + +def projected(actual, expected): + """Ignore response metadata, but retain every expected security field.""" + if isinstance(expected, dict): + require(isinstance(actual, dict), 'Expected an object from GitHub') + return {key: projected(actual[key], value) for key, value in expected.items()} + if isinstance(expected, list): + require(isinstance(actual, list) and len(actual) == len(expected), 'GitHub list differs from policy') + if expected and isinstance(expected[0], dict): + key = next((key for key in ('type', 'context', 'actor_id') if key in expected[0]), None) + if key: + actual = sorted(actual, key=lambda item: item[key]) + expected = sorted(expected, key=lambda item: item[key]) + return [projected(a, e) for a, e in zip(actual, expected)] + return actual + + +def gh(endpoint): + return json.loads(subprocess.check_output(['gh', 'api', endpoint], text=True)) + + +def verify_live(policy, api=gh, *, public_only=False): + repository = policy['repository'] + base = f'repos/{repository}' + listed = api(base + '/rulesets?includes_parents=true&per_page=100') + require(len(listed) < 100, 'Ruleset inventory requires pagination; refusing an incomplete audit') + active = api(base + '/rules/branches/main?per_page=100') + require(len(active) < 100, 'Active rule inventory requires pagination') + ids = [] + for expected in policy['rulesets']: + matches = [row for row in listed if row['name'] == expected['name'] and row['source'] == repository] + require(len(matches) == 1, 'Missing or duplicate repository ruleset: ' + expected['name']) + rule_id = matches[0]['id'] + actual = api(base + '/rulesets/' + str(rule_id)) + # GitHub serializes the ordinary update restriction without parameters + # when the optional upstream-fetch exception is disabled. + for rule in actual['rules']: + if rule['type'] == 'update' and 'parameters' not in rule: + rule['parameters'] = {'update_allows_fetch_and_merge': False} + # GitHub hides bypass actors from callers without ruleset write access. + # CI deliberately checks public fields; deployment must run full mode. + compared = {key: value for key, value in expected.items() if not (public_only and key == 'bypass_actors')} + require(projected(actual, compared) == projected(compared, compared), 'Live ruleset drift: ' + expected['name']) + applied = {row['type'] for row in active if row['ruleset_id'] == rule_id} + require(applied == {rule['type'] for rule in expected['rules']}, 'Ruleset is not fully active on main') + ids.append(rule_id) + return ids + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + live = parser.add_mutually_exclusive_group() + live.add_argument('--live', action='store_true', help='Verify all GitHub settings, including bypass actors (requires ruleset write visibility; makes no writes)') + live.add_argument('--public-live', action='store_true', help='Verify public GitHub settings only; bypass actors are not observable with CI read permissions') + args = parser.parse_args() + try: + policy = json.loads((ROOT / '.github/main-protection.json').read_text()) + validate_policy(policy, required_contexts(ROOT)) + ids = verify_live(policy, public_only=args.public_live) if args.live or args.public_live else [] + except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error: + parser.exit(1, f'MAIN-PROTECTION-FAIL: {error}\n') + mode = 'live' if args.live else 'public-live' if args.public_live else 'declared' + print(json.dumps({'status': 'pass', 'mode': mode, 'bypassActorsVerifiedLive': args.live, 'rulesetIds': ids, 'mutated': False})) + + +if __name__ == '__main__': + main() diff --git a/.github/tests/test_main_protection.py b/.github/tests/test_main_protection.py new file mode 100644 index 0000000..0f4a46e --- /dev/null +++ b/.github/tests/test_main_protection.py @@ -0,0 +1,110 @@ +import copy +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('main_protection', ROOT / '.github/scripts/check_main_protection.py') +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class ProtectionTests(unittest.TestCase): + def setUp(self): + self.policy = json.loads((ROOT / '.github/main-protection.json').read_text()) + self.contexts = checker.required_contexts(ROOT) + + def validate(self): + checker.validate_policy(self.policy, self.contexts) + + def test_declared_policy_matches_real_matrix(self): + self.validate() + self.assertIn('test (3.12)', self.contexts) + self.assertIn('test (3.13)', self.contexts) + self.assertNotIn('test', self.contexts) + + def test_gate_bypass_rejected(self): + self.policy['rulesets'][0]['bypass_actors'] = self.policy['rulesets'][1]['bypass_actors'] + with self.assertRaises(ValueError): + self.validate() + + def test_untrusted_publisher_rejected(self): + self.policy['rulesets'][1]['bypass_actors'][0]['actor_id'] = 1 + with self.assertRaises(ValueError): + self.validate() + + def test_disabled_or_excluded_main_rejected(self): + for field, value in [('enforcement', 'disabled'), ('conditions', {'ref_name': {'include': ['refs/heads/main'], 'exclude': ['refs/heads/main']}})]: + with self.subTest(field=field): + candidate = copy.deepcopy(self.policy) + candidate['rulesets'][0][field] = value + with self.assertRaises(ValueError): + checker.validate_policy(candidate, self.contexts) + + def test_missing_matrix_check_rejected(self): + self.policy['rulesets'][0]['rules'][-1]['parameters']['required_status_checks'].pop(0) + with self.assertRaises(ValueError): + self.validate() + + def test_stale_review_rejected(self): + self.policy['rulesets'][0]['rules'][2]['parameters']['dismiss_stale_reviews_on_push'] = False + with self.assertRaises(ValueError): + self.validate() + + def fake_api(self, drift=None, omit_active=False): + rows = copy.deepcopy(self.policy['rulesets']) + for number, row in enumerate(rows, 1): + row.update(id=number, source='semcod/goal', created_at='metadata ignored') + if drift: + drift(rows) + def read(endpoint): + if '/rulesets?' in endpoint: + return rows + if '/rules/branches/main?' in endpoint: + return [] if omit_active else [{'ruleset_id': row['id'], 'type': rule['type']} for row in rows for rule in row['rules']] + return rows[int(endpoint.rsplit('/', 1)[1]) - 1] + return read + + def test_live_readback_handles_order_and_metadata(self): + api = self.fake_api(lambda rows: rows[0]['rules'].reverse()) + self.assertEqual(checker.verify_live(self.policy, api), [1, 2]) + + def test_live_extra_bypass_is_drift(self): + api = self.fake_api(lambda rows: rows[0]['bypass_actors'].append({'actor_id': 1})) + with self.assertRaises(ValueError): + checker.verify_live(self.policy, api) + + def test_declared_but_inactive_rules_rejected(self): + with self.assertRaises(ValueError): + checker.verify_live(self.policy, self.fake_api(omit_active=True)) + + def test_incomplete_inventory_rejected(self): + with self.assertRaises(ValueError): + checker.verify_live(self.policy, lambda endpoint: [{}] * 100) + + def test_api_failure_propagates(self): + def unavailable(endpoint): + raise OSError('GitHub unavailable') + with self.assertRaises(OSError): + checker.verify_live(self.policy, unavailable) + + def test_hidden_bypass_requires_explicit_public_scope(self): + api = self.fake_api(lambda rows: [row.pop('bypass_actors') for row in rows]) + with self.assertRaises(KeyError): + checker.verify_live(self.policy, api) + self.assertEqual(checker.verify_live(self.policy, api, public_only=True), [1, 2]) + + def test_github_omits_disabled_upstream_fetch_exception(self): + api = self.fake_api(lambda rows: rows[1]['rules'][0].pop('parameters')) + self.assertEqual(checker.verify_live(self.policy, api), [1, 2]) + + def test_enabled_upstream_fetch_exception_is_drift(self): + def drift(rows): + rows[1]['rules'][0]['parameters']['update_allows_fetch_and_merge'] = True + with self.assertRaises(ValueError): + checker.verify_live(self.policy, self.fake_api(drift)) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 658c42e..701f0d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,13 @@ jobs: run: | python -m pytest tests/ -v --tb=short + - name: Verify main publication protection + env: + GH_TOKEN: ${{ github.token }} + run: | + python -m unittest discover -s .github/tests -v + python .github/scripts/check_main_protection.py --public-live + - name: Lint run: | pip install flake8 || true diff --git a/project/ticket-095/README.md b/project/ticket-095/README.md new file mode 100644 index 0000000..b560186 --- /dev/null +++ b/project/ticket-095/README.md @@ -0,0 +1,14 @@ +# Ticket 095: Enforce Goal main publication on GitHub + +- **Owner**: codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION + +SESSION_EXECUTION_AUTHORIZATION: the user requests continuation, publication and testing after the missing main protection was explicitly reported. This authorizes the bounded Goal settings deployment and the declared independent Validator publication process. + +- [x] AC-01: Version the exact server rules and tests that detect unsafe or drifted settings. +- [ ] AC-02: Activate rules, verify real required checks and independent merge, and confirm main CI and the local deployment. + +The required checks have no bypass. A separate main update rule permits only App 4344831; it cannot bypass the check/review rules. Existing working data and OneDev ticket-207 are preserved. + +Pre-publication validation: 735 product tests passed, 2 existing skips; 14 protection tests passed. Governance, Compose and declaration checks passed. CI uses explicit public-rule verification because GitHub hides bypass actors from read-only callers; deployment uses full administrator-visible readback. Hosted Python checks remain required; OneDev is not substituted. diff --git a/project/ticket-095/intent.json b/project/ticket-095/intent.json new file mode 100644 index 0000000..0ec0d4d --- /dev/null +++ b/project/ticket-095/intent.json @@ -0,0 +1,89 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-095", + "summary": "Enforce server-side Goal main publication and detect policy drift", + "workstream": "infrastructure", + "classification": { + "kind": "SERVICE", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + ".github/main-protection.json", + ".github/scripts/check_main_protection.py", + ".github/tests/test_main_protection.py", + ".github/workflows/ci.yml", + "project/ticket-095/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python", + "docker" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "bd43a30a3ce5122ebe551246bc8174900eae6174", + "targetBranch": "main", + "outcome": "Publish and activate server-enforced checks, current independent review and a Validator-only main publisher; prove a real PR can merge under those rules and CI detects drift.", + "nonGoals": [ + "Change trusted Validator identity or grant bypass of checks.", + "Modify OneDev profiles while ticket-207 owns their shared files." + ], + "complexity": "M", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Version the exact Goal rulesets. Separate mandatory PR/check rules with no bypass from the update restriction whose sole publisher is the protected Validator App. Add read-only drift verification to both existing Python jobs.", + "components": [ + { + "name": "github-publication", + "paths": [ + ".github/main-protection.json", + ".github/scripts/check_main_protection.py", + ".github/tests/test_main_protection.py", + ".github/workflows/ci.yml" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Restore the prior recorded ruleset configuration through an explicit administrative recovery operation; do not bypass a failed check to merge." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python3 -m unittest discover -s .github/tests -v", + "python3 .github/scripts/check_main_protection.py" + ], + "evidence": "Policy failure cases and live required-check names." + }, + { + "criterion": "AC-02", + "commands": [ + "python3 .github/scripts/check_main_protection.py --live", + "python3 -m pytest tests/ -q", + "docker compose config --quiet" + ], + "evidence": "Fresh GitHub readback, exact-head independent approval, merge under active rules and green post-merge CI." + } + ] + } +}