"""Read-only Phase 2 runtime introspection with a bounded SSH transport. The wrapper is deliberately sent as ``subprocess.run(input=payload)``. No JSON, ABI data, or base64 representation is placed in the Windows SSH argv; the default argv is the fixed, short command documented by ``DEFAULT_REMOTE_COMMAND``. The remote wrapper reads the current issuer unit's MainPID, WorkingDirectory, and PYTHONPATH, then resolves only the named importable modules. It does not walk release trees, ``/opt``, historical worktrees, SOUL, or MEMORY, and it does not call a database, signer, Bridge, Task API, or mutation endpoint. This module is locally testable, but the module import and systemd/POSIX behaviour still require external Linux live validation. Phase 2 status is always explicit about all mutation classes being false. """ from __future__ import annotations import argparse import ast import hashlib import json import subprocess import sys from pathlib import Path from typing import Any, Callable, Mapping, Sequence DEFAULT_SSH = "ssh.exe" DEFAULT_HOST = "hw" DEFAULT_REMOTE_COMMAND = 'test "$(hostname)" = cn-network-mkt && sudo -n python3 -' DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_ISSUER_UNIT = "aios-task-principal-issuer.service" DEFAULT_ADMISSION_MODULE = "aios_task_principal_admission" DEFAULT_MIGRATION_MODULE = "aios_task_principal_storage_migration" DEFAULT_REQUIRED_ADMISSION_FUNCTIONS = ( "offline_migrate_task_principal_schema", "supersede_p0_scope", ) DEFAULT_MIGRATION_VERIFIER = "SSHOwnerBootstrapVerifier" MAX_ARGV_BYTES = 512 MAX_REQUEST_BYTES = 32 * 1024 MUTATION_FLAGS = ( "task_db_mutation", "scope_mutation", "git_mutation", "service_mutation", ) class RemoteTransportError(RuntimeError): """A safe, non-secret description of an SSH transport failure.""" class Phase2StatusError(RuntimeError): """The remote result was not a valid fail-closed Phase2 status.""" def build_ssh_argv( ssh: str = DEFAULT_SSH, host: str = DEFAULT_HOST, ) -> list[str]: """Return the only SSH argv shape permitted by the Phase2 transport.""" argv = [ ssh, "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=12", host, DEFAULT_REMOTE_COMMAND, ] argv_bytes = sum(len(part.encode("utf-8")) + 1 for part in argv) if argv_bytes > MAX_ARGV_BYTES: raise ValueError("SSH argv exceeds the Phase2 hard limit") return argv def run_remote_python( payload: bytes, *, ssh: str = DEFAULT_SSH, host: str = DEFAULT_HOST, timeout: float = DEFAULT_TIMEOUT_SECONDS, runner: Callable[..., Any] = subprocess.run, ) -> bytes: """Send *payload* as stdin to the fixed remote Python command. Error messages intentionally contain no stderr text. SSH stderr can include command output, environment values, or credentials emitted by a misconfigured remote service. """ if not isinstance(payload, bytes): raise TypeError("payload must be bytes") if timeout <= 0: raise ValueError("timeout must be positive") argv = build_ssh_argv(ssh=ssh, host=host) try: completed = runner( argv, input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: raise RemoteTransportError( f"remote SSH timed out after {timeout:g} seconds" ) from exc except OSError as exc: raise RemoteTransportError("remote SSH could not be started") from exc return_code = getattr(completed, "returncode", None) if return_code != 0: raise RemoteTransportError(f"remote SSH exited with status {return_code}") output = getattr(completed, "stdout", b"") if isinstance(output, str): output = output.encode("utf-8") if not isinstance(output, bytes): raise RemoteTransportError("remote SSH returned a non-byte stdout value") return output def module_sha256(path: str | Path) -> str: """Hash one resolved module file without searching neighbouring files.""" digest = hashlib.sha256() with Path(path).open("rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def validate_public_function_ast( source: str, required_functions: Sequence[str] = DEFAULT_REQUIRED_ADMISSION_FUNCTIONS, ) -> dict[str, Any]: """Validate public symbols and the source-level export mechanism. The live admission overlay intentionally re-exports the legacy public API through a small ``globals()[name] = value`` loop. Requiring every public function to be a direct ``def`` would therefore reject the exact deployed module even though the runtime symbol and signature are present. The AST check still proves that the module has either a direct/imported binding or an explicit dynamic re-export mechanism; the caller separately verifies the runtime callable and its signature. """ try: tree = ast.parse(source) except SyntaxError as exc: return { "ok": False, "missing": list(required_functions), "error": f"AST_PARSE_ERROR:{exc.msg}", } top_level = { node.name: node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_") } imported = { alias.asname or alias.name.split(".", 1)[0] for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names if alias.name != "*" } dynamic_reexport = any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "globals" for node in ast.walk(tree) ) bound = set(top_level) | imported missing = [ name for name in required_functions if name not in bound and not dynamic_reexport ] return { "ok": not missing, "missing": missing, "functions": sorted(top_level), "imported": sorted(imported), "dynamic_reexport": dynamic_reexport, } def make_phase2_status( status: str, errors: Sequence[str] = (), **details: Any, ) -> dict[str, Any]: """Create a status object whose mutation flags are always false.""" result: dict[str, Any] = { "kind": "phase2-status", "version": 1, "status": status, "read_only": True, "fail_closed": status != "PASS", } result.update({flag: False for flag in MUTATION_FLAGS}) if errors: result["errors"] = list(errors) result.update(details) return result def validate_phase2_status(status: Mapping[str, Any]) -> dict[str, Any]: """Validate the result contract and reject any mutation claim.""" if status.get("kind") != "phase2-status": raise Phase2StatusError("remote result is not phase2-status") for flag in MUTATION_FLAGS: if status.get(flag) is not False: raise Phase2StatusError(f"remote result has invalid {flag}") if status.get("status") not in {"PASS", "FAIL"}: raise Phase2StatusError("remote result has invalid status") return dict(status) def _json_request(request: Mapping[str, Any]) -> str: """Serialize only a small request into the stdin-delivered wrapper.""" encoded = json.dumps(dict(request), ensure_ascii=True, separators=(",", ":")) if len(encoded.encode("utf-8")) > MAX_REQUEST_BYTES: raise ValueError("Phase2 request is unexpectedly large") return encoded REMOTE_SCRIPT_PREFIX = r'''# Phase2 read-only runtime wrapper. Do not add mutations here. import ast import hashlib import importlib import importlib.util import inspect import json import os import shlex import subprocess import sys from pathlib import Path sys.dont_write_bytecode = True REQUEST = json.loads(''' REMOTE_SCRIPT_SUFFIX = r''') MUTATION_FLAGS = ("task_db_mutation", "scope_mutation", "git_mutation", "service_mutation") def status(status_name, errors=(), **details): result = { "kind": "phase2-status", "version": 1, "status": status_name, "read_only": True, "fail_closed": status_name != "PASS", } result.update({flag: False for flag in MUTATION_FLAGS}) if errors: result["errors"] = list(errors) result.update(details) return result def systemd_value(unit, property_name): try: completed = subprocess.run( ["systemctl", "show", unit, "--property=" + property_name, "--value", "--no-pager"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=12, ) except (OSError, subprocess.TimeoutExpired): raise RuntimeError("SYSTEMD_QUERY_FAILED") if completed.returncode != 0: raise RuntimeError("SYSTEMD_QUERY_FAILED") return completed.stdout.decode("utf-8", "strict").strip() def parse_pythonpath(environment_value): for token in shlex.split(environment_value): if token.startswith("PYTHONPATH="): return token[len("PYTHONPATH="):].split(os.pathsep) return [] def runtime_identity(): unit = REQUEST.get("issuer_unit", "aios-task-principal-issuer.service") pid = REQUEST.get("issuer_pid") working_directory = REQUEST.get("working_directory") pythonpath = REQUEST.get("pythonpath") # Regression callers may pin one identity component while obtaining the # remaining components from the live unit. In particular, never replace # an explicitly supplied PID with a newly queried MainPID. if pid is None: pid = int(systemd_value(unit, "MainPID")) if working_directory is None: working_directory = systemd_value(unit, "WorkingDirectory") if pythonpath is None: pythonpath = parse_pythonpath(systemd_value(unit, "Environment")) if not pid or not working_directory or working_directory == "-": raise RuntimeError("ISSUER_RUNTIME_IDENTITY_INCOMPLETE") if isinstance(pythonpath, str): pythonpath = pythonpath.split(os.pathsep) return { "unit": unit, "main_pid": int(pid), "working_directory": working_directory, "pythonpath": [item for item in pythonpath if item], } def setup_import_path(identity): path = [identity["working_directory"]] path.extend(identity["pythonpath"]) path.extend(item for item in sys.path if item and item not in path) sys.path[:] = path def importable_module(module_name): try: spec = importlib.util.find_spec(module_name) if spec is None or not spec.origin or spec.origin in {"built-in", "frozen"}: raise RuntimeError("MODULE_NOT_IMPORTABLE:" + module_name) module = importlib.import_module(module_name) except Exception as exc: if isinstance(exc, RuntimeError): raise raise RuntimeError("MODULE_IMPORT_FAILED:" + module_name) from exc module_path = getattr(module, "__file__", None) if not module_path: raise RuntimeError("MODULE_HAS_NO_FILE:" + module_name) return module, Path(module_path).resolve() def hash_file(path): digest = hashlib.sha256() with path.open("rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def inspect_admission(module, path, required, expected_hash): source = path.read_text(encoding="utf-8") tree = ast.parse(source) nodes = { node.name: node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_") } imported = { alias.asname or alias.name.split(".", 1)[0] for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names if alias.name != "*" } dynamic_reexport = any( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "globals" for node in ast.walk(tree) ) bound = set(nodes) | imported missing = [ name for name in required if name not in bound and not dynamic_reexport ] if missing: raise RuntimeError("PUBLIC_SYMBOL_MISSING:" + ",".join(missing)) signatures = {} for name in required: value = getattr(module, name, None) if value is None: raise RuntimeError("PUBLIC_SYMBOL_NOT_IMPORTABLE:" + name) try: signatures[name] = str(inspect.signature(value)) except (TypeError, ValueError): raise RuntimeError("SIGNATURE_UNAVAILABLE:" + name) actual_hash = hash_file(path) if expected_hash and actual_hash != expected_hash: raise RuntimeError("MODULE_HASH_MISMATCH:admission") return { "name": module.__name__, "path": str(path), "sha256": actual_hash, "signatures": signatures, "ast_functions": sorted(nodes), "ast_imported": sorted(imported), "ast_dynamic_reexport": dynamic_reexport, } def inspect_migration(module, path, verifier_name, expected_hash): verifier = getattr(module, verifier_name, None) if verifier is None: raise RuntimeError("MIGRATION_VERIFIER_MISSING:" + verifier_name) try: signature = str(inspect.signature(verifier)) except (TypeError, ValueError): raise RuntimeError("MIGRATION_VERIFIER_SIGNATURE_UNAVAILABLE") actual_hash = hash_file(path) if expected_hash and actual_hash != expected_hash: raise RuntimeError("MODULE_HASH_MISMATCH:migration") return { "name": module.__name__, "path": str(path), "sha256": actual_hash, "verifier": verifier_name, "signature": signature, } def main(): try: identity = runtime_identity() setup_import_path(identity) admission_name = REQUEST.get("admission_module", "aios_task_principal_admission") migration_name = REQUEST.get("migration_module", "aios_task_principal_storage_migration") if migration_name == admission_name: raise RuntimeError("MIGRATION_MODULE_NOT_DISTINCT") required = tuple(REQUEST.get("required_admission_functions", ( "offline_migrate_task_principal_schema", "supersede_p0_scope"))) admission, admission_path = importable_module(admission_name) migration, migration_path = importable_module(migration_name) admission_info = inspect_admission( admission, admission_path, required, REQUEST.get("expected_admission_sha256"), ) migration_info = inspect_migration( migration, migration_path, REQUEST.get("migration_verifier", "SSHOwnerBootstrapVerifier"), REQUEST.get("expected_migration_sha256"), ) print(json.dumps(status( "PASS", issuer=identity, modules={"admission": admission_info, "migration": migration_info}, ast_public_functions=list(required), ), sort_keys=True, separators=(",", ":"))) except Exception as exc: # Emit only stable error classes and never exception text from imports. error = str(exc).split(":", 1)[0] or "PHASE2_INTROSPECTION_FAILED" print(json.dumps(status("FAIL", errors=[error]), sort_keys=True, separators=(",", ":"))) if __name__ == "__main__": main() ''' def build_remote_payload(request: Mapping[str, Any] | None = None) -> bytes: """Build the stdin wrapper; request data never enters SSH argv.""" request = {} if request is None else request # repr() makes the embedded JSON a valid Python string for every request # value, including apostrophes and backslashes; it still remains stdin. request_literal = repr(_json_request(request)) return (REMOTE_SCRIPT_PREFIX + request_literal + REMOTE_SCRIPT_SUFFIX).encode("utf-8") def _load_request(args: argparse.Namespace) -> dict[str, Any]: request: dict[str, Any] = {} if args.request_json: loaded = json.loads(args.request_json) if not isinstance(loaded, dict): raise ValueError("request JSON must be an object") request.update(loaded) if args.request_file: loaded = json.loads(Path(args.request_file).read_text(encoding="utf-8")) if not isinstance(loaded, dict): raise ValueError("request JSON must be an object") request.update(loaded) overrides = { "issuer_unit": args.issuer_unit, "issuer_pid": args.issuer_pid, "working_directory": args.working_directory, "pythonpath": args.pythonpath, "admission_module": args.admission_module, "migration_module": args.migration_module, "expected_admission_sha256": args.expected_admission_sha256, "expected_migration_sha256": args.expected_migration_sha256, } request.update({key: value for key, value in overrides.items() if value is not None}) return request def _write_status(path: str | Path, status: Mapping[str, Any]) -> None: destination = Path(path) destination.write_text( json.dumps(dict(status), ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--request-json") parser.add_argument("--request-file") parser.add_argument("--output", default="phase2-status.json") parser.add_argument("--ssh", default=DEFAULT_SSH) parser.add_argument("--host", default=DEFAULT_HOST) parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS) parser.add_argument("--issuer-unit") parser.add_argument("--issuer-pid", type=int) parser.add_argument("--working-directory") parser.add_argument("--pythonpath", nargs="*") parser.add_argument("--admission-module") parser.add_argument("--migration-module") parser.add_argument("--expected-admission-sha256") parser.add_argument("--expected-migration-sha256") args = parser.parse_args(argv) try: request = _load_request(args) payload = build_remote_payload(request) stdout = run_remote_python( payload, ssh=args.ssh, host=args.host, timeout=args.timeout, ) remote_status = json.loads(stdout.decode("utf-8")) status = validate_phase2_status(remote_status) except (OSError, ValueError, json.JSONDecodeError, RemoteTransportError, Phase2StatusError) as exc: status = make_phase2_status("FAIL", errors=[str(exc).split(":", 1)[0]]) _write_status(args.output, status) return 1 _write_status(args.output, status) return 0 if status["status"] == "PASS" else 1 if __name__ == "__main__": raise SystemExit(main())