From 3fbd145c5c36cc7c30b164ed8b3e94919441abf1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:29:45 +0000 Subject: [PATCH 1/6] Constrain guest work to the original parent clock budget --- .../acceptance/colima-window/test_window.py | 4 +- .../acceptance/condition-history/README.md | 44 ++++-- .../acceptance/condition-history/engine.py | 3 +- .../condition-history/guest-host.py | 81 ++++++++++- .../condition-history/guest-proposal.md | 29 +++- .../acceptance/condition-history/recover.py | 80 +++++++++-- .../acceptance/condition-history/resources.py | 2 +- .../acceptance/condition-history/subject.py | 10 +- .../condition-history/test_audit.py | 9 +- .../condition-history/test_guest_host.py | 129 +++++++++++++++++- .../condition-history/test_packet.py | 118 +++++++++++++++- .../condition-history/test_resources.py | 17 ++- .../condition-history/test_subject.py | 20 ++- 13 files changed, 497 insertions(+), 49 deletions(-) diff --git a/scripts/acceptance/colima-window/test_window.py b/scripts/acceptance/colima-window/test_window.py index 0036764..3fff431 100644 --- a/scripts/acceptance/colima-window/test_window.py +++ b/scripts/acceptance/colima-window/test_window.py @@ -6,6 +6,7 @@ import pathlib import plistlib import shutil +import subprocess import sys import tempfile import time @@ -16,7 +17,8 @@ spec = importlib.util.spec_from_file_location('colima_window_controls', HERE / 'window.py') window = importlib.util.module_from_spec(spec) spec.loader.exec_module(window) -CODE = (HERE.parent / 'condition-history/guest-host.py').read_text() +CODE = subprocess.check_output(['git', '--no-replace-objects', '-C', str(HERE.parents[2]), + 'show', window.SDK_R + ':scripts/acceptance/condition-history/guest-host.py'], text=True, timeout=20) SOURCE = window.sha((HERE / 'window.py').read_bytes()) TOKEN = '0123456789abcdef' BOOT = '11111111-1111-1111-1111-111111111111' diff --git a/scripts/acceptance/condition-history/README.md b/scripts/acceptance/condition-history/README.md index 2fe3df7..6006054 100644 --- a/scripts/acceptance/condition-history/README.md +++ b/scripts/acceptance/condition-history/README.md @@ -6,6 +6,11 @@ It is source under review. It has not built its consumer or run a container. Server history review and Security executable review precede a separate General admission of the exact runner commit, command, fresh capacity and absolute deadline. +The original `df617fb` / `bdbb4d4` private proposal remains frozen and unexecuted. +This clock successor is a new source/input subject; its R/M and host guardian +identity must be captured independently. Source integration does not select its +lifecycle, guest launch, consumer build, publication or installed execution. + The package is crates.io `durable-workflow =4.12.0`, archive SHA-256 `620a8287af73c363dac61f0a91f20971a39f3b8cdc9513fbea85cdbd95a74ba4`, source `4b2440a5618ccc1c98960ea2e1e62127042c3590` (H). Its published tag, @@ -88,12 +93,21 @@ daemon. Before work creation or registry requests, read-only preflight refuses any existing owner/run container, network or volume and every colliding exact name. An uncertain query is a refusal. -One 30-minute absolute owner window includes setup, fetch, compilation, execution -and cleanup. Active work ends 60 seconds before the final ceiling. The Cell's -roles share one maximum 600-second deadline plus a 10-second TERM/KILL grace. +The unchanged 30-minute parent window includes setup, fetch, compilation, +execution and cleanup. The guest uses a fresh start G inside that parent lease, +not a renewed 30-minute envelope. A resident-parent clock receipt maps the +parent's original monotonic deadline conservatively into the guest clock. +Guest active is `min(G+1740, mapped_parent_active-180)`; guest cleanup is active +plus 60 seconds, leaving at least 120 seconds before parent active for the +independent guest audit/export. Admission requires at least 600 active work +seconds still available. Late setup refuses before store allocation, VM +create/start and guest work allocation; it does not extend any parent cutoff. +The Cell's roles share one maximum 600-second deadline plus a 10-second +TERM/KILL grace. `run.json` is an exclusive owner-only regular single-link record binding R, H, manifest and recovery digests, token, local daemon, root, UID/GID, isolated owner -PID/group/session, Linux boot ID and kernel start ticks, and all three deadlines. +PID/group/session, Linux boot ID and kernel start ticks, the parent clock receipt, +and all three deadlines plus the derived fixed guest monotonic cutoffs. Execution requires Linux proc authority. Identity observations must agree across two kernel-stat reads and boot reads; recovery revalidates that tuple immediately before each TERM or KILL. A missing or changed authority refuses the signal. @@ -101,8 +115,8 @@ Its digest is captured outside the work root in the private launch handoff. Before any pull, the owner starts a detached recovery process from captured -inline bytes and requires its exact readiness receipt. At the active cutoff it -uses its captured complete authority to stop the exact owner and remove only +inline bytes and requires its exact readiness receipt. At the fixed guest +monotonic active cutoff it uses its captured complete authority to stop the exact owner and remove only matching immutable container/network IDs. A later source/record drift fails the result but cannot replace the already-captured cleanup authority. Foreign process epochs, labels or names are refused. Interrupted Cell evidence is purged because @@ -111,9 +125,15 @@ bounded by the final ceiling. No edit, retry, fallback or extension is included. After source review and a separate exact execution admission, use the reviewed inline collector below. `R`, `M`, `CHECKOUT`, fresh `W`, token `T`, local daemon -`D`, and captured `S`/`ACTIVE`/`CLEANUP` belong in the private proposal. `ACTIVE` -is `S + 1740` and `CLEANUP` is `S + 1800`. Do not substitute a mutable script path -for the captured entry point. +`D`, and captured `S`/`ACTIVE`/`CLEANUP` belong in the private proposal. The CLI's +`S` is fresh guest G. `ACTIVE` and `CLEANUP` follow +the constrained formula above; `CLOCK_JSON`/`CLOCK_SHA` are the resident guardian's +exclusive `guest-clock.json` and its canonical SHA-256. They bind the host record, +both boot IDs, original parent Unix/monotonic ceilings and a bounded paired +host/guest clock observation. The response-side remaining parent duration and +rounding toward an earlier deadline account for transport uncertainty. They do +not assume equal host/guest clocks or reopen a lease after a wall-clock change. +Do not substitute a mutable script path for the captured entry point. ```sh python3 -I -S -c ' @@ -131,6 +151,7 @@ raise SystemExit(subprocess.run([sys.executable, "-I", "-S", "-c", entry, --package-commit 4b2440a5618ccc1c98960ea2e1e62127042c3590 \ --work-dir W --run-token T --docker-endpoint D \ --guest-boot-id B \ + --parent-clock-json CLOCK_JSON --parent-clock-sha256 CLOCK_SHA \ --started-unix S --active-unix ACTIVE --cleanup-unix CLEANUP ``` @@ -148,6 +169,11 @@ For immediate recovery or the mandatory independent final audit, capture `--run-token`. Audit also requires the externally captured `--summary-sha256`. Standalone recovery does not depend on a mutable checkout entry point. +Recovery records use schema `condition-run/v5`, binding the same parent clock +and derived monotonic deadlines through recovery, summary, guest audit and the +independent host audit. Command timeouts and recovery wakeups use those fixed +monotonic deadlines; changing UTC cannot grant more time. + The owner writes `summary.json` with `O_EXCL|O_NOFOLLOW`, mode 0600, and returns its immutable SHA-256. It records package/source/lock/binary inputs, exact role readbacks and fresh typed replay/live results. The fetch transport is the public diff --git a/scripts/acceptance/condition-history/engine.py b/scripts/acceptance/condition-history/engine.py index 3eea9b8..798969b 100644 --- a/scripts/acceptance/condition-history/engine.py +++ b/scripts/acceptance/condition-history/engine.py @@ -259,7 +259,8 @@ def execute(args): watchdog, recovery_process_sha = start_recovery(args, record) result = {'outcome': 'fail', 'checks_outcome': 'fail', 'record_sha256': subject.RECORD_SHA, 'runner_commit': args.runner_commit, 'package_commit': H, 'manifest_sha256': subject.CURRENT.sha256, - 'recovery_process_sha256': recovery_process_sha, 'storage': record['storage']} + 'recovery_process_sha256': recovery_process_sha, 'storage': record['storage'], + 'parent_clock': record['parent_clock']} try: registry = registry_identity() images = [RUST, SERVER, MYSQL, REDIS] diff --git a/scripts/acceptance/condition-history/guest-host.py b/scripts/acceptance/condition-history/guest-host.py index cdd4df6..4b9a150 100644 --- a/scripts/acceptance/condition-history/guest-host.py +++ b/scripts/acceptance/condition-history/guest-host.py @@ -29,12 +29,15 @@ GUEST_BYTES = 20 * 1024**3 MEMORY_BYTES = 6 * 1024**3 PROFILE_SHA = 'fd12240c0a04fbe0614901038586a9cc411f154e11a56d6d1fcd3f4f045f0516' +MIN_WORK_RESERVE = 600 + 180 AUTHORITY_KEYS = {'schema', 'token', 'runner_commit', 'manifest_sha256', 'guardian_sha256', 'started_unix', 'active_unix', 'cleanup_unix', 'parent', 'limactl', 'guest_agent', 'host_boot_id', 'image_dev', 'image_ino', 'whole_device', - 'volume_uuid', 'profile_sha256', 'profile', 'guest_image', 'guest_agent_binary_sha256'} + 'volume_uuid', 'profile_sha256', 'profile', 'guest_image', 'guest_agent_binary_sha256', + 'active_monotonic_ns', 'cleanup_monotonic_ns'} INTENT_KEYS = (AUTHORITY_KEYS - {'image_dev', 'image_ino', 'whole_device', 'volume_uuid', - 'profile_sha256', 'profile', 'guest_image', 'guest_agent_binary_sha256'}) | {'cached_image'} + 'profile_sha256', 'profile', 'guest_image', 'guest_agent_binary_sha256', + 'active_monotonic_ns', 'cleanup_monotonic_ns'}) | {'cached_image'} def require(ok, label): @@ -208,7 +211,7 @@ def authority(path, expected_sha, source_sha): raw = regular(path, 64 * 1024) require(digest(raw) == expected_sha, 'host authority digest mismatch') value = json.loads(raw) - require(set(value) == AUTHORITY_KEYS and value['schema'] == 'durable-workflow.condition-host/v1', 'wrong host authority schema') + require(set(value) == AUTHORITY_KEYS and value['schema'] == 'durable-workflow.condition-host/v2', 'wrong host authority schema') require(value['guardian_sha256'] == source_sha and re.fullmatch('[0-9a-f]{40}', value['runner_commit']) and re.fullmatch('[0-9a-f]{64}', value['manifest_sha256']), 'wrong captured host source') require(all(type(value[k]) is int for k in ('started_unix', 'active_unix', 'cleanup_unix', 'image_dev', 'image_ino')) @@ -233,8 +236,12 @@ def authority(path, expected_sha, source_sha): require(re.fullmatch('/dev/disk[0-9]+', value['whole_device']) and re.fullmatch('[0-9A-Fa-f-]{36}', value['volume_uuid']) and re.fullmatch('[0-9a-f]{64}', value['profile_sha256']), 'invalid native store authority') - value['_cleanup_mono'] = time.monotonic() + max(0, value['cleanup_unix'] - time.time()) - value['_active_mono'] = time.monotonic() + max(0, value['active_unix'] - time.time()) + require(type(value['active_monotonic_ns']) is int and value['active_monotonic_ns'] > 0 + and type(value['cleanup_monotonic_ns']) is int + and value['cleanup_monotonic_ns'] == value['active_monotonic_ns'] + 60_000_000_000, + 'wrong fixed host monotonic ceilings') + value['_cleanup_mono'] = value['cleanup_monotonic_ns'] / 1_000_000_000 + value['_active_mono'] = value['active_monotonic_ns'] / 1_000_000_000 return value @@ -402,6 +409,11 @@ def capacity(record): return {'available_bytes': available, 'free_disk_bytes': free, 'os': version, 'build': build} +def require_work_reserve(record): + require(record['_active_mono'] - time.monotonic() >= MIN_WORK_RESERVE, + 'minimum guest work/audit reserve consumed before allocation or launch') + + def observe_attachment(record): scoped = paths(record['parent'], record['token']) entries = plistlib.loads(call(record, ['/usr/bin/hdiutil', 'info', '-plist']))['images'] @@ -436,6 +448,10 @@ def remove_store(record): def prepare(record, template): """Resident watchdog owns setup before the first disk mutation. No VM start.""" scoped = paths(record['parent'], record['token']) + active = min(int(record['_active_mono'] * 1_000_000_000), + int(record['_cleanup_mono'] * 1_000_000_000) - 60_000_000_000) + record.update(active_monotonic_ns=active, cleanup_monotonic_ns=active + 60_000_000_000) + require_work_reserve(record) proposal = plan(record['parent'], record['token'], record['limactl'], str(scoped['mount'] / 'a/guest.raw.gz'), template) observed = capacity(record) @@ -443,6 +459,7 @@ def prepare(record, template): for key, size, sha in (('limactl', 35_175_712, LIMA_SHA), ('guest_agent', 8_257_812, AGENT_SHA), ('cached_image', IMAGE_BYTES, IMAGE_SHA)): input_bytes(record[key], size, sha, deadline=record['_stage_mono']) + require_work_reserve(record) scoped['parent'].mkdir(mode=0o700) record['_setup_tmp'] = str(scoped['parent']) exclusive(scoped['parent'] / 'setup-ready.json', {'guardian_sha256': record['guardian_sha256'], @@ -450,6 +467,7 @@ def prepare(record, template): 'process': process_identity(os.getpid()), 'capacity': observed}) attached = False try: + require_work_reserve(record) record['_command_seconds'] = 180 call(record, proposal['create_store']) info = scoped['image'].lstat() @@ -470,7 +488,7 @@ def prepare(record, template): record['profile_sha256'] = exclusive(scoped['mount'] / 'guest.yaml', proposal['profile']) require(record['profile_sha256'] == proposal['profile_sha256'], 'rendered setup template changed') value = {key: record[key] for key in AUTHORITY_KEYS if key != 'schema'} - value['schema'] = 'durable-workflow.condition-host/v1' + value['schema'] = 'durable-workflow.condition-host/v2' record_sha = exclusive(scoped['parent'] / 'host-record.json', value) verify_store(record) require(time.monotonic() < record['_stage_mono'], 'setup window exhausted') @@ -612,12 +630,14 @@ def launch_guest(record, record_sha): record['_stage_mono'] = record['_active_mono'] record['_command_seconds'] = 180 try: + require_work_reserve(record) call(record, [record['limactl'], '--tty=false', 'create', '--name', scoped['name'], '-'], profile) before = effective_config(record, 'Stopped') exclusive(scoped['parent'] / 'vm-created.json', {'record_sha256': record_sha, 'profile_sha256': digest(profile), 'guest_image': record['guest_image'], 'effective': before}) verify_guest_inputs(record) require(effective_config(record, 'Stopped') == before, 'VM config changed before guardian start') + require_work_reserve(record) record['_command_seconds'] = 300 call(record, [record['limactl'], '--tty=false', 'start', '--timeout', '5m', scoped['name']]) after = effective_config(record, 'Running') @@ -637,6 +657,43 @@ def launch_guest(record, record_sha): for key in ('_stage_mono', '_command_seconds'):record.pop(key, None) +GUEST_CLOCK_PROBE = '''import json,pathlib,time +boot=pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip() +before=time.monotonic_ns();unix=time.time_ns();after=time.monotonic_ns() +if boot!=pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip():raise SystemExit('guest boot changed') +print(json.dumps({'guest_boot_id':boot,'guest_before_monotonic_ns':before,'guest_after_monotonic_ns':after,'guest_unix_ns':unix})) +''' + + +def capture_guest_clock(record, record_sha): + """Only the resident guardian can sample its original monotonic cutoffs.""" + scoped = paths(record['parent'], record['token']) + request = json.loads(regular(scoped['parent'] / 'guest-clock-request.json', 4096)) + require(request == {'record_sha256': record_sha, 'action': 'capture-guest-clock'}, 'wrong clock request') + require_work_reserve(record) + started = json.loads(regular(scoped['parent'] / 'vm-started.json')) + before = time.monotonic_ns() + value = json.loads(call(record, [record['limactl'], 'shell', scoped['name'], '--', + 'python3', '-I', '-S', '-c', GUEST_CLOCK_PROBE])) + after = time.monotonic_ns() + require(set(value) == {'guest_boot_id', 'guest_before_monotonic_ns', 'guest_after_monotonic_ns', 'guest_unix_ns'} + and value['guest_boot_id'] == started['agent']['guest_boot_id'] + and all(type(value[k]) is int and value[k] > 0 for k in value if k != 'guest_boot_id') + and 0 <= value['guest_after_monotonic_ns'] - value['guest_before_monotonic_ns'] <= 5_000_000_000 + and 0 <= after - before <= 5_000_000_000, 'unbounded or changed guest clock sample') + require_work_reserve(record) + # Round toward an earlier deadline and preserve the original cleanup bound. + active = record['active_monotonic_ns'] + clock = dict(value, schema='durable-workflow.condition-parent-clock/v1', + host_record_sha256=record_sha, host_boot_id=record['host_boot_id'], + host_started_unix=record['started_unix'], host_active_unix=record['active_unix'], + host_cleanup_unix=record['cleanup_unix'], host_active_monotonic_ns=active, + host_cleanup_monotonic_ns=active + 60_000_000_000, + host_before_monotonic_ns=before, host_after_monotonic_ns=after) + exclusive(scoped['parent'] / 'guest-clock.json', clock) + return clock + + def cleanup(record): scoped = paths(record['parent'], record['token']) verify_store(record) @@ -679,6 +736,15 @@ def audit(record, record_sha, source_sha): guest = json.loads(regular(scoped['parent'] / 'guest-summary.json')) guest_audit = json.loads(regular(scoped['parent'] / 'guest-audit.json')) started = json.loads(regular(scoped['parent'] / 'vm-started.json')) + clock = json.loads(regular(scoped['parent'] / 'guest-clock.json')) + require(guest.get('parent_clock') == clock and guest_audit.get('parent_clock') == clock + and clock['host_record_sha256'] == record_sha and clock['host_boot_id'] == record['host_boot_id'] + and clock['host_started_unix'] == record['started_unix'] + and clock['host_active_unix'] == record['active_unix'] + and clock['host_cleanup_unix'] == record['cleanup_unix'] + and clock['host_active_monotonic_ns'] == record['active_monotonic_ns'] + and clock['host_cleanup_monotonic_ns'] == record['cleanup_monotonic_ns'] + and clock['guest_boot_id'] == exports['guest_boot_id'], 'guest clock/parent authority mismatch') require(guest['runner_commit'] == record['runner_commit'] and guest['manifest_sha256'] == record['manifest_sha256'] and guest['checks_outcome'] == 'pass' and guest_audit['outcome'] == 'pending_external_guest_teardown' and guest['storage']['guest_boot_id'] == exports['guest_boot_id'] @@ -730,6 +796,9 @@ def main(argv=None): if not launched and os.path.lexists(scoped['parent'] / 'launch.json'): launch_guest(record, args.record_sha256) launched = True + if launched and (scoped['parent'] / 'guest-clock-request.json').exists() \ + and not (scoped['parent'] / 'guest-clock.json').exists(): + capture_guest_clock(record, args.record_sha256) process = capture_agent(record) receipt = scoped['parent'] / 'vm-process.json' if process and not receipt.exists(): diff --git a/scripts/acceptance/condition-history/guest-proposal.md b/scripts/acceptance/condition-history/guest-proposal.md index 3f08525..3f250aa 100644 --- a/scripts/acceptance/condition-history/guest-proposal.md +++ b/scripts/acceptance/condition-history/guest-proposal.md @@ -50,9 +50,11 @@ window could release the existing 6-GiB shared VM allocation; this proposal does not authorize stopping it. The fixed disk is additional to that shared VM, which is never an acceptance input. No global daemon setting or host package changes. -The entire invocation starts at S, active work ends at S+1740 seconds, and all -cleanup ends by S+1800. Setup consumes the same window and must finish within -300 seconds. Host guardian timers use monotonic time once the externally captured +The parent invocation starts at S, active work ends at S+1740 seconds, and all +parent cleanup ends by S+1800. Those ceilings never move. Setup captures their +monotonic counterparts once; the schema-v2 host authority persists them across +setup/guard and later readback rather than recomputing a lease from wall time. +Setup consumes the same window and must finish within 300 seconds. Host guardian timers use monotonic time once the externally captured absolute cutoffs are bound. The existing guest Cell keeps its 600-second bound, two Cargo jobs, sequential build/runtime phases and per-role caps. @@ -152,8 +154,21 @@ used for native controls. effective configuration; no SDK consumer is released before this receipt. These probes are source under review and have not run in a guest. Independently obtain B over the generated control path and match that receipt. - Transfer a clean public R checkout with the immutable H tag and invoke - the README's captured entry with that B and the same S/cutoffs. No host source + Transfer a clean public R checkout with the immutable annotated H tag. Then + write exclusive `guest-clock-request.json` with exactly `record_sha256` and + `action: "capture-guest-clock"`. The resident guardian alone samples its + original monotonic remaining budget around a captured read-only guest + Unix/monotonic/boot probe. Both observation intervals must be at most five + seconds. Its exclusive `guest-clock.json` binds those domains and the host + authority digest. A setup/launch with less than 600 seconds of work plus + 180 seconds of cleanup/audit reserve refuses before allocation or start. + Capture fresh guest G and invoke the README entry with B and this exact + parent clock JSON/digest. Guest active is `min(G+1740, mapped_parent_active-180)` + and guest cleanup is active+60. The guest requires at least 600 active seconds + before its work allocation. Independent audit/export keeps at least 120 seconds + before parent active. Delaying setup or choosing fresh G never grants a new + parent envelope. The run, recovery, summary and audits bind the same clock. + Command/recovery cutoffs use its conservative guest monotonic mapping. No host source mount or credential is used. The guest requires an empty daemon/build cache and ext4 root/work/Docker data on its fixed block device before pulling. Existing Linux boot/start-tick recovery and full internal-network readbacks @@ -173,7 +188,9 @@ used for native controls. 10. After guardian exit, run the same captured source in independent `audit` mode. Require image/mount/instance absence, guardian and VM hostagent absence, exact host custody/source receipts, R/M/B agreement, guest audit and summary - digest agreement. Its exclusive `host-audit.json` may then record pass. A + digest agreement and equality with the resident clock receipt, including + the original parent ceilings and both boot identities. Its exclusive + `host-audit.json` may then record pass. A guest pass alone remains `pending_external_guest_teardown`. Failure at any stage is terminal for this attempt. The resident setup watchdog diff --git a/scripts/acceptance/condition-history/recover.py b/scripts/acceptance/condition-history/recover.py index baf07a0..7756234 100644 --- a/scripts/acceptance/condition-history/recover.py +++ b/scripts/acceptance/condition-history/recover.py @@ -20,7 +20,15 @@ ROLE = 'dev.durable-workflow.acceptance.role' RECORD_KEYS = {'schema', 'owner', 'token', 'cells', 'runner_commit', 'package_commit', 'manifest_sha256', 'recovery_sha256', 'started_unix', 'active_unix', 'cleanup_unix', 'work_dir', - 'docker_endpoint', 'uid', 'gid', 'owner_process', 'storage'} + 'docker_endpoint', 'uid', 'gid', 'owner_process', 'storage', 'parent_clock', + 'active_monotonic_ns', 'cleanup_monotonic_ns'} +NS = 1_000_000_000 +MIN_WORK_SECONDS = 600 +CLOCK_KEYS = {'schema', 'host_record_sha256', 'host_boot_id', 'guest_boot_id', + 'host_started_unix', 'host_active_unix', 'host_cleanup_unix', + 'host_active_monotonic_ns', 'host_cleanup_monotonic_ns', + 'host_before_monotonic_ns', 'host_after_monotonic_ns', + 'guest_before_monotonic_ns', 'guest_after_monotonic_ns', 'guest_unix_ns'} STORAGE_KEYS = {'schema', 'guest_boot_id', 'device', 'block_bytes', 'filesystem_bytes', 'docker_root', 'daemon_id'} GUEST_DISK_BYTES = 20 * 1024**3 ROLES = ('fetch', 'build', 'mysql', 'redis', 'bootstrap', 'runtime', 'queue', 'consumer') @@ -40,6 +48,54 @@ def canonical(value): return (json.dumps(value, sort_keys=True, separators=(',', ':')) + '\n').encode() +def clock_envelope(clock, started, guest_boot): + """Map the resident parent's remaining monotonic lease conservatively.""" + require(set(clock) == CLOCK_KEYS and clock['schema'] == 'durable-workflow.condition-parent-clock/v1', + 'wrong parent clock schema') + integers = CLOCK_KEYS - {'schema', 'host_record_sha256', 'host_boot_id', 'guest_boot_id'} + require(all(type(clock[k]) is int and clock[k] > 0 for k in integers) + and type(started) is int, 'wrong clock integer authority') + require(isinstance(clock['host_record_sha256'], str) and re.fullmatch('[0-9a-f]{64}', clock['host_record_sha256']) + and all(isinstance(clock[k], str) and re.fullmatch('[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', clock[k]) + for k in ('host_boot_id', 'guest_boot_id')) + and clock['guest_boot_id'] == guest_boot, 'wrong clock source/boot authority') + require(clock['host_active_unix'] == clock['host_started_unix'] + 1740 + and clock['host_cleanup_unix'] == clock['host_started_unix'] + 1800 + and clock['host_cleanup_monotonic_ns'] == clock['host_active_monotonic_ns'] + 60 * NS, + 'parent clock ceilings changed') + require(0 <= clock['host_after_monotonic_ns'] - clock['host_before_monotonic_ns'] <= 5 * NS + and 0 <= clock['guest_after_monotonic_ns'] - clock['guest_before_monotonic_ns'] <= 5 * NS + and clock['host_after_monotonic_ns'] < clock['host_active_monotonic_ns'], + 'clock observation is late or unbounded') + sample = clock['guest_unix_ns'] + require(sample // NS <= started < sample // NS + 30, 'stale guest clock mapping') + # The guest sample precedes the host response. Using the parent's remaining + # lease at that later response can only shorten the mapped parent deadline. + parent_active = (sample + clock['host_active_monotonic_ns'] - clock['host_after_monotonic_ns']) // NS + active = min(started + 1740, parent_active - 180) + cleanup = active + 60 + require(active - started >= MIN_WORK_SECONDS and cleanup <= parent_active - 120, + 'minimum guest work/audit reserve consumed') + active_mono = clock['guest_before_monotonic_ns'] + active * NS - sample + return {'active_unix': active, 'cleanup_unix': cleanup, + 'active_monotonic_ns': active_mono, 'cleanup_monotonic_ns': active_mono + 60 * NS} + + +def launch_clock(args): + require(isinstance(args.parent_clock_json, str) and len(args.parent_clock_json.encode()) <= 8192, + 'oversize parent clock input') + clock = json.loads(args.parent_clock_json) + require(digest(canonical(clock)) == args.parent_clock_sha256, 'parent clock digest mismatch') + envelope = clock_envelope(clock, args.started_unix, args.guest_boot_id) + require(all(getattr(args, k) == envelope[k] for k in ('active_unix', 'cleanup_unix')), + 'guest deadlines differ from parent-constrained envelope') + require(args.started_unix <= time.time() < args.started_unix + 30, 'stale launch timestamp') + require(envelope['active_monotonic_ns'] - time.monotonic_ns() >= MIN_WORK_SECONDS * NS, + 'minimum guest work/audit reserve consumed before launch') + args.parent_clock = clock + for key in ('active_monotonic_ns', 'cleanup_monotonic_ns'):setattr(args, key, envelope[key]) + + def read(path, mode=0o600, limit=8 * 1024 * 1024): before = path.lstat() require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 @@ -180,13 +236,14 @@ def make_record(args, manifest_sha, recovery_sha): require(owner and owner['pid'] == owner['pgid'] == owner['sid'], 'isolated owner session required') root = pathlib.Path(args.work_dir) require(root.is_absolute() and root.resolve() == root, 'canonical work root required') - return {'schema': 'durable-workflow.condition-run/v4', 'owner': PREFIX, 'token': args.run_token, + return {'schema': 'durable-workflow.condition-run/v5', 'owner': PREFIX, 'token': args.run_token, 'cells': [CELL], 'runner_commit': args.runner_commit, 'package_commit': H, 'manifest_sha256': manifest_sha, 'recovery_sha256': recovery_sha, 'started_unix': args.started_unix, 'active_unix': args.active_unix, 'cleanup_unix': args.cleanup_unix, 'work_dir': str(root), 'docker_endpoint': args.docker_endpoint, 'uid': os.getuid(), 'gid': os.getgid(), - 'owner_process': owner, 'storage': args.guest_storage} + 'owner_process': owner, 'storage': args.guest_storage, 'parent_clock': args.parent_clock, + 'active_monotonic_ns': args.active_monotonic_ns, 'cleanup_monotonic_ns': args.cleanup_monotonic_ns} def validate_record(root, expected_record_sha, runner, manifest_sha, recovery_sha, token): @@ -198,7 +255,7 @@ def validate_record(root, expected_record_sha, runner, manifest_sha, recovery_sh raw = read(root / 'run.json') require(digest(raw) == expected_record_sha, 'run record digest mismatch') record = json.loads(raw) - require(set(record) == RECORD_KEYS and record['schema'] == 'durable-workflow.condition-run/v4', 'wrong run record schema') + require(set(record) == RECORD_KEYS and record['schema'] == 'durable-workflow.condition-run/v5', 'wrong run record schema') require(record['owner'] == PREFIX and record['token'] == token and re.fullmatch('[0-9a-f]{16}', token) and record['cells'] == [CELL], 'wrong recovery ownership') require(record['runner_commit'] == runner and re.fullmatch('[0-9a-f]{40}', runner) @@ -206,9 +263,9 @@ def validate_record(root, expected_record_sha, runner, manifest_sha, recovery_sh and record['recovery_sha256'] == recovery_sha, 'wrong recovery source subject') require(record['work_dir'] == str(root) and record['uid'] == os.getuid() and record['gid'] == os.getgid() and record['docker_endpoint'].startswith('unix:///'), 'wrong recovery placement') - require(all(type(record[k]) is int for k in ('started_unix', 'active_unix', 'cleanup_unix')) - and record['cleanup_unix'] == record['started_unix'] + 1800 - and record['active_unix'] == record['cleanup_unix'] - 60, 'wrong recovery deadlines') + envelope = clock_envelope(record['parent_clock'], record['started_unix'], record['storage']['guest_boot_id']) + require(all(type(record[k]) is int and record[k] == value for k, value in envelope.items()), + 'wrong recovery deadlines') process = record['owner_process'] validate_process(process, 'wrong captured owner process') validate_storage(record['storage'], root, observe=False) @@ -251,7 +308,7 @@ def docker(record, command, check=True): require(json.loads(read(root / 'recovery-docker/config.json')) == PUBLIC_CONFIG, 'anonymous Docker configuration changed') env = {'PATH': os.environ.get('PATH', '/usr/bin:/bin'), 'HOME': str(root / 'public-home'), 'DOCKER_CONFIG': str(root / 'recovery-docker'), 'DOCKER_HOST': record['docker_endpoint']} - remaining = record['cleanup_unix'] - time.time() + remaining = (record['cleanup_monotonic_ns'] - time.monotonic_ns()) / NS require(remaining > 0, 'cleanup deadline exhausted') result = subprocess.run(['docker', *command], capture_output=True, text=True, timeout=min(5, remaining), env=env) require(not check or result.returncode == 0, 'recovery Docker query/removal failed') @@ -376,7 +433,7 @@ def main(argv=None): try: if args.at is not None: require(args.at == record['active_unix'], 'wrong recovery wake deadline') - time.sleep(max(0, args.at - time.time())) + time.sleep(max(0, (record['active_monotonic_ns'] - time.monotonic_ns()) / NS)) # A later file drift fails evidence but cannot replace the captured # authority/code or prevent its already-authorized exact cleanup. try: @@ -417,7 +474,8 @@ def main(argv=None): summary = json.loads(raw) require(summary.get('record_sha256') == args.record_sha256 and summary.get('runner_commit') == args.runner_commit and summary.get('package_commit') == H - and summary.get('manifest_sha256') == args.manifest_sha256, 'summary subject mismatch') + and summary.get('manifest_sha256') == args.manifest_sha256 + and summary.get('parent_clock') == record['parent_clock'], 'summary subject mismatch') remaining = {kind: objects(record, kind) for kind in ('container', 'network', 'volume')} require(all(v == [] for v in remaining.values()), 'independent object survivor') require(not group_members(record['owner_process']['pgid']), 'independent owner process survivor') @@ -437,7 +495,7 @@ def main(argv=None): result = {'outcome': 'pending_external_guest_teardown', 'record_sha256': args.record_sha256, 'manifest_sha256': args.manifest_sha256, 'summary_sha256': args.summary_sha256, 'remaining': remaining, 'owner_process_absent': True, 'recovery_process_absent': True, - 'storage': record['storage']} + 'storage': record['storage'], 'parent_clock': record['parent_clock']} exclusive(pathlib.Path(args.work_dir) / 'independent-audit.json', canonical(result)) print(json.dumps(result)) diff --git a/scripts/acceptance/condition-history/resources.py b/scripts/acceptance/condition-history/resources.py index 8c15164..a50fdbc 100644 --- a/scripts/acceptance/condition-history/resources.py +++ b/scripts/acceptance/condition-history/resources.py @@ -50,7 +50,7 @@ def environment(args): def call(args, line, timeout=120, check=True, **kwargs): if hasattr(args, 'storage_guard'): args.storage_guard() - remaining = int(args.active_unix - time.time()) + remaining = (args.active_monotonic_ns - time.monotonic_ns()) / 1_000_000_000 require(remaining > 0, 'active deadline exhausted') result = subprocess.run(line, capture_output=True, timeout=min(timeout, remaining), env=environment(args), **dict({'text': True}, **kwargs)) diff --git a/scripts/acceptance/condition-history/subject.py b/scripts/acceptance/condition-history/subject.py index 754bb76..6afb03f 100644 --- a/scripts/acceptance/condition-history/subject.py +++ b/scripts/acceptance/condition-history/subject.py @@ -171,6 +171,8 @@ def arguments(argv=None): parser.add_argument('--run-token') parser.add_argument('--docker-endpoint') parser.add_argument('--guest-boot-id') + parser.add_argument('--parent-clock-json') + parser.add_argument('--parent-clock-sha256') for key in ('started-unix', 'active-unix', 'cleanup-unix'): parser.add_argument('--' + key, type=int) parser.add_argument('--record-sha256') @@ -190,10 +192,8 @@ def validate(args): require(args.docker_endpoint and args.docker_endpoint.startswith('unix:///'), 'local daemon endpoint required') require(args.guest_boot_id and re.fullmatch('[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', args.guest_boot_id), 'exact disposable guest boot identity required') - require(type(args.started_unix) is int and type(args.active_unix) is int and type(args.cleanup_unix) is int - and args.cleanup_unix == args.started_unix + 1800 - and args.active_unix == args.cleanup_unix - 60, 'wrong absolute deadline envelope') - require(args.started_unix <= time.time() < args.started_unix + 30, 'stale launch timestamp') + require(all(type(v) is int for v in (args.started_unix, args.active_unix, args.cleanup_unix)) + and args.parent_clock_json and args.parent_clock_sha256, 'parent-constrained clock input required') def bootstrap(): @@ -229,9 +229,11 @@ def bootstrap(): recovery = types.ModuleType('captured_recovery_preflight') recovery.__file__ = 'git:' + args.runner_commit + ':' + PREFIX + 'recover.py' exec(compile(captured[PREFIX + 'recover.py'], recovery.__file__, 'exec'), recovery.__dict__) + recovery.launch_clock(args) args.guest_storage = recovery.storage_preflight(args, lambda line: module.call(args, line)) args.storage_guard = lambda: recovery.validate_storage(args.guest_storage, args.work_dir) module.preflight_collisions(args) + recovery.launch_clock(args) RECORD = recovery.make_record(args, actual, value['files'][PREFIX + 'recover.py']['sha256']) RECORD_SHA = digest(canonical(RECORD)) print(json.dumps({'private_launch': RECORD, 'record_sha256': RECORD_SHA}), flush=True) diff --git a/scripts/acceptance/condition-history/test_audit.py b/scripts/acceptance/condition-history/test_audit.py index b7cfd7f..703adc4 100644 --- a/scripts/acceptance/condition-history/test_audit.py +++ b/scripts/acceptance/condition-history/test_audit.py @@ -30,7 +30,7 @@ def setUp(self): self.summary = {'checks_outcome': 'pass', 'outcome': 'pending_independent_audit', 'record_sha256': self.record_sha, 'runner_commit': self.authority['runner_commit'], 'package_commit': subject.H, 'manifest_sha256': self.sealed.sha256, - 'recovery_process_sha256': watcher_sha, 'cleanup_errors': [], + 'recovery_process_sha256': watcher_sha, 'cleanup_errors': [], 'parent_clock': self.authority['parent_clock'], 'cleanup': {'pass': True, 'errors': [], 'remaining': {'container': [], 'network': [], 'volume': []}}} self.summary_sha = subject.exclusive(self.root / 'summary.json', subject.canonical(self.summary)) @@ -60,6 +60,13 @@ def test_independent_audit_binds_exact_summary_and_both_processes(self): self.assertEqual(result['storage'], self.authority['storage']) with self.assertRaises(FileExistsError):self.invoke() + def test_changed_clock_cannot_gain_audit_authority_with_a_new_summary_digest(self): + value=dict(self.summary,parent_clock=dict(self.authority['parent_clock'],host_record_sha256='f'*64)) + raw=subject.canonical(value);(self.root/'summary.json').write_bytes(raw) + self.summary_sha=subject.digest(raw) + with self.assertRaisesRegex(SystemExit,'summary subject'):self.invoke() + self.assertFalse((self.root/'independent-audit.json').exists()) + def test_changed_summary_never_gains_authority(self): (self.root / 'summary.json').write_bytes(b'{}\n') with self.assertRaisesRegex(SystemExit, 'digest mismatch'):self.invoke() diff --git a/scripts/acceptance/condition-history/test_guest_host.py b/scripts/acceptance/condition-history/test_guest_host.py index ed2e3e7..63cd883 100644 --- a/scripts/acceptance/condition-history/test_guest_host.py +++ b/scripts/acceptance/condition-history/test_guest_host.py @@ -13,7 +13,7 @@ import types import unittest from unittest import mock -from test_subject import HERE, load +from test_subject import HERE, load, parent_clock host = load('condition_guest_host_tests', HERE / 'guest-host.py') TOKEN = '0123456789abcdef' @@ -26,7 +26,9 @@ def setUp(self): self.addCleanup(shutil.rmtree, self.temp) self.parent = self.temp / ('sr-' + TOKEN) self.parent.mkdir(mode=0o700) - self.record = {'parent': str(self.parent), 'token': TOKEN, 'limactl': '/reviewed/bin/limactl', + active_ns = time.monotonic_ns() + 1740 * 1_000_000_000 + self.record = {'active_monotonic_ns': active_ns, 'cleanup_monotonic_ns': active_ns + 60_000_000_000, + 'parent': str(self.parent), 'token': TOKEN, 'limactl': '/reviewed/bin/limactl', 'runner_commit': 'a' * 40, 'manifest_sha256': 'b' * 64, 'guardian_sha256': 'c' * 64, 'guest_agent_binary_sha256': 'e' * 64, 'started_unix': int(time.time()), 'active_unix': int(time.time()) + 1740, @@ -89,11 +91,15 @@ def test_public_darwin_struct_and_consistent_kernel_epoch(self): host.process_identity(1234) def exports(self): + clock = parent_clock(self.record['started_unix']) + self.record['host_boot_id'] = clock['host_boot_id'] + for key in ('started_unix', 'active_unix', 'cleanup_unix', 'active_monotonic_ns', 'cleanup_monotonic_ns'):clock['host_'+key] = self.record[key] + self.put('guest-clock.json', clock) storage = {'guest_boot_id': BOOT} summary_sha = self.put('guest-summary.json', {'runner_commit': self.record['runner_commit'], - 'manifest_sha256': self.record['manifest_sha256'], 'checks_outcome': 'pass', 'storage': storage}) + 'manifest_sha256': self.record['manifest_sha256'], 'checks_outcome': 'pass', 'storage': storage, 'parent_clock': clock}) audit_sha = self.put('guest-audit.json', {'outcome': 'pending_external_guest_teardown', - 'manifest_sha256': self.record['manifest_sha256'], 'summary_sha256': summary_sha, 'storage': storage}) + 'manifest_sha256': self.record['manifest_sha256'], 'summary_sha256': summary_sha, 'storage': storage, 'parent_clock': clock}) exports_sha = self.put('exports.json', {'record_sha256': 'd' * 64, 'guest_boot_id': BOOT, 'guest-summary.json': summary_sha, 'guest-audit.json': audit_sha}) self.put('finish.json', {'record_sha256': 'd' * 64, 'exports_sha256': exports_sha}) @@ -441,5 +447,120 @@ def command(record, argv, input_data=None): self.assertFalse((self.parent / 'vm-started.json').exists()) + def test_late_budget_refuses_before_store_allocation_and_guest_create(self): + self.record['_active_mono']=time.monotonic()+779 + self.record['_stage_mono']=time.monotonic()+300 + with mock.patch.object(host,'capacity',return_value={}),mock.patch.object(host,'selected_agent'), \ + mock.patch.object(host,'input_bytes'),mock.patch.object(host,'call') as command, \ + self.assertRaisesRegex(SystemExit,'reserve consumed'): + host.prepare(self.record,self.profile) + command.assert_not_called() + self.put('launch.json',{'record_sha256':'d'*64,'manifest_sha256':self.record['manifest_sha256'],'action':'create-and-start'}) + self.record['profile']=self.proposal()['profile'];self.record['profile_sha256']=host.digest(host.canonical(self.record['profile'])) + with mock.patch.object(host,'verify_store'),mock.patch.object(host,'verify_guest_inputs'), \ + mock.patch.object(host,'call') as command,self.assertRaisesRegex(SystemExit,'reserve consumed'): + host.launch_guest(self.record,'d'*64) + command.assert_not_called() + + def test_resident_clock_receipt_binds_original_parent_and_guest_domains(self): + self.record['host_boot_id']=BOOT + self.put('guest-clock-request.json',{'record_sha256':'d'*64,'action':'capture-guest-clock'}) + self.put('vm-started.json',{'agent':{'guest_boot_id':BOOT}}) + mono=time.monotonic_ns() + sample={'guest_boot_id':BOOT,'guest_before_monotonic_ns':900000000000, + 'guest_after_monotonic_ns':900000000010,'guest_unix_ns':2000000000000000000} + with mock.patch.object(host,'call',return_value=host.canonical(sample)), \ + mock.patch.object(host.time,'monotonic_ns',side_effect=[mono,mono+2000000000]): + clock=host.capture_guest_clock(self.record,'d'*64) + self.assertEqual(clock['host_record_sha256'],'d'*64) + self.assertEqual(clock['host_active_monotonic_ns'],self.record['active_monotonic_ns']) + self.assertEqual(clock['host_cleanup_monotonic_ns'],self.record['cleanup_monotonic_ns']) + self.assertEqual(clock['host_after_monotonic_ns']-clock['host_before_monotonic_ns'],2000000000) + self.assertEqual(clock['guest_unix_ns'],sample['guest_unix_ns']) + self.assertEqual(json.loads((self.parent/'guest-clock.json').read_bytes()),clock) + with mock.patch.object(host,'call',return_value=host.canonical(sample)),self.assertRaises(FileExistsError): + host.capture_guest_clock(self.record,'d'*64) + + def test_budget_consumed_by_input_preflight_refuses_before_scope_creation(self): + # Read-only inspection must not make an initially adequate budget stale. + self.parent.rmdir() + self.record['_stage_mono'] = time.monotonic() + 300 + self.record.update(guest_agent='/synthetic/guest-agent.gz', cached_image='/synthetic/guest.raw.gz') + def inspect(*args, **kwargs): + self.record['_active_mono'] = time.monotonic() + 779 + with mock.patch.object(host, 'capacity', return_value={}), \ + mock.patch.object(host, 'selected_agent'), mock.patch.object(host, 'input_bytes', side_effect=inspect), \ + mock.patch.object(host, 'call') as command, self.assertRaisesRegex(SystemExit, 'reserve consumed'): + host.prepare(self.record, self.profile) + command.assert_not_called() + self.assertFalse(self.parent.exists()) + + def test_clock_probe_refuses_wrong_boot_slow_sample_or_late_budget(self): + self.record['host_boot_id']=BOOT + self.put('guest-clock-request.json',{'record_sha256':'d'*64,'action':'capture-guest-clock'}) + self.put('vm-started.json',{'agent':{'guest_boot_id':BOOT}}) + sample={'guest_boot_id':'foreign','guest_before_monotonic_ns':100,'guest_after_monotonic_ns':200,'guest_unix_ns':1000} + with mock.patch.object(host,'call',return_value=host.canonical(sample)),self.assertRaises(SystemExit): + host.capture_guest_clock(self.record,'d'*64) + sample['guest_boot_id']=BOOT + mono=time.monotonic_ns() + with mock.patch.object(host,'call',return_value=host.canonical(sample)), \ + mock.patch.object(host.time,'monotonic_ns',side_effect=[mono,mono+6000000000]),self.assertRaises(SystemExit): + host.capture_guest_clock(self.record,'d'*64) + self.record['_active_mono']=time.monotonic()+779 + with mock.patch.object(host,'call') as command,self.assertRaises(SystemExit): + host.capture_guest_clock(self.record,'d'*64) + command.assert_not_called();self.assertFalse((self.parent/'guest-clock.json').exists()) + + def test_final_host_audit_rejects_rebound_clock_even_with_matching_exports(self): + self.audit_files() + clock=json.loads((self.parent/'guest-clock.json').read_bytes());clock['host_active_unix']+=1 + self.put('guest-clock.json',clock) + digests={} + for name in ('guest-summary.json','guest-audit.json'): + value=json.loads((self.parent/name).read_bytes());value['parent_clock']=clock + digests[name]=self.put(name,value) + export_sha=self.put('exports.json',dict(record_sha256='d'*64,guest_boot_id=BOOT,**digests)) + self.put('finish.json',{'record_sha256':'d'*64,'exports_sha256':export_sha}) + with mock.patch.object(host,'process_identity',return_value=None),self.assertRaisesRegex(SystemExit,'clock/parent'): + host.audit(self.record,'d'*64,'c'*64) + + + def test_reopening_host_authority_never_renews_monotonic_ceilings(self): + self.store_fixture() + binary=self.temp/'launcher';binary.write_bytes(b'launcher') + agent=self.temp/'agent.gz';agent.write_bytes(b'agent') + value={key:self.record.get(key) for key in host.AUTHORITY_KEYS} + value.update(schema='durable-workflow.condition-host/v2',limactl=str(binary),guest_agent=str(agent), + guest_agent_binary_sha256=host.AGENT_BINARY_SHA) + sha=self.put('clock-authority.json',value) + with mock.patch.object(host,'LIMA_SHA',host.digest(binary.read_bytes())), \ + mock.patch.object(host,'AGENT_SHA',host.digest(agent.read_bytes())), \ + mock.patch.object(host,'selected_agent'),mock.patch.object(host,'boot_identity',return_value=BOOT), \ + mock.patch.object(host.time,'time',return_value=self.record['started_unix']-3600): + observed=host.authority(self.parent/'clock-authority.json',sha,self.record['guardian_sha256']) + self.assertEqual(observed['_active_mono'],value['active_monotonic_ns']/1000000000) + self.assertEqual(observed['_cleanup_mono'],value['cleanup_monotonic_ns']/1000000000) + + + def test_budget_consumed_during_create_refuses_start_without_renewal(self): + self.store_fixture() + self.record['guest_image']={'device':1,'inode':2,'bytes':3,'sha256':'f'*64} + self.put('launch.json',{'record_sha256':'d'*64,'manifest_sha256':self.record['manifest_sha256'],'action':'create-and-start'}) + calls=[] + def command(record,argv,input_data=None): + calls.append(argv) + if 'create' in argv:record['_active_mono']=time.monotonic()+779 + return b'' + original=self.record['active_monotonic_ns'] + with mock.patch.object(host,'verify_store'),mock.patch.object(host,'verify_guest_inputs'), \ + mock.patch.object(host,'call',side_effect=command), \ + mock.patch.object(host,'effective_config',return_value={'status':'Stopped'}), \ + self.assertRaisesRegex(SystemExit,'reserve consumed'): + host.launch_guest(self.record,'d'*64) + self.assertEqual(len(calls),1);self.assertIn('create',calls[0]);self.assertNotIn('start',calls[0]) + self.assertEqual(self.record['active_monotonic_ns'],original) + + if __name__ == '__main__': unittest.main() diff --git a/scripts/acceptance/condition-history/test_packet.py b/scripts/acceptance/condition-history/test_packet.py index 32f9a5d..48f54f0 100644 --- a/scripts/acceptance/condition-history/test_packet.py +++ b/scripts/acceptance/condition-history/test_packet.py @@ -11,7 +11,7 @@ import types import unittest from unittest import mock -from test_subject import subject, fixture_subject, remove_private, REPO, HERE +from test_subject import subject, recovery, parent_clock, fixture_subject, remove_private, REPO, HERE class Packet(unittest.TestCase): def setUp(self): @@ -21,6 +21,10 @@ def setUp(self): self.args = types.SimpleNamespace(mode='run', checkout=str(REPO), work_dir=str(self.root / 'fresh'), package_commit=subject.H, runner_commit='a' * 40, run_token='0123456789abcdef', docker_endpoint='unix:///synthetic.sock', guest_boot_id='11111111-1111-1111-1111-111111111111', started_unix=now, active_unix=now+1740, cleanup_unix=now+1800) + self.args.parent_clock_json = recovery.canonical(parent_clock(now)).decode() + self.args.parent_clock_sha256 = recovery.digest(self.args.parent_clock_json.encode()) + envelope = recovery.clock_envelope(json.loads(self.args.parent_clock_json), now, self.args.guest_boot_id) + for key, value in envelope.items():setattr(self.args, key, value) subject.ARGS = self.args subject.CURRENT = fixture_subject(self.root) self.packet = subject.CURRENT.module('engine') @@ -36,6 +40,7 @@ def test_preflight_refusals_precede_any_side_effect(self): args = copy.copy(self.args);setattr(args, field, value) with self.subTest(field=field), mock.patch.object(subject,'git',self.git), self.assertRaises(SystemExit): subject.validate(args) + recovery.launch_clock(args) self.assertFalse(pathlib.Path(args.work_dir).exists()) def test_wrong_h_refuses_under_python_optimization(self): @@ -233,4 +238,113 @@ def test_timer_terminals_require_prior_schedule_and_row_ordered_supersession(sel packet.comparator.check(report) -if __name__ == "__main__":unittest.main() +class ClockContract(unittest.TestCase): + BOOT = '11111111-1111-1111-1111-111111111111' + + def clock(self, setup=60, offset=0): + host_start=2_000_000_000;host_mono=10_000*recovery.NS;guest_mono=20_000*recovery.NS + return {'schema':'durable-workflow.condition-parent-clock/v1','host_record_sha256':'d'*64, + 'host_boot_id':'22222222-2222-2222-2222-222222222222','guest_boot_id':self.BOOT, + 'host_started_unix':host_start,'host_active_unix':host_start+1740,'host_cleanup_unix':host_start+1800, + 'host_active_monotonic_ns':host_mono+1740*recovery.NS, + 'host_cleanup_monotonic_ns':host_mono+1800*recovery.NS, + 'host_before_monotonic_ns':host_mono+setup*recovery.NS, + 'host_after_monotonic_ns':host_mono+setup*recovery.NS, + 'guest_before_monotonic_ns':guest_mono+setup*recovery.NS, + 'guest_after_monotonic_ns':guest_mono+setup*recovery.NS, + 'guest_unix_ns':(host_start+setup+offset)*recovery.NS} + + def args(self, clock, start=None): + start=clock['guest_unix_ns']//recovery.NS if start is None else start + envelope=recovery.clock_envelope(clock,start,self.BOOT) + raw=recovery.canonical(clock) + return types.SimpleNamespace(started_unix=start,guest_boot_id=self.BOOT, + parent_clock_json=raw.decode(),parent_clock_sha256=recovery.digest(raw),**envelope) + + def old_validate(self): + source=subprocess.check_output(['git','show','df617fb:scripts/acceptance/condition-history/subject.py'],text=True) + fn=next(node for node in ast.parse(source).body if isinstance(node,ast.FunctionDef) and node.name=='validate') + space={'require':subject.require,'H':subject.H,'git':lambda repo,*args: + {'HEAD':'a'*40,'refs/tags/4.12.0^{commit}':subject.H}.get(args[-1],''), + 'os':os,'pathlib':pathlib,'re':__import__('re'),'time':time} + exec(compile(ast.Module(body=[fn],type_ignores=[]),'frozen-df617-clock','exec'),space) + return space['validate'] + + def old_args(self, started): + return types.SimpleNamespace(mode='run',package_commit=subject.H,runner_commit='a'*40, + checkout='/unused',work_dir='/nonexistent/sdk-rust-clock-control',run_token='0'*16, + docker_endpoint='unix:///synthetic.sock',guest_boot_id=self.BOOT, + started_unix=started,active_unix=started+1740,cleanup_unix=started+1800) + + def test_actual_predecessor_refuses_allowed_60s_setup_but_successor_accepts(self): + clock=self.clock();guest_start=clock['guest_unix_ns']//recovery.NS + old=self.old_validate();args=self.old_args(clock['host_started_unix']) + with mock.patch.object(time,'time',return_value=guest_start),self.assertRaisesRegex(SystemExit,'stale launch'): + old(args) + args=self.args(clock) + with mock.patch.object(recovery.time,'time',return_value=guest_start), \ + mock.patch.object(recovery.time,'monotonic_ns',return_value=clock['guest_after_monotonic_ns']): + recovery.launch_clock(args) + self.assertEqual(args.active_unix,clock['host_active_unix']-180) + self.assertEqual(args.cleanup_unix,args.active_unix+60) + self.assertEqual(clock['host_active_unix']-args.cleanup_unix,120) + + def test_actual_predecessor_admits_fresh_full_extension_successor_refuses(self): + clock=self.clock();guest_start=clock['guest_unix_ns']//recovery.NS + old=self.old_validate();old_args=self.old_args(guest_start) + with mock.patch.object(time,'time',return_value=guest_start):old(old_args) + self.assertGreater(old_args.cleanup_unix,clock['host_cleanup_unix']) + args=self.args(clock);args.active_unix=old_args.active_unix;args.cleanup_unix=old_args.cleanup_unix + with self.assertRaisesRegex(SystemExit,'parent-constrained'):recovery.launch_clock(args) + + def test_setup_and_fresh_start_never_renew_parent_monotonic_cutoffs(self): + values=[] + for setup in (0,60,300,900): + clock=self.clock(setup) + values.append(recovery.clock_envelope(clock,clock['guest_unix_ns']//recovery.NS,self.BOOT)) + self.assertEqual(len({item['active_monotonic_ns'] for item in values}),1) + self.assertEqual(len({item['cleanup_monotonic_ns'] for item in values}),1) + clock=self.clock(60) + for delay in (0,10,29): + value=recovery.clock_envelope(clock,clock['guest_unix_ns']//recovery.NS+delay,self.BOOT) + self.assertEqual(value,values[0]) + + def test_late_setup_refuses_before_storage_or_allocation(self): + clock=self.clock(961);start=clock['guest_unix_ns']//recovery.NS + with mock.patch.object(recovery.subprocess,'run',side_effect=AssertionError('no native operation')), \ + self.assertRaisesRegex(SystemExit,'reserve consumed'): + recovery.clock_envelope(clock,start,self.BOOT) + clock=self.clock(950);args=self.args(clock) + with mock.patch.object(recovery.time,'time',return_value=args.started_unix+20), \ + mock.patch.object(recovery.time,'monotonic_ns',return_value=clock['guest_after_monotonic_ns']+20*recovery.NS), \ + self.assertRaisesRegex(SystemExit,'before launch'):recovery.launch_clock(args) + + def test_clock_offsets_cancel_and_response_delay_only_shortens(self): + base=self.clock();start=base['guest_unix_ns']//recovery.NS + expected=recovery.clock_envelope(base,start,self.BOOT) + for offset in (-3600,300,3600): + clock=self.clock(offset=offset) + value=recovery.clock_envelope(clock,start+offset,self.BOOT) + self.assertEqual(value['active_monotonic_ns'],expected['active_monotonic_ns']) + self.assertEqual(value['cleanup_monotonic_ns'],expected['cleanup_monotonic_ns']) + self.assertEqual(value['active_unix'],expected['active_unix']+offset) + delayed=dict(base,host_after_monotonic_ns=base['host_after_monotonic_ns']+3*recovery.NS) + value=recovery.clock_envelope(delayed,start,self.BOOT) + self.assertEqual(value['active_unix'],expected['active_unix']-3) + self.assertEqual(value['active_monotonic_ns'],expected['active_monotonic_ns']-3*recovery.NS) + + def test_closed_clock_authority_and_deadline_mutations_refuse(self): + base=self.clock();start=base['guest_unix_ns']//recovery.NS + changes=[('extra',1),('host_record_sha256','foreign'),('guest_boot_id','foreign'), + ('host_active_unix',base['host_active_unix']+1),('host_cleanup_unix',base['host_cleanup_unix']+1), + ('host_cleanup_monotonic_ns',base['host_cleanup_monotonic_ns']+1), + ('host_after_monotonic_ns',base['host_before_monotonic_ns']+6*recovery.NS), + ('guest_before_monotonic_ns',True),('guest_unix_ns',base['guest_unix_ns']-31*recovery.NS)] + for key,value in changes: + with self.subTest(key=key),self.assertRaises(SystemExit): + recovery.clock_envelope(dict(base,**{key:value}),start,self.BOOT) + args=self.args(base);args.parent_clock_sha256='0'*64 + with self.assertRaisesRegex(SystemExit,'digest mismatch'):recovery.launch_clock(args) + + +if __name__ == '__main__':unittest.main() diff --git a/scripts/acceptance/condition-history/test_resources.py b/scripts/acceptance/condition-history/test_resources.py index 66d88fd..273f374 100644 --- a/scripts/acceptance/condition-history/test_resources.py +++ b/scripts/acceptance/condition-history/test_resources.py @@ -28,7 +28,8 @@ def setUp(self): self.tmp = tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) self.root = pathlib.Path(self.tmp.name) self.args = types.SimpleNamespace(work_dir=str(self.root / 'unused'), run_token='0123456789abcdef', - docker_endpoint='unix:///synthetic.sock', active_unix=int(time.time()) + 1740) + docker_endpoint='unix:///synthetic.sock', active_unix=int(time.time()) + 1740, + active_monotonic_ns=time.monotonic_ns()+1740*1_000_000_000) self.controller = resources.Docker(self.args, Frozen(), {}) self.image = {'sha256': 'c' * 64, 'raw': {'Id': 'image-id', 'Os': 'linux', 'Architecture': 'amd64', 'RootFS': {'Layers': []}, 'Config': {'Entrypoint': ['/entry'], 'Cmd': ['default'], @@ -65,6 +66,20 @@ def network_frame(self): def network(self): return types.SimpleNamespace(stdout=json.dumps([self.network_frame()])) + def test_actual_predecessor_wall_clock_rollback_extends_command_budget(self): + import types + old_source=subprocess.check_output(['git','show','df617fb:scripts/acceptance/condition-history/resources.py'],text=True) + old=types.ModuleType('old_wall_budget');exec(compile(old_source,'frozen-df617-resources','exec'),old.__dict__) + self.args.active_unix=int(time.time())+2 + mono=time.monotonic_ns();self.args.active_monotonic_ns=mono+2*1000000000 + with mock.patch.object(time,'time',return_value=self.args.active_unix-3600), \ + mock.patch.object(time,'monotonic_ns',return_value=mono), \ + mock.patch.object(subprocess,'run',return_value=types.SimpleNamespace(returncode=0)) as command: + old.call(self.args,['never-executed']) + self.assertEqual(command.call_args.kwargs['timeout'],120) + resources.call(self.args,['never-executed']) + self.assertEqual(command.call_args.kwargs['timeout'],2) + def test_actual_held_build_has_no_swap_cap(self): repo=HERE.parents[2] old=subprocess.check_output(['git','--no-replace-objects','-C',str(repo),'show', diff --git a/scripts/acceptance/condition-history/test_subject.py b/scripts/acceptance/condition-history/test_subject.py index 55a6dab..7e84fa1 100644 --- a/scripts/acceptance/condition-history/test_subject.py +++ b/scripts/acceptance/condition-history/test_subject.py @@ -29,6 +29,20 @@ def load(name, path): recovery = load('subject_recovery_tests', HERE / 'recover.py') +def parent_clock(started=None, remaining=1680): + started = int(time.time()) if started is None else started + mono = time.monotonic_ns() + return {'schema': 'durable-workflow.condition-parent-clock/v1', 'host_record_sha256': 'd' * 64, + 'host_boot_id': '22222222-2222-2222-2222-222222222222', + 'guest_boot_id': '11111111-1111-1111-1111-111111111111', + 'host_started_unix': started - 60, 'host_active_unix': started + 1680, + 'host_cleanup_unix': started + 1740, 'host_active_monotonic_ns': mono + remaining * recovery.NS, + 'host_cleanup_monotonic_ns': mono + (remaining + 60) * recovery.NS, + 'host_before_monotonic_ns': mono, 'host_after_monotonic_ns': mono, + 'guest_before_monotonic_ns': mono, 'guest_after_monotonic_ns': mono, + 'guest_unix_ns': started * recovery.NS} + + def fixture_subject(root): data = {p: (REPO / p).read_bytes() for p in subject.RUNTIME_FILES} files = {p: {'git_blob': 'a' * 40, 'mode': '100644', 'bytes': len(v), 'sha256': subject.digest(v)} @@ -172,11 +186,13 @@ def test_exclusive_summary_handoff_rejects_existing_file_and_redirect(self): def record(self): now = int(time.time()) - return {'schema': 'durable-workflow.condition-run/v4', 'owner': recovery.PREFIX, + clock = parent_clock(now) + envelope = recovery.clock_envelope(clock, now, clock['guest_boot_id']) + return {'schema': 'durable-workflow.condition-run/v5', 'owner': recovery.PREFIX, 'token': '0123456789abcdef', 'cells': [recovery.CELL], 'runner_commit': 'a' * 40, 'package_commit': subject.H, 'manifest_sha256': self.sealed.sha256, 'recovery_sha256': self.sealed.value['files'][subject.PREFIX + 'recover.py']['sha256'], - 'started_unix': now, 'active_unix': now + 1740, 'cleanup_unix': now + 1800, + 'started_unix': now, **envelope, 'parent_clock': clock, 'work_dir': str(self.root), 'docker_endpoint': 'unix:///synthetic.sock', 'uid': os.getuid(), 'gid': os.getgid(), 'storage': {'schema': 'durable-workflow.condition-guest-storage/v1', 'guest_boot_id': '11111111-1111-1111-1111-111111111111', 'device': '8:1', 'block_bytes': 20 * 1024**3, 'filesystem_bytes': 19 * 1024**3, 'docker_root': '/synthetic/docker', 'daemon_id': 'synthetic-daemon-id'}, From 92ecd01c9ff98a77fb6a06ca886bf7f9769db407 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:30:24 +0000 Subject: [PATCH 2/6] Bind the outer proposal to the constrained guest clock source --- scripts/acceptance/colima-window/README.md | 18 ++++++++++++------ scripts/acceptance/colima-window/window.py | 6 +++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/scripts/acceptance/colima-window/README.md b/scripts/acceptance/colima-window/README.md index 2d65a48..287f056 100644 --- a/scripts/acceptance/colima-window/README.md +++ b/scripts/acceptance/colima-window/README.md @@ -1,11 +1,17 @@ # One-shot default-Colima handoff proposal -This source accompanies #210. It is not admitted for execution. It preserves the -separately reviewed task controller at `df617fb681958801182e67ca63e886041bec6de0` -and its 13-member manifest -`9ce029dcfb64220ff233e9f42605400ea4a73aceb598272a4034b3e8a65d6e38`. -The outer source is a separate review subject. It does not change the Linux -process epochs, guest/network controls, fixed task store or acceptance rules. +This source continues #191/#192 after #210. It is not admitted for execution. +The exact successor task controller, runtime manifest and controller digest are +bound by `SDK_R`, `SDK_M` and `SDK_CODE_SHA` in `window.py`. The outer controls +load that selected Git blob, independently of the working checkout's task code. +The successor constrains the guest work/cleanup/audit schedule to the unchanged +parent ceilings using a resident guardian's paired clock observation. + +The original `df617fb` / `bdbb4d4` source and private invocation stay frozen and +unexecuted. This successor requires its own source/input subject and private +invocation; it inherits no blocked-review PASS or operating authority. Linux +process epochs, guest/network controls, fixed task store and history acceptance +rules remain unchanged. The selected existing Intel macOS/VZ host cannot currently meet the task's 10-GiB available-memory gate while its default Colima profile runs. General owns diff --git a/scripts/acceptance/colima-window/window.py b/scripts/acceptance/colima-window/window.py index 267169a..8507b8e 100644 --- a/scripts/acceptance/colima-window/window.py +++ b/scripts/acceptance/colima-window/window.py @@ -20,9 +20,9 @@ import time import types -SDK_R = 'df617fb681958801182e67ca63e886041bec6de0' -SDK_M = '9ce029dcfb64220ff233e9f42605400ea4a73aceb598272a4034b3e8a65d6e38' -SDK_CODE_SHA = 'bf9dbd8166c71dd1b87bc5761640ce26d07e624d56b19d26e40e5ff02eeab9a1' +SDK_R = '3fbd145c5c36cc7c30b164ed8b3e94919441abf1' +SDK_M = 'f0ca803c10b7d43357dbb44539f2c4d6020f25f302b7a94651ee525f37aef070' +SDK_CODE_SHA = '5cd5584187b95587f10eb216fa34ead1f632555532353ed6a3d41841bcbfd297' KEYS = {'schema', 'token', 'source_sha256', 'host_boot_id', 'uid', 'scope', 'owner_process', 'started_unix', 'sdk_intent', 'tools', 'service', 'config_files', 'disks', 'docker_host', 'engine', 'profile_home'} From cc359c063085cfa6ef9227c7e216833c47b231dd Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:32:50 +0000 Subject: [PATCH 3/6] Isolate the expected clock preflight refusal in its control --- scripts/acceptance/condition-history/test_packet.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/acceptance/condition-history/test_packet.py b/scripts/acceptance/condition-history/test_packet.py index 48f54f0..ce21935 100644 --- a/scripts/acceptance/condition-history/test_packet.py +++ b/scripts/acceptance/condition-history/test_packet.py @@ -38,9 +38,13 @@ def test_preflight_refusals_precede_any_side_effect(self): for field, value in {'package_commit':'b'*40, 'run_token':'foreign', 'active_unix':0, 'started_unix':0, 'docker_endpoint':'tcp://foreign'}.items(): args = copy.copy(self.args);setattr(args, field, value) - with self.subTest(field=field), mock.patch.object(subject,'git',self.git), self.assertRaises(SystemExit): - subject.validate(args) - recovery.launch_clock(args) + with self.subTest(field=field), mock.patch.object(subject,'git',self.git): + check = subject.validate + if field in ('active_unix', 'started_unix'): + subject.validate(args) + check = recovery.launch_clock + with self.assertRaises(SystemExit): + check(args) self.assertFalse(pathlib.Path(args.work_dir).exists()) def test_wrong_h_refuses_under_python_optimization(self): From 9930dcfdc78b812bddcade537cd80ea02579ff7a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:33:14 +0000 Subject: [PATCH 4/6] Refresh the source binding after the test precision correction --- scripts/acceptance/colima-window/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/acceptance/colima-window/window.py b/scripts/acceptance/colima-window/window.py index 8507b8e..642a7fe 100644 --- a/scripts/acceptance/colima-window/window.py +++ b/scripts/acceptance/colima-window/window.py @@ -20,8 +20,8 @@ import time import types -SDK_R = '3fbd145c5c36cc7c30b164ed8b3e94919441abf1' -SDK_M = 'f0ca803c10b7d43357dbb44539f2c4d6020f25f302b7a94651ee525f37aef070' +SDK_R = 'cc359c063085cfa6ef9227c7e216833c47b231dd' +SDK_M = '756bc1830ac468e862a588fc980d9e9a50470dd3738173727092130e40aba726' SDK_CODE_SHA = '5cd5584187b95587f10eb216fa34ead1f632555532353ed6a3d41841bcbfd297' KEYS = {'schema', 'token', 'source_sha256', 'host_boot_id', 'uid', 'scope', 'owner_process', 'started_unix', 'sdk_intent', 'tools', 'service', 'config_files', 'disks', From 86f65e1ca1b01baaa68f8d7256a7699dfda5f828 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:39:42 +0000 Subject: [PATCH 5/6] Retain the resident clock receipt digest through final audit --- .../acceptance/condition-history/README.md | 5 ++- .../condition-history/guest-host.py | 18 ++++++++-- .../condition-history/guest-proposal.md | 4 ++- .../condition-history/test_guest_host.py | 34 +++++++++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/acceptance/condition-history/README.md b/scripts/acceptance/condition-history/README.md index 6006054..f80ab03 100644 --- a/scripts/acceptance/condition-history/README.md +++ b/scripts/acceptance/condition-history/README.md @@ -171,7 +171,10 @@ Standalone recovery does not depend on a mutable checkout entry point. Recovery records use schema `condition-run/v5`, binding the same parent clock and derived monotonic deadlines through recovery, summary, guest audit and the -independent host audit. Command timeouts and recovery wakeups use those fixed +independent host audit. The resident host guardian retains its produced clock +digest in memory, checks it before accepting finish, and carries it in the +cleanup receipt checked by independent host audit. Recomputing export digests +cannot replace that clock observation. Command timeouts and recovery wakeups use those fixed monotonic deadlines; changing UTC cannot grant more time. The owner writes `summary.json` with `O_EXCL|O_NOFOLLOW`, mode 0600, and returns diff --git a/scripts/acceptance/condition-history/guest-host.py b/scripts/acceptance/condition-history/guest-host.py index 4b9a150..46d6ea3 100644 --- a/scripts/acceptance/condition-history/guest-host.py +++ b/scripts/acceptance/condition-history/guest-host.py @@ -561,9 +561,19 @@ def finish_requested(record, record_sha): value = json.loads(regular(marker, 64 * 1024)) _, export_sha = export_handoff(record, record_sha) require(value == {'record_sha256': record_sha, 'exports_sha256': export_sha}, 'wrong cleanup request authority') + clock_receipt(record) return True +def clock_receipt(record): + expected = record.get('_clock_sha256') + require(isinstance(expected, str) and re.fullmatch('[0-9a-f]{64}', expected), + 'resident clock receipt not captured') + raw = regular(paths(record['parent'], record['token'])['parent'] / 'guest-clock.json') + require(digest(raw) == expected, 'resident clock receipt changed') + return json.loads(raw) + + def effective_config(record, status): scoped = paths(record['parent'], record['token']) value = json.loads(call(record, [record['limactl'], 'list', '--json', '--all-fields', scoped['name']])) @@ -690,7 +700,7 @@ def capture_guest_clock(record, record_sha): host_cleanup_unix=record['cleanup_unix'], host_active_monotonic_ns=active, host_cleanup_monotonic_ns=active + 60_000_000_000, host_before_monotonic_ns=before, host_after_monotonic_ns=after) - exclusive(scoped['parent'] / 'guest-clock.json', clock) + record['_clock_sha256'] = exclusive(scoped['parent'] / 'guest-clock.json', clock) return clock @@ -732,11 +742,12 @@ def audit(record, record_sha, source_sha): and result['guest_absent'] is True and result['store_unmounted'] is True and result['image_absent'] is True, 'external cleanup did not pass') exports, export_sha = export_handoff(record, record_sha) + record['_clock_sha256'] = result.get('clock_sha256') require(finish_requested(record, record_sha), 'completed guest export request required') guest = json.loads(regular(scoped['parent'] / 'guest-summary.json')) guest_audit = json.loads(regular(scoped['parent'] / 'guest-audit.json')) started = json.loads(regular(scoped['parent'] / 'vm-started.json')) - clock = json.loads(regular(scoped['parent'] / 'guest-clock.json')) + clock = clock_receipt(record) require(guest.get('parent_clock') == clock and guest_audit.get('parent_clock') == clock and clock['host_record_sha256'] == record_sha and clock['host_boot_id'] == record['host_boot_id'] and clock['host_started_unix'] == record['started_unix'] @@ -816,7 +827,8 @@ def main(argv=None): 'custody': 'external_owner_must_resolve_retained_task_objects'} if control_error: result.update(outcome='fail', control_error_type=control_error) - result.update(record_sha256=args.record_sha256, guardian_sha256=source_sha) + result.update(record_sha256=args.record_sha256, guardian_sha256=source_sha, + clock_sha256=record.get('_clock_sha256')) exclusive(scoped['parent'] / 'host-cleanup.json', result) require(result['outcome'] != 'fail', 'external cleanup incomplete') else: diff --git a/scripts/acceptance/condition-history/guest-proposal.md b/scripts/acceptance/condition-history/guest-proposal.md index 3f250aa..823661e 100644 --- a/scripts/acceptance/condition-history/guest-proposal.md +++ b/scripts/acceptance/condition-history/guest-proposal.md @@ -160,7 +160,9 @@ used for native controls. original monotonic remaining budget around a captured read-only guest Unix/monotonic/boot probe. Both observation intervals must be at most five seconds. Its exclusive `guest-clock.json` binds those domains and the host - authority digest. A setup/launch with less than 600 seconds of work plus + authority digest. The guardian retains that receipt's digest in memory and + refuses a finish handoff if the file changes. Its final cleanup receipt + carries the same digest for independent host audit. A setup/launch with less than 600 seconds of work plus 180 seconds of cleanup/audit reserve refuses before allocation or start. Capture fresh guest G and invoke the README entry with B and this exact parent clock JSON/digest. Guest active is `min(G+1740, mapped_parent_active-180)` diff --git a/scripts/acceptance/condition-history/test_guest_host.py b/scripts/acceptance/condition-history/test_guest_host.py index 63cd883..d03bd38 100644 --- a/scripts/acceptance/condition-history/test_guest_host.py +++ b/scripts/acceptance/condition-history/test_guest_host.py @@ -94,7 +94,7 @@ def exports(self): clock = parent_clock(self.record['started_unix']) self.record['host_boot_id'] = clock['host_boot_id'] for key in ('started_unix', 'active_unix', 'cleanup_unix', 'active_monotonic_ns', 'cleanup_monotonic_ns'):clock['host_'+key] = self.record[key] - self.put('guest-clock.json', clock) + self.record['_clock_sha256'] = self.put('guest-clock.json', clock) storage = {'guest_boot_id': BOOT} summary_sha = self.put('guest-summary.json', {'runner_commit': self.record['runner_commit'], 'manifest_sha256': self.record['manifest_sha256'], 'checks_outcome': 'pass', 'storage': storage, 'parent_clock': clock}) @@ -123,7 +123,7 @@ def audit_files(self): 'agent': {'guest_boot_id': BOOT, 'sha256': self.record['guest_agent_binary_sha256']}}) self.put('host-cleanup.json', {'record_sha256': 'd' * 64, 'guardian_sha256': 'c' * 64, 'outcome': 'pending_independent_host_audit', 'guest_absent': True, - 'store_unmounted': True, 'image_absent': True}) + 'store_unmounted': True, 'image_absent': True, 'clock_sha256': self.record['_clock_sha256']}) def test_independent_host_audit_requires_guest_proof_and_both_processes_absent(self): self.audit_files() @@ -478,6 +478,7 @@ def test_resident_clock_receipt_binds_original_parent_and_guest_domains(self): self.assertEqual(clock['host_after_monotonic_ns']-clock['host_before_monotonic_ns'],2000000000) self.assertEqual(clock['guest_unix_ns'],sample['guest_unix_ns']) self.assertEqual(json.loads((self.parent/'guest-clock.json').read_bytes()),clock) + self.assertEqual(self.record['_clock_sha256'], host.digest(host.canonical(clock))) with mock.patch.object(host,'call',return_value=host.canonical(sample)),self.assertRaises(FileExistsError): host.capture_guest_clock(self.record,'d'*64) @@ -522,9 +523,36 @@ def test_final_host_audit_rejects_rebound_clock_even_with_matching_exports(self) digests[name]=self.put(name,value) export_sha=self.put('exports.json',dict(record_sha256='d'*64,guest_boot_id=BOOT,**digests)) self.put('finish.json',{'record_sha256':'d'*64,'exports_sha256':export_sha}) - with mock.patch.object(host,'process_identity',return_value=None),self.assertRaisesRegex(SystemExit,'clock/parent'): + with mock.patch.object(host,'process_identity',return_value=None),self.assertRaisesRegex(SystemExit,'clock'): host.audit(self.record,'d'*64,'c'*64) + def test_actual_clock_draft_admits_rebound_samples_but_resident_digest_refuses(self): + self.audit_files() + old_source = subprocess.check_output(['git', 'show', + 'cc359c063085cfa6ef9227c7e216833c47b231dd:scripts/acceptance/condition-history/guest-host.py'], text=True) + old = types.ModuleType('unbound_clock_draft') + exec(compile(old_source, 'git:cc359c0:guest-host.py', 'exec'), old.__dict__) + clock = json.loads((self.parent / 'guest-clock.json').read_bytes()) + clock['host_after_monotonic_ns'] -= 100_000_000_000 + self.put('guest-clock.json', clock) + digests = {} + for name in ('guest-summary.json', 'guest-audit.json'): + value = json.loads((self.parent / name).read_bytes());value['parent_clock'] = clock + if name == 'guest-audit.json': + value['summary_sha256'] = digests['guest-summary.json'] + digests[name] = self.put(name, value) + export_sha = self.put('exports.json', dict(record_sha256='d'*64, guest_boot_id=BOOT, **digests)) + self.put('finish.json', {'record_sha256':'d'*64, 'exports_sha256':export_sha}) + self.assertTrue(old.finish_requested(self.record, 'd'*64)) + with self.assertRaisesRegex(SystemExit, 'clock receipt changed'): + host.finish_requested(self.record, 'd'*64) + with mock.patch.object(host, 'process_identity', return_value=None), \ + mock.patch.object(old, 'process_identity', return_value=None): + self.assertEqual(old.audit(self.record, 'd'*64, 'c'*64)['outcome'], 'pass') + (self.parent / 'host-audit.json').unlink() + with self.assertRaisesRegex(SystemExit, 'clock receipt changed'): + host.audit(self.record, 'd'*64, 'c'*64) + def test_reopening_host_authority_never_renews_monotonic_ceilings(self): self.store_fixture() From a689a2fba28db34c888777c9ed5686afe5c615e2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:40:08 +0000 Subject: [PATCH 6/6] Select the clock receipt integrity successor --- scripts/acceptance/colima-window/window.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/acceptance/colima-window/window.py b/scripts/acceptance/colima-window/window.py index 642a7fe..ac4aab3 100644 --- a/scripts/acceptance/colima-window/window.py +++ b/scripts/acceptance/colima-window/window.py @@ -20,9 +20,9 @@ import time import types -SDK_R = 'cc359c063085cfa6ef9227c7e216833c47b231dd' -SDK_M = '756bc1830ac468e862a588fc980d9e9a50470dd3738173727092130e40aba726' -SDK_CODE_SHA = '5cd5584187b95587f10eb216fa34ead1f632555532353ed6a3d41841bcbfd297' +SDK_R = '86f65e1ca1b01baaa68f8d7256a7699dfda5f828' +SDK_M = '2b0cfb21bd1a051bf8222879a304dadb87e93a4873693eb6e6978671313ca715' +SDK_CODE_SHA = '006f6e0e9f7379a4593b2a73f4e77ff271da24b308ce497e3ca0aa54b210e603' KEYS = {'schema', 'token', 'source_sha256', 'host_boot_id', 'uid', 'scope', 'owner_process', 'started_unix', 'sdk_intent', 'tools', 'service', 'config_files', 'disks', 'docker_host', 'engine', 'profile_home'}