230 lines
7.9 KiB
Python
230 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Inactive Phase-1.0X transport orchestration with injected adapters only.
|
|
|
|
This module has no socket, DNS, CLI, target address, byte-command formatter or
|
|
live prompt detector. It implements local exclusive evidence and orchestrates
|
|
synthetic adapter boundaries under an injected monotonic clock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Protocol
|
|
|
|
from phase10v_shsrv_collector_model import CollectorError, OfflineCollector
|
|
from phase10w_shsrv_client_policy import (
|
|
COLLECTOR_SHA256,
|
|
SessionPlan,
|
|
)
|
|
|
|
|
|
POLICY_SHA256 = \
|
|
"747d23c88f2722e8e8846599c3ac1dae3826eb3fca881caaad36b251f30f3592"
|
|
MAX_BOUNDARY_CHUNKS = 64
|
|
|
|
|
|
class SessionFailure(RuntimeError):
|
|
"""Generic failure that never embeds adapter data or target values."""
|
|
|
|
|
|
class EvidenceFailure(RuntimeError):
|
|
"""Generic exclusive-evidence failure."""
|
|
|
|
|
|
class MonotonicClock(Protocol):
|
|
def monotonic(self) -> float: ...
|
|
|
|
|
|
class InjectedSessionAdapter(Protocol):
|
|
def open_once(self, plan: SessionPlan, remaining_seconds: float) -> None: ...
|
|
def receive_boundary(
|
|
self, boundary: str, remaining_seconds: float,
|
|
) -> list[bytes]: ...
|
|
def send_command_token(
|
|
self, command: str, exact_path: str | None, remaining_seconds: float,
|
|
) -> None: ...
|
|
def close_once(self) -> None: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceRecord:
|
|
path: Path
|
|
size: int
|
|
sha256: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionOutcome:
|
|
receipt: EvidenceRecord
|
|
output: EvidenceRecord
|
|
classification: str
|
|
exact_identity: bool
|
|
|
|
|
|
class ExclusiveEvidenceStore:
|
|
"""Write local JSON once with O_EXCL; never overwrite or clean up."""
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
self.root = root
|
|
|
|
@staticmethod
|
|
def _encode(record: dict[str, Any]) -> bytes:
|
|
return (json.dumps(
|
|
record, sort_keys=True, separators=(",", ":"), ensure_ascii=True,
|
|
) + "\n").encode("ascii")
|
|
|
|
def _create(self, filename: str, record: dict[str, Any]) -> EvidenceRecord:
|
|
try:
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
path = self.root / filename
|
|
payload = self._encode(record)
|
|
except (OSError, TypeError, ValueError) as error:
|
|
raise EvidenceFailure("exclusive evidence preparation failed") from error
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
if hasattr(os, "O_BINARY"):
|
|
flags |= os.O_BINARY
|
|
descriptor = -1
|
|
close_failure: OSError | None = None
|
|
try:
|
|
descriptor = os.open(path, flags, 0o600)
|
|
offset = 0
|
|
while offset < len(payload):
|
|
written = os.write(descriptor, payload[offset:])
|
|
if written <= 0:
|
|
raise OSError("short local evidence write")
|
|
offset += written
|
|
os.fsync(descriptor)
|
|
except OSError as error:
|
|
raise EvidenceFailure("exclusive evidence creation failed") from error
|
|
finally:
|
|
if descriptor >= 0:
|
|
try:
|
|
os.close(descriptor)
|
|
except OSError as error:
|
|
close_failure = error
|
|
if close_failure is not None:
|
|
raise EvidenceFailure("exclusive evidence close failed") from close_failure
|
|
try:
|
|
reopened = path.read_bytes()
|
|
except OSError as error:
|
|
raise EvidenceFailure("exclusive evidence reopen failed") from error
|
|
if reopened != payload:
|
|
raise EvidenceFailure("exclusive evidence reopen mismatch")
|
|
return EvidenceRecord(
|
|
path=path,
|
|
size=len(reopened),
|
|
sha256=hashlib.sha256(reopened).hexdigest(),
|
|
)
|
|
|
|
def create_consumed_receipt(
|
|
self, plan: SessionPlan, monotonic_value: float,
|
|
) -> EvidenceRecord:
|
|
return self._create(f"{plan.run_id}.consumed.json", {
|
|
"schema_version": 1,
|
|
"status": "CONSUMED_BEFORE_ADAPTER_OPEN",
|
|
"run_id": plan.run_id,
|
|
"policy_sha256": POLICY_SHA256,
|
|
"collector_sha256": COLLECTOR_SHA256,
|
|
"window": plan.window,
|
|
"deadline_seconds": plan.deadline_seconds,
|
|
"created_monotonic": monotonic_value,
|
|
"target_retained": False,
|
|
"retry_allowed": False,
|
|
})
|
|
|
|
def create_sanitized_output(
|
|
self, plan: SessionPlan, receipt: EvidenceRecord,
|
|
sanitized: dict[str, Any],
|
|
) -> EvidenceRecord:
|
|
return self._create(f"{plan.run_id}.sanitized.json", {
|
|
"schema_version": 1,
|
|
"status": "SANITIZED_OUTPUT_COMPLETE",
|
|
"run_id": plan.run_id,
|
|
"receipt_sha256": receipt.sha256,
|
|
"raw_transcript_persisted": False,
|
|
"result": sanitized,
|
|
})
|
|
|
|
|
|
def _remaining(clock: MonotonicClock, deadline: float) -> float:
|
|
remaining = deadline - clock.monotonic()
|
|
if remaining <= 0:
|
|
raise SessionFailure("session deadline reached")
|
|
return remaining
|
|
|
|
|
|
def _feed_boundary(
|
|
collector: OfflineCollector, chunks: list[bytes],
|
|
) -> None:
|
|
if not isinstance(chunks, list) or len(chunks) > MAX_BOUNDARY_CHUNKS:
|
|
raise SessionFailure("adapter boundary is invalid")
|
|
try:
|
|
for chunk in chunks:
|
|
collector.feed(chunk)
|
|
except CollectorError as error:
|
|
raise SessionFailure("collector rejected adapter input") from error
|
|
|
|
|
|
def run_injected_session(
|
|
plan: SessionPlan,
|
|
adapter: InjectedSessionAdapter,
|
|
clock: MonotonicClock,
|
|
evidence: ExclusiveEvidenceStore,
|
|
) -> SessionOutcome:
|
|
"""Run one injected session; never retries and never performs live I/O."""
|
|
start = clock.monotonic()
|
|
deadline = start + plan.deadline_seconds
|
|
receipt = evidence.create_consumed_receipt(plan, start)
|
|
collector = OfflineCollector()
|
|
open_attempted = False
|
|
sanitized: dict[str, Any] | None = None
|
|
primary_failure: SessionFailure | None = None
|
|
try:
|
|
open_attempted = True
|
|
adapter.open_once(plan, _remaining(clock, deadline))
|
|
chunks = adapter.receive_boundary(
|
|
"INITIAL_PROMPT", _remaining(clock, deadline))
|
|
_feed_boundary(collector, chunks)
|
|
for command in plan.commands:
|
|
adapter.send_command_token(
|
|
command, plan.exact_literal_path, _remaining(clock, deadline))
|
|
chunks = adapter.receive_boundary(
|
|
f"AFTER_{command.upper()}", _remaining(clock, deadline))
|
|
_feed_boundary(collector, chunks)
|
|
_remaining(clock, deadline)
|
|
expected_paths = (
|
|
{plan.exact_literal_path}
|
|
if plan.exact_literal_path is not None else set()
|
|
)
|
|
sanitized = collector.finalize(expected_paths)
|
|
except Exception: # noqa: BLE001 - injected adapter boundary
|
|
# Adapter exceptions are deliberately normalized; raw messages never
|
|
# become evidence or user output.
|
|
primary_failure = SessionFailure("injected session failed")
|
|
finally:
|
|
if open_attempted:
|
|
try:
|
|
adapter.close_once()
|
|
except Exception: # noqa: BLE001 - injected adapter
|
|
if primary_failure is None:
|
|
primary_failure = SessionFailure("injected session close failed")
|
|
if primary_failure is not None:
|
|
raise primary_failure from None
|
|
if sanitized is None:
|
|
raise SessionFailure("sanitized result is missing")
|
|
try:
|
|
output = evidence.create_sanitized_output(plan, receipt, sanitized)
|
|
except EvidenceFailure as error:
|
|
raise SessionFailure("sanitized output creation failed") from error
|
|
return SessionOutcome(
|
|
receipt=receipt,
|
|
output=output,
|
|
classification=str(sanitized["classification"]),
|
|
exact_identity=bool(sanitized["exact_identity"]),
|
|
)
|