diff --git a/docs/changes/CUST-WP-0080/accept_forge_routes.py b/docs/changes/CUST-WP-0080/accept_forge_routes.py index 867e5fb..0e00120 100644 --- a/docs/changes/CUST-WP-0080/accept_forge_routes.py +++ b/docs/changes/CUST-WP-0080/accept_forge_routes.py @@ -65,6 +65,28 @@ class Suite: raise ValueError('invalid-object-id') return oid + def wait_mergeable(self, suffix): + for _ in range(40): + code, body = self.api('GET', suffix) + if code == 200 and json.loads(body).get('mergeable') is True: + return + time.sleep(.5) + raise ValueError('pull-not-mergeable') + + def clean_merge(self, suffix): + # A refused merge makes Forgejo recheck mergeability; until that finishes + # it answers 405. Wait again and retry only that status, a bounded few times. + statuses = self.evidence['clean_merge_status_codes'] = [] + for _ in range(3): + self.wait_mergeable(suffix) + code = self.api('POST', suffix + '/merge', {'Do': 'merge', + 'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0] + statuses.append(code) + if code != 405: + break + time.sleep(1) + return statuses[-1] + def content(self, name, content, branch='main'): return self.api('POST', '/contents/' + name, {'content': base64.b64encode(content).decode(), @@ -137,13 +159,7 @@ class Suite: if type(number) is not int: raise ValueError('invalid-pull-number') suffix = '/pulls/' + str(number) - for _ in range(40): - code, body = self.api('GET', suffix) - if code == 200 and json.loads(body).get('mergeable') is True: - break - time.sleep(.5) - else: - raise ValueError('pull-not-mergeable') + self.wait_mergeable(suffix) before = self.head() code, body = self.api('POST', suffix + '/merge', {'Do': 'merge', 'MergeTitleField': 'Disposable merge', 'MergeMessageField': fixture.decode(), @@ -152,8 +168,7 @@ class Suite: detected(json.loads(body).get('message', '').encode())) code, body = self.api('GET', suffix) self.check('rejected_merge_not_applied', code == 200 and json.loads(body).get('merged') is False and self.head() == before) - self.check('clean_merge_accepted', self.api('POST', suffix + '/merge', {'Do': 'merge', - 'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0] in (200, 201)) + self.check('clean_merge_accepted', self.clean_merge(suffix) in (200, 201)) code, body = self.api('GET', suffix) self.check('clean_merge_applied', code == 200 and json.loads(body).get('merged') is True and self.head() != before) self.evidence['acceptance_passed'] = True diff --git a/docs/changes/CUST-WP-0080/forge-routes-proposal-r2.json b/docs/changes/CUST-WP-0080/forge-routes-proposal-r2.json new file mode 100644 index 0000000..999fd34 --- /dev/null +++ b/docs/changes/CUST-WP-0080/forge-routes-proposal-r2.json @@ -0,0 +1,30 @@ +{ + "proposal_id": "FORGE6-ROUTES-001", + "revision": 2, + "state": "prepared-isolated-verified-awaiting-attended-login", + "origin": "https://forgejo.coulomb.social", + "user": "tegwick", + "repository_name": "custodian-routes-f140c6b840da4bbb9de64a74", + "source_sha256": { + "accept_forge_routes.py": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c", + "run_forge_routes.py": "6258140d408e5fbed7373611cdcd65e64aee743a0b36ce4fe55b5dc94663120f", + "receive-hook-proposal.json": "9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d", + "activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789", + "activate_receive_hooks.py": "9b01ee383e16463981112226e19156b0de59af2d03dd71b9403e144e280a9fb3", + "verify_receive_native.py": "c9120122c6f6f0b7c41fe4fa975e5a2a340e776885d852b861b8c13b7664adcd" + }, + "target": "One newly created private disposable repository; pinned deployed Forgejo and admitted hook. Existing repository content, hooks, accounts and policies remain untouched.", + "expected_result": "Authenticated native HTTPS clean acceptance and synthetic-secret refusal with detector evidence; API private creation inherits exact hook; clean/content-secret and clean/merge-message refusal with unchanged refs; quarantine commit absent; anonymous private access refused; owned test repository deleted and absent.", + "verification": "Exact shared Suite rehearsed against pinned isolated Forgejo; five boundary regression tests. Live receipt required; rehearsal is not deployed acceptance.", + "rollback": "Automatic cleanup checks numeric repository ID, owner, private flag, name and ownership description before deleting only the newly created test repository. On interrupted known creation, rerun run_forge_routes.py with the same proposal and receipt plus --cleanup-only. Ambiguous POST before numeric ID receipt requires reconciliation, never replay or delete by name.", + "abort_owner": "railiance-forge; custodian-codex prepares and verifies receipts", + "cost_estimate_eur": 0, + "cost_basis": "Existing service only; serialized small private probes, no added resources or spend commitments.", + "risk_estimate": "Bounded synthetic test writes to a unique private disposable repository. No real secret fixtures, account creation, access-policy change or external publication. Unexpected acceptance may retain an unbound synthetic private-key fixture until cleanup. API/network failure stops the trial; ambiguous creation and cleanup remain explicitly unverified.", + "authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md", + "lane": "orange: existing caller OIDC/MFA required; no access or secret-policy scope change", + "live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json", + "remaining_scope": "Browser/SSO journey, external gitlink repository, CI, import/mirror, historical/LFS/log/artifact detection and finding response remain open.", + "original_revision_sha256": "a253fce3a565a2af94133da019bdc434dc57b21a8c61b06b4c27e036cf5a01d3", + "refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained." +} diff --git a/docs/changes/CUST-WP-0080/forge-routes-proposal.json b/docs/changes/CUST-WP-0080/forge-routes-proposal.json index 999fd34..d6d06bb 100644 --- a/docs/changes/CUST-WP-0080/forge-routes-proposal.json +++ b/docs/changes/CUST-WP-0080/forge-routes-proposal.json @@ -1,12 +1,12 @@ { "proposal_id": "FORGE6-ROUTES-001", - "revision": 2, - "state": "prepared-isolated-verified-awaiting-attended-login", + "revision": 3, + "state": "prepared-awaiting-isolated-rehearsal", "origin": "https://forgejo.coulomb.social", "user": "tegwick", - "repository_name": "custodian-routes-f140c6b840da4bbb9de64a74", + "repository_name": "custodian-routes-b15df5a32e9697ab47215efc", "source_sha256": { - "accept_forge_routes.py": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c", + "accept_forge_routes.py": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0", "run_forge_routes.py": "6258140d408e5fbed7373611cdcd65e64aee743a0b36ce4fe55b5dc94663120f", "receive-hook-proposal.json": "9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d", "activate_forge_template.py": "ef2b38f655d5520d69dcd69cdb4997eaad4b50773f32c1d798d16331b204c789", @@ -15,7 +15,7 @@ }, "target": "One newly created private disposable repository; pinned deployed Forgejo and admitted hook. Existing repository content, hooks, accounts and policies remain untouched.", "expected_result": "Authenticated native HTTPS clean acceptance and synthetic-secret refusal with detector evidence; API private creation inherits exact hook; clean/content-secret and clean/merge-message refusal with unchanged refs; quarantine commit absent; anonymous private access refused; owned test repository deleted and absent.", - "verification": "Exact shared Suite rehearsed against pinned isolated Forgejo; five boundary regression tests. Live receipt required; rehearsal is not deployed acceptance.", + "verification": "Seven boundary tests pass. Exact shared Suite must be re-rehearsed against pinned isolated Forgejo with the admitted binaries before live use; rehearsal is not deployed acceptance.", "rollback": "Automatic cleanup checks numeric repository ID, owner, private flag, name and ownership description before deleting only the newly created test repository. On interrupted known creation, rerun run_forge_routes.py with the same proposal and receipt plus --cleanup-only. Ambiguous POST before numeric ID receipt requires reconciliation, never replay or delete by name.", "abort_owner": "railiance-forge; custodian-codex prepares and verifies receipts", "cost_estimate_eur": 0, @@ -23,8 +23,11 @@ "risk_estimate": "Bounded synthetic test writes to a unique private disposable repository. No real secret fixtures, account creation, access-policy change or external publication. Unexpected acceptance may retain an unbound synthetic private-key fixture until cleanup. API/network failure stops the trial; ambiguous creation and cleanup remain explicitly unverified.", "authorization_ref": "canon/standards/build-mode-change-authorization_v0.1.md", "lane": "orange: existing caller OIDC/MFA required; no access or secret-policy scope change", - "live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json", + "live_receipt": "docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.json", "remaining_scope": "Browser/SSO journey, external gitlink repository, CI, import/mirror, historical/LFS/log/artifact detection and finding response remain open.", "original_revision_sha256": "a253fce3a565a2af94133da019bdc434dc57b21a8c61b06b4c27e036cf5a01d3", - "refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained." + "refinement": "Pre-execution rehearsal corrected inherited Git hook mode to accept exact hash/owner with 0555 or 0755; initial failed rehearsal and source-binding limitation are retained.", + "previous_revision_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792", + "previous_live_receipt": "docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json", + "refinement_r3": "Revision 2 live run (2026-10-18) passed 24/25 checks; the clean merge sent ~1s after the refused merge got 405 in 17ms (Forgejo log), i.e. mergeability recheck still running. Revision 3 waits for mergeable again before the clean merge, retries only 405 at most three times, and records the merge status codes (no response bodies). Two new boundary tests cover retry and its bounds." } diff --git a/docs/changes/CUST-WP-0080/test_forge_routes_safety.py b/docs/changes/CUST-WP-0080/test_forge_routes_safety.py index 591bd52..f37542a 100644 --- a/docs/changes/CUST-WP-0080/test_forge_routes_safety.py +++ b/docs/changes/CUST-WP-0080/test_forge_routes_safety.py @@ -54,6 +54,37 @@ class CleanupTests(unittest.TestCase): self.assertFalse(m.detected(b'{"status":"unverified"}')) +class MergeRetryTests(unittest.TestCase): + def suite(self, directory, merges): + calls = [] + class Backend: + user = 'tegwick' + scope = 'test' + def api(self, method, path, data=None, authenticated=True): + calls.append(method) + if method == 'GET': + return 200, json.dumps({'mergeable': True}).encode() + return merges.pop(0), b'{}' + suite = m.Suite(Backend(), Path(directory) / 'receipt.json', 'custodian-routes-' + 'a' * 24) + return suite, calls + + def test_recheck_405_is_waited_out_and_retried(self): + with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'): + suite, calls = self.suite(directory, [405, 200]) + self.assertEqual(suite.clean_merge('/pulls/1'), 200) + self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 200]) + self.assertEqual(calls, ['GET', 'POST', 'GET', 'POST']) + + def test_other_refusals_are_not_retried_and_retries_are_bounded(self): + with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'): + suite, _ = self.suite(directory, [409]) + self.assertEqual(suite.clean_merge('/pulls/1'), 409) + self.assertEqual(suite.evidence['clean_merge_status_codes'], [409]) + suite, _ = self.suite(directory, [405, 405, 405, 200]) + self.assertEqual(suite.clean_merge('/pulls/1'), 405) + self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 405, 405]) + + class TransportTests(unittest.TestCase): def test_http_redirect_does_not_forward_injected_credential(self): hits = [] diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json b/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json new file mode 100644 index 0000000..534ecc9 --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.json @@ -0,0 +1,39 @@ +{ + "result": "unverified", + "repository_name": "custodian-routes-f140c6b840da4bbb9de64a74", + "observed_at": "2026-10-18T00:18:27.583559+00:00", + "checks": { + "expected_authenticated_caller": true, + "target_does_not_preexist": true, + "owned_private_repository_created": true, + "anonymous_private_read_denied": true, + "new_repository_inherits_exact_hook": true, + "http_clean_push_accepted": true, + "http_clean_ref_matches": true, + "http_secret_push_detected_and_refused": true, + "http_refused_ref_absent": true, + "http_rejection_preserves_main": true, + "http_quarantine_object_absent": true, + "api_clean_content_accepted": true, + "api_secret_content_refused": true, + "api_refusal_has_detector_evidence": true, + "api_refusal_preserves_main": true, + "api_refused_content_absent": true, + "feature_branch_created": true, + "feature_content_accepted": true, + "pull_request_created": true, + "merge_secret_message_detected_and_refused": true, + "rejected_merge_not_applied": true, + "clean_merge_accepted": false, + "cleanup_identity_matches": true, + "owned_disposable_repository_deleted": true, + "deleted_repository_absent": true + }, + "scope": "Deployed HTTPS Git, repository creation, content API and merge API on one owned private disposable repository. No browser/SSO, CI, import/mirror or finding-response conformance inferred.", + "proposal_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792", + "phase": "cleanup", + "executor_sha256": "96608481ab62445d2688e20f48c76a35c3d7be26c37452be3c46202eec4e103c", + "creation_attempted": true, + "created_repository_id": 173, + "failure_phase": "merge-api" +} diff --git a/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.transport.json b/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.transport.json new file mode 100644 index 0000000..b27892c --- /dev/null +++ b/docs/evidence/2026-10-13-netkingdom-security/forge-routes-live.transport.json @@ -0,0 +1,8 @@ +{ + "route_exit_code": 1, + "receipt_present": true, + "cleanup_only": false, + "proposal_sha256": "d12c12857e9ed5f4afa3c1c231fd142adad580a458cc175e74f0ea0e3118d792", + "raw_diagnostics_retained": false, + "credential_returned_to_agent": false +} diff --git a/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.baseline.json b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.baseline.json new file mode 100644 index 0000000..dac65bf --- /dev/null +++ b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.baseline.json @@ -0,0 +1,24 @@ +{ + "observed_at": "2026-10-18T00:27:53.039275+00:00", + "image": "code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", + "result": "fixture-ready", + "checks": { + "synthetic_identity_authenticated": true, + "native_repository_created": true + }, + "scope": "Pinned-image fixture setup only; shared suite has its own acceptance receipt.", + "identity_http_status": 200, + "repository_create_http_status": 201, + "repository_create_error_kinds": [ + "email" + ], + "inherited_hook_mode_owner": "755 1000 1000", + "failure_diagnostics": [ + "PushRejected", + "app.ini", + "exit status 1" + ], + "isolated_container_and_volumes_removed": true, + "last_phase": "baseline", + "executor_sha256": "88bbf4235086b7ffc40ca4d7df7fafa1d67e2e1b41d2f0055ec82421ed4840cb" +} diff --git a/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.json b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.json new file mode 100644 index 0000000..d3eefe7 --- /dev/null +++ b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-isolated-3.json @@ -0,0 +1,44 @@ +{ + "result": "verified", + "repository_name": "custodian-routes-8da2932b43a6476ab883aca2", + "observed_at": "2026-10-18T00:28:10.732135+00:00", + "checks": { + "expected_authenticated_caller": true, + "target_does_not_preexist": true, + "owned_private_repository_created": true, + "anonymous_private_read_denied": true, + "new_repository_inherits_exact_hook": true, + "http_clean_push_accepted": true, + "http_clean_ref_matches": true, + "http_secret_push_detected_and_refused": true, + "http_refused_ref_absent": true, + "http_rejection_preserves_main": true, + "http_quarantine_object_absent": true, + "api_clean_content_accepted": true, + "api_secret_content_refused": true, + "api_refusal_has_detector_evidence": true, + "api_refusal_preserves_main": true, + "api_refused_content_absent": true, + "feature_branch_created": true, + "feature_content_accepted": true, + "pull_request_created": true, + "merge_secret_message_detected_and_refused": true, + "rejected_merge_not_applied": true, + "clean_merge_accepted": true, + "clean_merge_applied": true, + "executor_unchanged_during_rehearsal": true, + "cleanup_identity_matches": true, + "owned_disposable_repository_deleted": true, + "deleted_repository_absent": true + }, + "scope": "Exact shared HTTP/API suite on pinned isolated Forgejo, SQLite, no external network; no live credential or deployed acceptance.", + "loaded_executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0", + "phase": "cleanup", + "executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0", + "creation_attempted": true, + "created_repository_id": 2, + "clean_merge_status_codes": [ + 200 + ], + "acceptance_passed": true +} diff --git a/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.json b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.json new file mode 100644 index 0000000..78f82fb --- /dev/null +++ b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.json @@ -0,0 +1,43 @@ +{ + "result": "verified", + "repository_name": "custodian-routes-b15df5a32e9697ab47215efc", + "observed_at": "2026-10-18T00:32:19.351604+00:00", + "checks": { + "expected_authenticated_caller": true, + "target_does_not_preexist": true, + "owned_private_repository_created": true, + "anonymous_private_read_denied": true, + "new_repository_inherits_exact_hook": true, + "http_clean_push_accepted": true, + "http_clean_ref_matches": true, + "http_secret_push_detected_and_refused": true, + "http_refused_ref_absent": true, + "http_rejection_preserves_main": true, + "http_quarantine_object_absent": true, + "api_clean_content_accepted": true, + "api_secret_content_refused": true, + "api_refusal_has_detector_evidence": true, + "api_refusal_preserves_main": true, + "api_refused_content_absent": true, + "feature_branch_created": true, + "feature_content_accepted": true, + "pull_request_created": true, + "merge_secret_message_detected_and_refused": true, + "rejected_merge_not_applied": true, + "clean_merge_accepted": true, + "clean_merge_applied": true, + "cleanup_identity_matches": true, + "owned_disposable_repository_deleted": true, + "deleted_repository_absent": true + }, + "scope": "Deployed HTTPS Git, repository creation, content API and merge API on one owned private disposable repository. No browser/SSO, CI, import/mirror or finding-response conformance inferred.", + "proposal_sha256": "322434dcb5a363f962a62d4e03613b68e829248ff75c849997577dac45185d8d", + "phase": "cleanup", + "executor_sha256": "73191bb85bfb41de7084a539eb30ff3f4cb5650fd79e68ff8acc496089bb77f0", + "creation_attempted": true, + "created_repository_id": 174, + "clean_merge_status_codes": [ + 200 + ], + "acceptance_passed": true +} diff --git a/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.transport.json b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.transport.json new file mode 100644 index 0000000..54a3f46 --- /dev/null +++ b/docs/evidence/2026-10-18-netkingdom-security/forge-routes-live-r3.transport.json @@ -0,0 +1,8 @@ +{ + "route_exit_code": 0, + "receipt_present": true, + "cleanup_only": false, + "proposal_sha256": "322434dcb5a363f962a62d4e03613b68e829248ff75c849997577dac45185d8d", + "raw_diagnostics_retained": false, + "credential_returned_to_agent": false +}