Verify bounded ordinary-input approach to the prison gate

This commit is contained in:
2026-09-19 19:21:21 +02:00
parent b7fba094d7
commit ec1071595d
90 changed files with 102832 additions and 35 deletions
+220 -12
View File
@@ -51,6 +51,8 @@ def game_arguments(owner, args):
raise Refused('Native observer nonce mismatch')
if getattr(args, 'enable_private_look_proof', False) and option(native['args'], '-errOrdinaryLookObserve') != args.nonce:
raise Refused('Native look observer nonce mismatch')
if getattr(args, 'enable_private_approach_proof', False) and option(native['args'], '-errOrdinaryGateObserve') != args.nonce:
raise Refused('Native gate observer nonce mismatch')
if option(native['args'], '-errOrdinaryScenario') not in ('core', 'chat'):
raise Refused('Not an ordinary successor scenario')
if option(native['args'], '-profile') != 'Z:' + str(args.profile):
@@ -115,8 +117,9 @@ def yaw_delta(first, second):
class RelativeMouse:
def __init__(self, query, keyboard, owner):
def __init__(self, query, keyboard, owner, enable_approach=False):
self.query, self.keyboard, self.owner = query, keyboard, owner
self.enable_approach = enable_approach
self.xt = keyboard.xt
self.xt.XTestFakeRelativeMotionEvent.restype = C.c_int
self.xt.XTestFakeRelativeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_ulong]
@@ -148,12 +151,27 @@ class RelativeMouse:
def move(self, dx, dy, guard):
if type(dx) is not int or dx not in (-24, 24) or dy != 0 or type(dy) is not int:
raise Refused('Unsupported relative look step')
self._move(dx, dy, guard)
def approach_move(self, dx, dy, guard, *, deadline, clock=time.monotonic):
if (self.enable_approach is not True or type(dx) is not int or not 1 <= abs(dx) <= 128
or type(dy) is not int or dy != 0):
raise Refused('Unsupported or unenabled approach relative step')
def within_lease():
if clock() >= deadline:
raise Refused("Original approach deadline exceeded before relative input")
within_lease()
self._move(dx, dy, guard, before_send=within_lease)
def _move(self, dx, dy, guard, before_send=None):
target = guard.check()
self.keyboard.require_idle()
self.validate_pointer(target, dx, dy)
# Recheck process/focus after the pointer roundtrip, with no focus changes.
target = guard.check()
self.validate_pointer(target, dx, dy)
if before_send is not None:
before_send()
if not self.xt.XTestFakeRelativeMotionEvent(self.query.d, dx, dy, 0):
raise Refused('Native relative motion request failed')
self.query.x.XFlush(self.query.d)
@@ -252,7 +270,7 @@ class Observer:
"""Only records appended after this exact owned console was opened count."""
pattern = re.compile(r'ERR INPUT OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=(\d+(?:\.\d+)?) (.+)$')
def __init__(self, path, nonce, clock=time.monotonic, enable_look=False):
def __init__(self, path, nonce, clock=time.monotonic, enable_look=False, enable_approach=False):
self.path, self.nonce, self.clock = path, nonce, clock
self.fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
info = os.fstat(self.fd)
@@ -269,7 +287,14 @@ class Observer:
self.raw = []
self.bytes = 0
self.terminal = False
if enable_approach and not enable_look:
os.close(self.fd)
raise Refused("Approach observation requires native look")
self.enable_look = enable_look
self.enable_approach = enable_approach
self.approach_lines = []; self.gate_records = []; self.gate_received = {}
self.gate_complete_end = 0; self.approach_previous = None
self.ready_at = self.ready_real = self.queued_real = None
self.look_records = []; self.input_samples = []; self.ready_sample = None
def close(self):
@@ -289,6 +314,20 @@ class Observer:
lines = self.pending.split(b'\n'); self.pending = lines.pop()
for raw in lines:
line = raw.decode('utf-8', 'strict').rstrip('\r')
if self.enable_approach and any(prefix in line for prefix in ('ERR INPUT OBSERVE:', 'ERR LOOK OBSERVE:', 'ERR GATE OBSERVE:')):
if len(self.approach_lines) >= 1900:
raise Refused('Native approach record bound exceeded')
self.approach_lines.append(line)
if self.enable_approach and 'ERR GATE OBSERVE:' in line:
from ordinary_gate_evidence import parse_gate_record
record = parse_gate_record(line, self.nonce)
self.gate_records.append(record)
if len(self.gate_records) > 902:
raise Refused('Native gate record bound exceeded')
if record['kind'] == 'geometry':
self.gate_complete_end = len(self.approach_lines)
self.gate_received.setdefault(record['sample'], self.clock())
continue
if self.enable_look and 'ERR LOOK OBSERVE:' in line:
record = parse_look_record(line, self.nonce)
if len(self.look_records) >= 453:
@@ -326,11 +365,16 @@ class Observer:
self.input_samples.append(parse_stationary_sample(sample, real, detail))
self.sample, self.real, self.world = sample, real, world
self.raw.append(line)
if self.enable_approach and detail == 'QUEUED':
if self.queued_real is not None:
raise Refused('Repeated native queued clock')
self.queued_real = real
if detail == 'READY':
if self.events or self.last_sample_at is None:
raise Refused('Repeated or unbound READY')
self.events.append('READY')
self.ready_sample = sample
self.ready_at = self.clock(); self.ready_real = real
elif detail in ('INVENTORY_OPEN', 'INVENTORY_CLOSED'):
expected = ['READY'] if detail == 'INVENTORY_OPEN' else ['READY', 'INVENTORY_OPEN']
if self.events != expected:
@@ -347,6 +391,47 @@ class Observer:
raise Refused('Native observer samples are stale')
def approach_data(self):
"""Reparse complete raw triples, retaining incomplete final pairs for next poll."""
from ordinary_gate_evidence import join_gate_snapshots, validate_gate_geometry
self.fresh()
if (not self.enable_approach or self.events != ['READY'] or not self.gate_complete_end or
any(r['kind'] == 'cancelled' for r in self.gate_records + self.look_records)):
raise Refused('Native approach context is unavailable or ended')
content = '\n'.join(self.approach_lines[:self.gate_complete_end])
data = join_gate_snapshots(content, self.nonce)
index = max(data['snapshots'])
geometry = validate_gate_geometry(content, self.nonce, data['ready']['sample'], index)
if not geometry['verified']:
raise Refused('Native gate geometry refused: ' + '; '.join(geometry['errors']))
for item in data['snapshots'].values():
action = item['action']
if item['sample'] >= data['ready']['sample'] and (action['pressed'] != 0 or action['performing'] or action['progress'] != 0):
raise Refused('Gate action input or progress changed during approach-only observation')
if (self.clock() - self.gate_received[index] > .5 or
self.input_samples[-1]['real_ms'] - data['snapshots'][index]['real_ms'] > 500):
raise Refused('Complete native gate snapshot is stale')
return content, data
def approach_snapshots(self):
_, data = self.approach_data()
snapshots = data['snapshots']; latest = max(snapshots)
first = latest if self.approach_previous is None else min(self.approach_previous + 1, latest)
if self.approach_previous is not None and latest < self.approach_previous:
raise Refused('Native approach snapshot index reversed')
result = []
for index in range(first, latest + 1):
item = snapshots[index]; geo, action, native, pose = item['geometry'], item['action'], item['input'], item['look']
result.append({'sample': index, 'real_ms': item['real_ms'], 'received_monotonic': self.gate_received[index],
'origin': native['origin'], 'direction': pose['direction'], 'body_forward': pose['body_forward'],
'input': native['input'], 'forward': native['forward'], 'velocity': native['velocity'],
'inventory': native['inventory'], 'phase': action['phase'], 'door_state': geo['door_state'], 'door_control': geo['door_control'],
'geometry': {'nonce': self.nonce, 'basis': 'registered-prison-lock-native-interaction',
**{key: geo[key] for key in ('prison', 'gate', 'gate_forward', 'context', 'radius')}}})
self.approach_previous = latest
return result
class Keyboard:
"""Only W/Tab key events on the pre-existing explicit owned connection."""
def __init__(self, query):
@@ -439,7 +524,7 @@ def confirm_release(guard, keyboard, report, key, clock=time.monotonic, sleep=ti
observation['end'] = clock()
def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep, mouse=None):
def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep, mouse=None, approach=False):
deadline = clock() + 70
held = None
report.update(events=[], paired_release_errors=[], sequence_completed=False)
@@ -493,11 +578,26 @@ def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time
try:
wait_for('READY', 45)
deadline = clock() + 12
if approach:
if mouse is None or not observer.enable_approach or observer.ready_at is None:
raise Refused('Approach requires explicit look and gate observation')
deadline = observer.ready_at + 35
if clock() >= deadline:
raise Refused('Original approach deadline exceeded before calibration')
notify({'kind': 'approach-pending', 'deadline_monotonic': deadline})
if observer.events != ['READY']:
raise Refused('Inventory was already changed before input')
if mouse is not None:
perform_look(guard, keyboard, observer, mouse, report, clock, sleep)
deadline = clock() + 12
if not approach:
deadline = clock() + 12
if approach:
run_approach(guard, keyboard, observer, mouse, report, clock, sleep)
if clock() >= deadline:
raise Refused("Original approach deadline exceeded")
keyboard.require_idle()
report["sequence_completed"] = True
return
pulse('W', 0.30)
check()
pulse('Tab', 0.05)
@@ -526,20 +626,86 @@ def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time
except (OSError, ValueError) as error:
report['paired_release_errors'].append(str(error))
report['unconfirmed_held_key'] = held if report['paired_release_errors'] else None
report['all_attempted_presses_released'] = not report.get('unconfirmed_held_key')
if approach:
route = report.get('approach_proof', {})
report['paired_release_errors'].extend(route.get('paired_release_errors', []))
report['unconfirmed_held_key'] = route.get('unconfirmed_held_key')
notify({'kind': 'approach-finished', 'successful': report['sequence_completed']})
report['all_attempted_presses_released'] = not report.get('unconfirmed_held_key') and not report['paired_release_errors']
class ApproachKeyboard:
"""W-only capability for the separate approach module; never F/Tab."""
def __init__(self, keyboard, report, clock, deadline):
self.keyboard, self.report, self.clock, self.deadline = keyboard, report, clock, deadline
def require_idle(self):
self.keyboard.require_idle()
def send(self, key, down):
if key != 'W' or type(down) is not bool:
raise Refused('Approach permits W key pairs only')
if down and self.clock() >= self.deadline:
raise Refused('Original approach deadline exceeded before W press')
self.report['input_attempted'] = True
self.keyboard.send(key, down)
self.report['events'].append({'key': key, 'down': down, 'monotonic': self.clock()})
self.report['input_sent'] = True
class ApproachMouse:
def __init__(self, mouse, report, clock, deadline):
self.mouse, self.report, self.clock, self.deadline = mouse, report, clock, deadline
def move(self, dx, dy, guard):
self.report['input_attempted'] = True
self.mouse.approach_move(dx, dy, guard, deadline=self.deadline, clock=self.clock)
self.report['input_sent'] = True
def run_approach(guard, keyboard, observer, mouse, report, clock, sleep):
from ordinary_look_evidence import validate_look_evidence
from prison_approach_proof import perform_approach
content, data = observer.approach_data()
validated = validate_look_evidence(content, observer.nonce, report['look_proof'])
if not validated['verified']:
raise Refused('Native approach calibration refused: ' + '; '.join(validated['errors']))
poses = {r['sample']: r for r in observer.look_records if r['kind'] == 'sample'}
proof = report['look_proof']
if observer.queued_real is None or observer.ready_at is None or observer.ready_real is None:
raise Refused('Original native observation clocks missing')
calibration = {'nonce': observer.nonce, 'completed': True, 'pixels': 24,
'baseline_direction': poses[proof['baseline_samples'][-1]]['direction'],
'response_direction': poses[proof['response_sample']]['direction'],
'ready_real_ms': observer.ready_real, 'ready_monotonic': observer.ready_at,
'observer_start_real_ms': observer.queued_real}
geometry = observer.approach_snapshots()[-1]['geometry']
# The adapter has only read here. The module's initial snapshot can repeat
# this latest sample; every subsequent native sample is returned in order.
def route_notify(event):
# Only the sequence owner can mark completion after its final checks.
if event.get("kind") != "approach-finished":
notify(event)
deadline = observer.ready_at + 35
perform_approach(guard, ApproachKeyboard(keyboard, report, clock, deadline), ApproachMouse(mouse, report, clock, deadline),
observer.approach_snapshots, geometry, calibration, report,
clock=clock, sleep=sleep, notify=route_notify, key_state_busy=KeyStateBusy)
if not report.get('approach_proof', {}).get('completed'):
raise Refused('Approach module did not complete')
def arguments(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--enable-private-keyboard-proof', action='store_true', required=True)
p.add_argument('--enable-private-look-proof', action='store_true')
p.add_argument('--enable-private-approach-proof', action='store_true', help='After look calibration, replace W/Tab cycle with bounded interior approach only')
for name in ('prefix', 'game-executable', 'profile', 'report'):
p.add_argument('--' + name, type=Path, required=True)
p.add_argument('--console', type=Path, help='Existing exact session; omit only in wrapper mode')
p.add_argument('--host-display', required=True)
p.add_argument('--nonce', required=True)
p.add_argument('command', nargs=argparse.REMAINDER)
return p.parse_args(argv)
args = p.parse_args(argv)
if args.enable_private_approach_proof and not args.enable_private_look_proof:
p.error("Approach proof requires explicit private look proof")
return args
def wrapper_command(args):
@@ -569,6 +735,8 @@ def wrapper_command(args):
raise Refused('Wrapped native observer context differs')
if args.enable_private_look_proof and option(native, '-errOrdinaryLookObserve') != args.nonce:
raise Refused('Wrapped native look observer context differs')
if getattr(args, 'enable_private_approach_proof', False) and option(native, '-errOrdinaryGateObserve') != args.nonce:
raise Refused('Wrapped native gate observer context differs')
return command
@@ -589,6 +757,31 @@ def console_path(args):
raise Refused('Expected exact canonical native session console')
APPROACH_MODULES = ('prison_approach_proof.py', 'ordinary_gate_evidence.py',
'ordinary_look_evidence.py', 'prison_approach_evidence.py')
def approach_module_hashes():
folder = Path(__file__).resolve().parent
return {name: hashlib.sha256((folder / name).read_bytes()).hexdigest() for name in APPROACH_MODULES}
def report_for_write(report):
"""Keep raw console as pose authority; archive bounded references, not duplicate poses."""
if report.get('approach_requested') is not True:
return report
result = dict(report)
route = report.get('approach_proof')
if isinstance(route, dict):
result['approach_proof'] = dict(route)
samples = route.get('samples')
if isinstance(samples, list):
result['approach_proof']['samples'] = [
{key: sample.get(key) for key in ('sample', 'real_ms', 'received_monotonic')}
if isinstance(sample, dict) else {'invalid_reference': True} for sample in samples]
return result
def main(argv=None):
args = arguments(argv)
args.observe_seconds = 25; args.query_worker = False
@@ -601,11 +794,14 @@ def main(argv=None):
bound = display.binding(args.host_display, args.prefix)
report = {'schema': 1, 'kind': 'headless-private-keyboard-experiment', 'input_sent': False,
'look_requested': args.enable_private_look_proof,
'approach_requested': getattr(args, 'enable_private_approach_proof', False),
'sequence_completed': False, 'release_qualified': False, 'gameplay_qualified': False,
'hard_supervision': NOTIFY is not None, 'nonce': args.nonce, 'binding': bound, 'command': command, 'report': str(args.report), 'input_attempted': False,
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'ownership_helper_sha256': hashlib.sha256(Path(display.__file__).read_bytes()).hexdigest(),
'prefix': str(args.prefix), 'profile': str(args.profile), 'game_executable': str(args.game_executable)}
if report['approach_requested']:
report['approach_module_sha256'] = approach_module_hashes()
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
info = os.fstat(fd)
notify({'kind': 'report', 'path': str(args.report), 'device': info.st_dev, 'inode': info.st_ino,
@@ -642,7 +838,8 @@ def main(argv=None):
if args.console is None:
raise Refused('New native console deadline exceeded')
report['console'] = str(args.console)
observer = Observer(args.console, args.nonce, enable_look=args.enable_private_look_proof)
observer = Observer(args.console, args.nonce, enable_look=args.enable_private_look_proof,
enable_approach=getattr(args, "enable_private_approach_proof", False))
query = display.X11(bound['display'])
while time.monotonic() < deadline:
if child is not None and child.poll() is not None:
@@ -669,8 +866,8 @@ def main(argv=None):
guard.check()
keyboard = Keyboard(query)
keyboard.require_idle()
mouse = RelativeMouse(query, keyboard, owner) if args.enable_private_look_proof else None
sequence(guard, keyboard, observer, report, mouse=mouse)
mouse = RelativeMouse(query, keyboard, owner, enable_approach=getattr(args, "enable_private_approach_proof", False)) if args.enable_private_look_proof else None
sequence(guard, keyboard, observer, report, mouse=mouse, approach=getattr(args, "enable_private_approach_proof", False))
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
report['error'] = str(error)
finally:
@@ -680,12 +877,14 @@ def main(argv=None):
'end_offset': observer.offset, 'records': observer.raw, 'events': observer.events}
if args.enable_private_look_proof:
report['look_observation'] = observer.look_records
if getattr(args, "enable_private_approach_proof", False):
report["gate_observation"] = observer.gate_records
observer.close()
if query:
query.close()
with os.fdopen(fd, 'w') as stream:
fd = None
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
json.dump(report_for_write(report), stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
notify({'kind': 'proof-finished', 'successful': report['sequence_completed'] and not report.get('error')})
child_code = 0
try:
@@ -773,6 +972,7 @@ def supervise(argv=None):
os.kill(child.pid, signum)
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[signum] = signal.signal(signum, forward)
approach_deadline = None; approach_finished = False
look_at = None; held_at = None; held_key = None; owned_child = None; report_record = None; proof_finished = False; expired = False
deadline = time.monotonic() + 190
try:
@@ -783,7 +983,15 @@ def supervise(argv=None):
event = parent.recv()
except EOFError:
event = None
if event and event['kind'] == 'look-pending':
if event and event['kind'] == 'approach-pending':
proposed = event.get('deadline_monotonic')
current = time.monotonic()
if type(proposed) not in (int, float) or not math.isfinite(proposed) or proposed > current + 35:
proposed = current # malformed lease fails closed
approach_deadline = proposed if approach_deadline is None else min(approach_deadline, proposed)
elif event and event['kind'] == 'approach-finished':
approach_finished = bool(event.get('successful'))
elif event and event['kind'] == 'look-pending':
look_at = time.monotonic()
elif event and event['kind'] == 'look-finished':
look_at = None
@@ -798,7 +1006,7 @@ def supervise(argv=None):
elif event and event['kind'] == 'proof-finished':
proof_finished = event['successful']
now = time.monotonic()
if received or now >= deadline or (held_at is not None and now - held_at > 1.5) or (look_at is not None and now - look_at > 4):
if received or now >= deadline or (approach_deadline is not None and not approach_finished and now >= approach_deadline) or (held_at is not None and now - held_at > 1.5) or (look_at is not None and now - look_at > 4):
expired = True
child.terminate() # asks worker finally to release if still provable
child.join(1)