diff --git a/.getzilla/getzilla/model_data_admission.py b/.getzilla/getzilla/model_data_admission.py new file mode 100644 index 0000000..4f04347 --- /dev/null +++ b/.getzilla/getzilla/model_data_admission.py @@ -0,0 +1,180 @@ +"""Bind an offline request preview; never authorize a model data transfer. + +The supplied account reference is an unverified label. This module does not +read credentials, validate human consent, or provide a dispatch capability. +""" +from __future__ import annotations + +import hashlib +import json +import re +import time +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +from . import model_continuation_state as journal +from . import openai_model_request as requests + +_HANDLE = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z') +_ENDPOINT = 'https://api.openai.com/v1/responses' +_MAX_REQUEST_BYTES = 4_000_000 + + +class AdmissionError(ValueError): + """Fixed diagnostic code with no payload or credential data.""" + + +@dataclass(frozen=True, repr=False) +class AccountReference: + provider: str + account_ref: str + handle: str + version: str + + +@dataclass(frozen=True, repr=False) +class AccountResolution: + reference: AccountReference + + @property + def account_identity_verified(self) -> bool: + return False + + +def _valid_account(account) -> bool: + if type(account) is not AccountReference: + return False + values = (account.provider, account.account_ref, account.handle, account.version) + return all(type(value) is str and _HANDLE.fullmatch(value) + and not value.lower().startswith('sk-') for value in values) + + +def resolve_account(provider: str, account_ref: str, records: tuple) -> AccountResolution: + """Resolve a unique metadata mapping, without asserting credential ownership.""" + if type(records) is not tuple or not 1 <= len(records) <= 64 or any(not _valid_account(r) for r in records): + raise AdmissionError('account_mapping_invalid') + matches = [r for r in records if r.provider == provider and r.account_ref == account_ref] + if len(matches) != 1: + raise AdmissionError('account_mapping_ambiguous') + return AccountResolution(matches[0]) + + +@dataclass(frozen=True, repr=False) +class Preview: + root_digest: str + binding_digest: str + contract_digest: str + policy_digest: str + request_digest: str + request_bytes: int + account_digest: str + generation: int + revision: int + issued_epoch: int + expires_epoch: int + endpoint: str = _ENDPOINT + preview_digest: str = '' + + +def _preview_digest(preview: Preview) -> str: + # An unkeyed consistency checksum, not a signature or authorization token. + value = asdict(preview) + del value['preview_digest'] + return journal._digest(value) + + +def _now() -> int: + try: + value = time.time() + if type(value) not in (int, float) or not 1 <= value <= journal.MAX_EPOCH_SECONDS: + raise AdmissionError('clock_uncertain') + return int(value) + except (OSError, TypeError, ValueError, OverflowError): + raise AdmissionError('clock_uncertain') from None + + +def _checked(root: Path, binding, contract, request, account, generation, revision, expires_epoch, now): + if not _valid_account(account): + raise AdmissionError('invalid_account_reference') + if type(generation) is not int or not 0 < generation <= 64: + raise AdmissionError('invalid_generation') + if not journal._integer(revision): + raise AdmissionError('invalid_revision') + if type(expires_epoch) is not int or not 0 < expires_epoch <= journal.MAX_EPOCH_SECONDS: + raise AdmissionError('invalid_expiry') + try: + directory = journal.fsx.open_dir(root) + try: + root_info = journal.fsx.fstat_dir(directory) + root_digest = journal._digest([root_info.st_dev, root_info.st_ino]) + finally: + journal.fsx.close_dir(directory) + policy, policy_digest = journal._policy(root) + bound = journal._binding(binding) + if type(contract) is not requests.Contract or type(request) is not requests.PreparedRequest: + raise AdmissionError('invalid_request') + if (type(request.body) is not bytes or len(request.body) > _MAX_REQUEST_BYTES + or type(request.reserved_cost_micros) is not int): + raise AdmissionError('invalid_request') + data = json.loads(request.body) + if not isinstance(data, dict) or set(data) != {'input', 'model', 'max_output_tokens', 'store'}: + raise AdmissionError('invalid_request') + checked = requests.prepare(contract, data['input']) + if checked.body != request.body or checked.reserved_cost_micros != request.reserved_cost_micros: + raise AdmissionError('invalid_request') + if generation > len(policy.candidates): + raise AdmissionError('candidate_denied') + candidate = policy.candidates[generation - 1] + if (candidate.provider != 'openai' or candidate.provider != account.provider + or candidate.account_ref != account.account_ref or contract.account_ref != account.account_ref + or candidate.model != contract.model): + raise AdmissionError('candidate_denied') + if (now >= expires_epoch or expires_epoch > contract.expires_epoch + or expires_epoch > now + int(policy.bounds.deadline_seconds)): + raise AdmissionError('expired_or_unbounded') + directory = journal.fsx.open_dir(root) + try: + after = journal.fsx.fstat_dir(directory) + if (after.st_dev, after.st_ino) != (root_info.st_dev, root_info.st_ino): + raise AdmissionError('root_changed') + finally: + journal.fsx.close_dir(directory) + return (root_digest, journal._digest(bound), journal._digest(asdict(contract)), policy_digest, + hashlib.sha256(request.body).hexdigest(), len(request.body), journal._digest(asdict(account))) + except (journal.JournalError, requests.RequestError, OSError, KeyError, TypeError, ValueError, UnicodeError, RecursionError): + raise AdmissionError('preflight_denied') from None + + +def prepare_preview(root: Path, binding, contract, request, account, generation: int, + expires_epoch: int, *, revision: int) -> Preview: + """Capture references for review; generation/revision are not journal claims.""" + now = _now() + values = _checked(root, binding, contract, request, account, generation, revision, expires_epoch, now) + preview = Preview(*values, generation, revision, now, expires_epoch) + return replace(preview, preview_digest=_preview_digest(preview)) + + +def recheck_preview(root: Path, preview, binding, contract, request, account, generation: int, *, revision: int) -> dict: + """Recheck this private observation; success never permits dispatch.""" + if (type(preview) is not Preview or type(preview.endpoint) is not str or preview.endpoint != _ENDPOINT + or any(not journal._integer(v, journal.MAX_EPOCH_SECONDS) + for v in (preview.issued_epoch, preview.expires_epoch)) + or not journal._integer(preview.generation, 64) + or not journal._integer(preview.request_bytes, _MAX_REQUEST_BYTES) + or not journal._integer(preview.revision) + or preview.issued_epoch >= preview.expires_epoch + or any(type(v) is not str or not re.fullmatch(r'[a-f0-9]{64}', v) + for v in (preview.root_digest, preview.binding_digest, preview.contract_digest, preview.policy_digest, + preview.request_digest, preview.account_digest, preview.preview_digest))): + raise AdmissionError('invalid_preview') + if _preview_digest(preview) != preview.preview_digest: + raise AdmissionError('preview_changed') + now = _now() + if now < preview.issued_epoch or now >= preview.expires_epoch: + raise AdmissionError('expired_or_clock_uncertain') + values = _checked(root, binding, contract, request, account, generation, revision, preview.expires_epoch, now) + expected = (preview.root_digest, preview.binding_digest, preview.contract_digest, preview.policy_digest, + preview.request_digest, preview.request_bytes, preview.account_digest) + if values != expected or generation != preview.generation or revision != preview.revision: + raise AdmissionError('preview_changed') + return {'state': 'ready_for_review', 'dispatch_permitted': False, 'account_identity_verified': False} diff --git a/architecture/system.yaml b/architecture/system.yaml index 4a76d35..6d59f17 100644 --- a/architecture/system.yaml +++ b/architecture/system.yaml @@ -2209,6 +2209,7 @@ ".codex", ".gemini", ".getzilla/getzilla", + ".getzilla/getzilla/model_data_admission.py", ".getzilla/templates", ".grok", ".qwen" diff --git a/docs/model-continuation-journal.md b/docs/model-continuation-journal.md index a3f33e7..c13f57f 100644 --- a/docs/model-continuation-journal.md +++ b/docs/model-continuation-journal.md @@ -38,6 +38,16 @@ The optional `codex_continuation.start_offline_container` entry point starts the Disable the policy to stop reservation. Preserve the journal and unknown outcomes. Revert source through a reviewed PR if needed; never delete a ledger to regain attempts or money. No live dispatch path exists to roll back in T3. +## Offline exact-request preview + +`getzilla.model_data_admission` provides an in-memory, non-authorizing preview. It reuses `openai_model_request.prepare` to check the exact canonical text-only request. It binds the request bytes and length, fixed `https://api.openai.com/v1/responses` destination, account reference/handle/version metadata, contract, policy, existing `Binding`, caller-supplied candidate generation and journal revision, observed root-directory identity, issue time and expiry. Rechecking refuses changed fields, expiry, backward clock observations, invalid clocks and revoked policy. Errors and status contain fixed codes, not request text or account handles. + +The four account-reference fields and preview endpoint/digest fields must be built-in strings. Account validation checks those fields directly; it rejects containers and string subclasses before copying. Preview recheck rejects string subclasses before computing its consistency checksum. + +The result is only `ready_for_review`, with `dispatch_permitted:false` and `account_identity_verified:false`. The unique account-metadata mapping does not verify credential ownership. Task/source/checkpoint digests and generation/revision are caller references, not current source, journal, grant or native-provenance evidence. The private preview checksum detects independent field changes but is unkeyed and reproducible; it proves no authenticity or consent. Root identity is a point-in-time directory observation, not a complete workspace snapshot or lock. The request digest can disclose low-entropy input through guessing, so keep the preview and its digests private. + +This helper reads no credential, opens no provider connection, creates no grant or accounting claim, and changes no journal or metrics record. `openai_model_request.dispatch` still raises `data_admission_unavailable` before claim consumption. Later live work must reuse the existing grants, source inventory, secret scanning and journal contracts to establish exact payload/source-audience permission, recipient/account ownership, current revocation checks and one qualified runtime row. A scanner result or this preview cannot supply those facts. All live adapter rows remain unqualified and issue235 remains open. Roll back this helper through a reviewed revert; the existing dispatch refusal remains in force. + ## Controlled local fixture `getzilla.controlled_fixture.run` exercises only a synthetic two-model Codex session. It requires a pinned local Docker image and an exact Codex 0.165.0 binary. The image is not downloaded. The host copies the fixture script and binary into a new local image and verifies both hashes inside that image before use. The inspected execution container has no bind mounts. It uses `network=none`, read-only root, a non-root user, dropped capabilities and bounded resources. It receives no host journal, credentials or user input. This assumes the local Docker daemon and pinned base image are trusted. diff --git a/tests/test_model_data_admission.py b/tests/test_model_data_admission.py new file mode 100644 index 0000000..3e288d5 --- /dev/null +++ b/tests/test_model_data_admission.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from contextlib import ExitStack +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / '.getzilla')) +from getzilla import model_data_admission as admission +from getzilla import model_continuation_state as journal +from getzilla import openai_model_request as requests +from getzilla import metrics_journal, state +from tests.test_model_continuation import enabled_policy + + +class ModelDataAdmissionTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / 'repository' + self.config = self.root / '.getzilla/config/model-continuation.json' + self.config.parent.mkdir(parents=True) + self.policy = enabled_policy() + self.policy['candidates'][0]['provider'] = 'openai' + self.write_policy() + self.binding = journal.Binding('chain1', 'task1', 'owner1', 'route1', 'session1', + 'a' * 64, 'b' * 64, 'c' * 64) + self.contract = requests.Contract('model-a', 'work', 128, 1000, 100, + '2', '10', 'rates1', 2000) + self.request = requests.prepare(self.contract, 'private-text-canary') + self.account = admission.AccountReference('openai', 'work', 'private-handle-canary', 'version-1') + clock_patch = patch.object(admission.time, 'time', return_value=1000) + self.clock = clock_patch.start() + self.addCleanup(clock_patch.stop) + + def write_policy(self): + self.config.write_text(json.dumps(self.policy), encoding='utf-8') + + def prepare(self, **changes): + inputs = dict(root=self.root, binding=self.binding, contract=self.contract, + request=self.request, account=self.account, generation=1, + revision=1, expires_epoch=1100) + return admission.prepare_preview(**(inputs | changes)) + + def recheck(self, preview, **changes): + inputs = dict(root=self.root, preview=preview, binding=self.binding, contract=self.contract, + request=self.request, account=self.account, generation=1, revision=1) + return admission.recheck_preview(**(inputs | changes)) + + def test_valid_preview_is_non_authorizing_and_private(self): + preview = self.prepare() + result = self.recheck(preview) + self.assertEqual(result, {'state': 'ready_for_review', 'dispatch_permitted': False, + 'account_identity_verified': False}) + self.assertEqual(preview.endpoint, 'https://api.openai.com/v1/responses') + self.assertEqual(preview.request_bytes, len(self.request.body)) + self.assertEqual(preview.revision, 1) + self.assertEqual(preview.issued_epoch, 1000) + self.assertEqual(preview.expires_epoch, 1100) + public = repr(preview) + repr(self.account) + repr(result) + for private in ('private-text-canary', 'private-handle-canary', preview.request_digest): + self.assertNotIn(private, public) + self.assertFalse((self.root / '.getzilla/runtime').exists()) + + def test_each_binding_field_is_rechecked(self): + preview = self.prepare() + for field, value in (('chain_id', 'chain2'), ('task_id', 'task2'), + ('owner_id', 'owner2'), ('route_id', 'route2'), + ('session_id', 'session2'), ('workspace_digest', 'd' * 64), + ('source_digest', 'e' * 64), ('checkpoint_digest', 'f' * 64)): + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.recheck(preview, binding=replace(self.binding, **{field: value})) + self.assertEqual(self.recheck(preview)['state'], 'ready_for_review') + + def test_request_bytes_and_every_contract_field_are_bound(self): + preview = self.prepare() + with self.assertRaises(admission.AdmissionError): + self.recheck(preview, request=requests.prepare(self.contract, 'changed-private-text')) + fields = (('model', 'model-b'), ('account_ref', 'other'), ('max_input_bytes', 256), + ('max_input_tokens', 1001), ('max_output_tokens', 101), + ('input_usd_per_million', '3'), ('output_usd_per_million', '11'), + ('rate_revision', 'rates2'), ('expires_epoch', 2001), ('schema_version', 2)) + for field, value in fields: + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.recheck(preview, contract=replace(self.contract, **{field: value})) + + def test_every_account_field_and_generation_are_bound(self): + preview = self.prepare() + for field, value in (('provider', 'other'), ('account_ref', 'other'), + ('handle', 'other-handle'), ('version', 'version-2')): + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.recheck(preview, account=replace(self.account, **{field: value})) + with self.assertRaises(admission.AdmissionError): + self.recheck(preview, generation=2) + + def test_revision_reference_is_bound_without_reading_a_journal(self): + preview = self.prepare(revision=7) + self.assertEqual(self.recheck(preview, revision=7)['state'], 'ready_for_review') + with self.assertRaises(admission.AdmissionError): + self.recheck(preview, revision=8) + self.assertFalse((self.root / '.getzilla/runtime').exists()) + + def test_each_preview_field_rejects_independent_mutation(self): + preview = self.prepare() + for field, value in (('root_digest', '0' * 64), ('binding_digest', '0' * 64), + ('contract_digest', '0' * 64), ('policy_digest', '0' * 64), + ('request_digest', '0' * 64), ('request_bytes', 1), + ('account_digest', '0' * 64), ('generation', 2), ('revision', 2), + ('issued_epoch', 999), ('expires_epoch', 1110), + ('endpoint', 'https://other.invalid/v1/responses'), + ('preview_digest', '0' * 64)): + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.recheck(replace(preview, **{field: value})) + + def test_malformed_preview_never_exposes_private_values(self): + preview = self.prepare() + invalid = [None, object()] + for field in ('issued_epoch', 'expires_epoch', 'request_bytes', 'generation', 'revision'): + invalid.extend(replace(preview, **{field: value}) + for value in (True, False, 0, -1, 'private-clock-canary')) + for field in ('root_digest', 'binding_digest', 'contract_digest', 'policy_digest', + 'request_digest', 'account_digest', 'preview_digest'): + invalid.extend(replace(preview, **{field: value}) for value in (None, True, 'private-digest-canary')) + for index, changed in enumerate(invalid): + with self.subTest(index=index), self.assertRaises(admission.AdmissionError) as caught: + self.recheck(changed) + self.assertEqual(str(caught.exception), 'invalid_preview') + + def test_account_resolution_requires_unique_exact_metadata(self): + other = replace(self.account, account_ref='other', handle='other-handle') + resolved = admission.resolve_account('openai', 'work', (self.account, other)) + self.assertEqual(resolved.reference, self.account) + self.assertFalse(resolved.account_identity_verified) + self.assertNotIn('private-handle-canary', repr(resolved)) + with self.assertRaises(TypeError): + admission.AccountResolution(self.account, True) + for records in ((), [], None, (other,), (self.account, self.account), + (self.account, replace(self.account, version='version-2')), + (replace(self.account, handle='sk-secret-canary'),), + (self.account,) * 65, (None,)): + with self.subTest(records_count=len(records) if isinstance(records, (tuple, list)) else None): + with self.assertRaises(admission.AdmissionError) as caught: + admission.resolve_account('openai', 'work', records) + self.assertIn(str(caught.exception), ('account_mapping_invalid', 'account_mapping_ambiguous')) + + def test_missing_or_malformed_account_cannot_prepare(self): + invalid = [None, {}, admission.AccountResolution(self.account)] + for field in ('provider', 'account_ref', 'handle', 'version'): + invalid.extend(replace(self.account, **{field: value}) + for value in ('', 'sk-secret-canary', 'a' * 65, True, 'private\ntext')) + for index, account in enumerate(invalid): + with self.subTest(index=index), self.assertRaisesRegex(admission.AdmissionError, '^invalid_account_reference$'): + self.prepare(account=account) + + def test_structured_account_fields_are_refused_without_copying(self): + copied = [] + + class CopyTrap: + def __deepcopy__(self, memo): + copied.append('copy') + raise AssertionError('account value must not be copied') + + class StringCopyTrap(str, CopyTrap): + pass + + cycle = [] + cycle.append(cycle) + preview = self.prepare() + original = replace(preview) + operations = ( + ('resolve', lambda value: admission.resolve_account('openai', 'work', (value,)), + 'account_mapping_invalid'), + ('prepare', lambda value: self.prepare(account=value), 'invalid_account_reference'), + ('recheck', lambda value: self.recheck(preview, account=value), 'invalid_account_reference'), + ) + for field in ('provider', 'account_ref', 'handle', 'version'): + malformed = (('cycle', cycle), ('copy_hook', CopyTrap()), + ('string_subclass', StringCopyTrap(getattr(self.account, field)))) + for case, value in malformed: + account = replace(self.account, **{field: value}) + for name, operation, refusal in operations: + with self.subTest(field=field, case=case, operation=name): + copied.clear() + with self.assertRaisesRegex(admission.AdmissionError, '^' + refusal + '$'): + operation(account) + self.assertEqual(copied, []) + self.assertEqual(preview, original) + self.assertEqual(self.recheck(preview), {'state': 'ready_for_review', 'dispatch_permitted': False, + 'account_identity_verified': False}) + + def test_preview_string_subclasses_are_refused_without_copying(self): + copied = [] + + class StringCopyTrap(str): + def __deepcopy__(self, memo): + copied.append('copy') + raise AssertionError('preview value must not be copied') + + preview = self.prepare() + for field in ('endpoint', 'root_digest', 'binding_digest', 'contract_digest', 'policy_digest', + 'request_digest', 'account_digest', 'preview_digest'): + changed = replace(preview, **{field: StringCopyTrap(getattr(preview, field))}) + with self.subTest(field=field): + copied.clear() + with self.assertRaisesRegex(admission.AdmissionError, '^invalid_preview$'): + self.recheck(changed) + self.assertEqual(copied, []) + self.assertFalse(self.recheck(preview)['dispatch_permitted']) + + def test_malformed_and_noncanonical_requests_are_denied(self): + body = self.request.body + invalid = (b'', b'{private-invalid-canary', b'\xff', b'[]', b'null', + body + b' ', body.replace(b'"store":false', b'"store":true'), + body.replace(b'"model":"model-a"', b'"model":"model-a","model":"model-a"'), + body.replace(b'"max_output_tokens":100', b'"max_output_tokens":100.0'), + body.replace(b'"max_output_tokens":100', b'"max_output_tokens":true'), + body.replace(b'"input":"private-text-canary"', b'"input":null'), + body.replace(b'"store":false', b'"store":false,"extra":"private-extra-canary"'), + body.decode().encode('utf-16'), b' ' * 4_000_001, + b'[' * 2000 + b'0' + b']' * 2000) + for index, malformed in enumerate(invalid): + with self.subTest(index=index), self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.prepare(request=replace(self.request, body=malformed)) + for malformed in (self.request.body.decode(), bytearray(body), None): + with self.subTest(type=type(malformed).__name__), self.assertRaises(admission.AdmissionError): + self.prepare(request=replace(self.request, body=malformed)) + self.assertEqual(self.recheck(self.prepare())['state'], 'ready_for_review') + + def test_reserved_cost_requires_an_integer_even_when_true_equals_one(self): + contract = replace(self.contract, max_input_tokens=1, max_output_tokens=1, + input_usd_per_million='0.1', output_usd_per_million='0.1') + request = requests.prepare(contract, 'private-text-canary') + self.assertEqual(request.reserved_cost_micros, 1) + preview = self.prepare(contract=contract, request=request) + self.assertEqual(self.recheck(preview, contract=contract, request=request)['state'], 'ready_for_review') + for value in (True, 1.0, '1', 0, -1, None): + with self.subTest(value=value), self.assertRaises(admission.AdmissionError): + self.prepare(contract=contract, request=replace(request, reserved_cost_micros=value)) + + def test_invalid_numeric_and_binding_inputs_fail_closed(self): + for field in ('generation', 'revision', 'expires_epoch'): + for value in (True, False, 0, -1, 1.0, 'private-number-canary', None): + with self.subTest(field=field, value=value), self.assertRaises(admission.AdmissionError): + self.prepare(**{field: value}) + for field, value in (('generation', 65), ('revision', 1_000_000_001), + ('expires_epoch', journal.MAX_EPOCH_SECONDS + 1)): + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.prepare(**{field: value}) + for field in ('max_input_bytes', 'max_input_tokens', 'max_output_tokens', 'expires_epoch', 'schema_version'): + with self.subTest(field=field), self.assertRaises(admission.AdmissionError): + self.prepare(contract=replace(self.contract, **{field: True})) + for binding in (None, {}, replace(self.binding, chain_id=True), replace(self.binding, source_digest='private-source-canary')): + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.prepare(binding=binding) + + def test_expiry_is_bounded_by_contract_and_policy(self): + self.assertEqual(self.prepare(expires_epoch=1120).expires_epoch, 1120) + for expiry in (1000, 999, 1121, 1500, 2001): + with self.subTest(expiry=expiry), self.assertRaises(admission.AdmissionError): + self.prepare(expires_epoch=expiry) + with self.assertRaises(admission.AdmissionError): + self.prepare(contract=replace(self.contract, expires_epoch=1099)) + + def test_recheck_expires_and_refuses_clock_rollback(self): + preview = self.prepare() + self.clock.return_value = 1099 + self.assertEqual(self.recheck(preview)['state'], 'ready_for_review') + for now in (999, 1100, 1101): + self.clock.return_value = now + with self.subTest(now=now), self.assertRaisesRegex(admission.AdmissionError, '^expired_or_clock_uncertain$'): + self.recheck(preview) + + def test_invalid_clock_is_a_fixed_private_free_error(self): + preview = self.prepare() + for now in (True, False, 0, -1, float('nan'), float('inf'), 'private-clock-canary', None): + self.clock.return_value = now + for operation in (self.prepare, lambda: self.recheck(preview)): + with self.subTest(now=now), self.assertRaisesRegex(admission.AdmissionError, '^clock_uncertain$'): + operation() + + def test_changed_or_revoked_policy_denies_recheck(self): + preview = self.prepare() + self.policy['bounds']['deadline_seconds'] = 119 + self.write_policy() + with self.assertRaisesRegex(admission.AdmissionError, '^preview_changed$'): + self.recheck(preview) + self.policy['enabled'] = False + self.write_policy() + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.recheck(preview) + self.config.write_bytes(b'{"private-policy-canary":') + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.recheck(preview) + self.config.unlink() + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.recheck(preview) + + def test_root_replacement_denies_recheck(self): + preview = self.prepare() + self.root.rename(self.root.with_name('retained-root')) + self.config.parent.mkdir(parents=True) + self.write_policy() + with self.assertRaisesRegex(admission.AdmissionError, '^preview_changed$'): + self.recheck(preview) + + def test_root_change_during_policy_read_is_denied(self): + real_policy = journal._policy + + def replace_after_read(root): + result = real_policy(root) + self.root.rename(self.root.with_name('retained-root')) + self.config.parent.mkdir(parents=True) + self.write_policy() + return result + + with patch.object(journal, '_policy', side_effect=replace_after_read): + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.prepare() + + def test_root_change_during_request_validation_is_denied(self): + real_prepare = requests.prepare + + def replace_after_validation(contract, text): + result = real_prepare(contract, text) + self.root.rename(self.root.with_name('retained-root')) + self.config.parent.mkdir(parents=True) + self.write_policy() + return result + + with patch.object(requests, 'prepare', side_effect=replace_after_validation): + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$'): + self.prepare() + + def test_private_filesystem_failures_are_sanitized(self): + with patch.object(journal.fsx, 'open_dir', side_effect=OSError('private-path-canary')): + with self.assertRaisesRegex(admission.AdmissionError, '^preflight_denied$') as caught: + self.prepare() + self.assertIsNone(caught.exception.__cause__) + + @unittest.skipIf(sys.platform == 'win32', 'directory fsync is unsupported on Windows') + def test_preview_and_denial_leave_budget_and_runtime_unchanged(self): + initial = journal.create_model_only_chain(self.root, self.binding, self.contract, + self.request, journal.TaskBudget(2, 4, '1.5', 2000)) + before = journal.inspect_chain(self.root, self.binding) + runtime = self.root / '.getzilla/runtime' + files_before = {p.relative_to(runtime).as_posix(): p.read_bytes() + for p in runtime.rglob('*') if p.is_file()} + real_open_at = journal.fsx.open_at + + def policy_only(parent, name, flags, *args, **kwargs): + if name != 'model-continuation.json': + raise AssertionError('unexpected file read or write') + return real_open_at(parent, name, flags, *args, **kwargs) + + with ExitStack() as guards: + for target in ('socket.socket', 'http.client.HTTPSConnection', 'subprocess.Popen', + 'builtins.open', 'pathlib.Path.open'): + guards.enter_context(patch(target, side_effect=AssertionError('unexpected external or file operation'))) + for module, method in ((journal, '_store'), (state, 'add_approval'), (metrics_journal, 'record')): + guards.enter_context(patch.object(module, method, side_effect=AssertionError('unexpected state mutation'))) + guards.enter_context(patch.object(journal.fsx, 'open_at', side_effect=policy_only)) + preview = self.prepare(revision=initial['revision']) + self.assertFalse(self.recheck(preview, revision=initial['revision'])['dispatch_permitted']) + with self.assertRaises(admission.AdmissionError): + self.recheck(preview, request=requests.prepare(self.contract, 'changed')) + with self.assertRaisesRegex(requests.RequestError, '^data_admission_unavailable$'): + requests.dispatch(self.root, self.binding, self.contract, self.request, + 1, initial['revision'], object()) + self.assertEqual(journal.inspect_chain(self.root, self.binding), before) + self.assertEqual({p.relative_to(runtime).as_posix(): p.read_bytes() + for p in runtime.rglob('*') if p.is_file()}, files_before) + self.assertEqual(before['attempts'][0]['state'], 'reserved_unknown') + self.assertEqual(before['attempts'][0]['cost_micros'], 3000) + + +if __name__ == '__main__': + unittest.main()