diff --git a/active-runtime/app/aios_chatgpt_bridge_v2.py b/active-runtime/app/aios_chatgpt_bridge_v2.py index 06d2626..87cef8a 100644 --- a/active-runtime/app/aios_chatgpt_bridge_v2.py +++ b/active-runtime/app/aios_chatgpt_bridge_v2.py @@ -6,6 +6,12 @@ import hashlib from pathlib import Path from typing import Any, Callable, Mapping +from aios_task_admission_bridge_contract import ( + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS, + TASK_ADMISSION_OPERATION_REQUIRED_FIELDS, + TASK_ADMISSION_ROUTES, +) + BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2" BRIDGE_OPERATIONS = ( @@ -29,15 +35,17 @@ BRIDGE_OPERATIONS = ( "getControlPlaneSnapshot", ) +TASK_ADMISSION_OPERATIONS = tuple(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS) + OPERATION_REQUIRED_FIELDS = { "createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"), "projectOnlineAgentExecution": ("task_id", "required_capabilities"), - "acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"), - "commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"), - "commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"), - "getGitOpsJob": ("job_id",), - "resumeGitOpsJob": ("job_id", "expected_revision"), - "reconcileGitOpsJob": ("job_id", "expected_revision"), + "acquireWriterLease": ("schema", "task_id", "job_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "lease_seconds"), + "commitFileGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"), + "commitFilesGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"), + "getGitOpsJob": ("task_id", "job_id"), + "resumeGitOpsJob": ("task_id", "job_id", "expected_revision"), + "reconcileGitOpsJob": ("task_id", "job_id", "expected_revision"), "createTagApprovalRequest": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "owner_approval", "execute"), "requestOrCreateTag": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"), "getTagPublicationStatus": ("version",), @@ -46,6 +54,7 @@ OPERATION_REQUIRED_FIELDS = { "getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"), "runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"), "getControlPlaneSnapshot": (), + **TASK_ADMISSION_OPERATION_REQUIRED_FIELDS, } _OPERATION_PATHS = { @@ -60,19 +69,48 @@ _OPERATION_PATHS = { "getTagPublicationStatus": "/v2/tag/status", "getReleaseConsistency": "/v2/release/consistency", "runReleaseAcceptance": "/v2/release/acceptance", + **{operation: path for path, operation in TASK_ADMISSION_ROUTES.items()}, } -def operation_contracts() -> dict[str, dict[str, object]]: +def _has_complete_task_admission_handlers( + handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None, +) -> bool: + return handlers is not None and all( + callable(handlers.get(operation)) for operation in TASK_ADMISSION_OPERATIONS + ) + + +def operation_contracts( + handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None, +) -> dict[str, dict[str, object]]: """Return the runtime contract consumed by adapters and schema tooling.""" - return { + contracts = { operation: { "method": "POST", "path": _OPERATION_PATHS.get(operation), "required_fields": list(fields), } for operation, fields in OPERATION_REQUIRED_FIELDS.items() + if operation not in TASK_ADMISSION_OPERATIONS } + if _has_complete_task_admission_handlers(handlers): + contracts.update( + { + operation: { + "method": "GET" if operation == "getTaskAdmissionStatus" else "POST", + "path": _OPERATION_PATHS[operation], + "required_fields": list( + TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation] + ), + "optional_fields": list( + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation] + ), + } + for operation in TASK_ADMISSION_OPERATIONS + } + ) + return contracts def render_openapi_contract_fragment() -> str: @@ -85,6 +123,8 @@ def render_openapi_contract_fragment() -> str: " post:\n" " operationId: commitFilesGoverned\n" " summary: Submit one atomic governed commit containing multiple files\n" + " security: [{taskPrincipalAuth: []}]\n" + " x-aios-task-principal: Bridge verifies a fresh aios.task-principal-assertion.v1 and exact task/job/repository/branch/generation/revision/attempt/execution-digest/capability binding; caller JSON cannot provide a principal and missing verification is denied.\n" " requestBody:\n" " required: true\n" " content:\n" @@ -116,8 +156,12 @@ def render_openapi_contract_fragment() -> str: " message: {type: string, minLength: 1, maxLength: 200}\n" " responses:\n" " '200': {description: Governed commit job and verified branch result}\n" + " '202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}\n" + " '401': {description: No verified Task principal is available}\n" + " '403': {description: Verified Task principal lacks exact task, external Job, or capability scope}\n" " '400': {description: Contract or policy rejection}\n" " '409': {description: Branch CAS or idempotency conflict}\n" + " '423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}\n" ) @@ -183,20 +227,38 @@ class ControlPlaneReleaseManifest: class BridgeV2Adapter: """Thin operation adapter; all mutating handlers are injected seams.""" - def __init__(self, *, runtime_status: Mapping[str, Any], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None: - self.runtime_status = dict(runtime_status) + def __init__(self, *, runtime_status: Mapping[str, Any] | Callable[[], Mapping[str, Any]], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None: + self._runtime_status_source = runtime_status if callable(runtime_status) else dict(runtime_status) self.handlers = dict(handlers or {}) + def _runtime_status(self) -> dict[str, Any]: + value = self._runtime_status_source() if callable(self._runtime_status_source) else self._runtime_status_source + if not isinstance(value, Mapping): + raise BridgeV2Error("RUNTIME_STATUS_INVALID") + return dict(value) + def getRuntimeStatus(self) -> dict[str, Any]: - return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self.runtime_status} + return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self._runtime_status()} def getGovernedWriteCapabilities(self) -> dict[str, Any]: + runtime_status = self._runtime_status() + governed_writes_enabled = runtime_status.get("governed_writes_enabled") is True return { "bridge_schema_version": BRIDGE_SCHEMA_VERSION, "legacy_writes_enabled": False, - "governed_writes_enabled": True, - "operations": list(BRIDGE_OPERATIONS), - "operation_contracts": operation_contracts(), + "governed_writes_enabled": governed_writes_enabled, + "governed_write_blockers": list( + runtime_status.get("governed_write_blockers") or [] + ), + "operations": [ + *BRIDGE_OPERATIONS, + *( + TASK_ADMISSION_OPERATIONS + if _has_complete_task_admission_handlers(self.handlers) + else () + ), + ], + "operation_contracts": operation_contracts(self.handlers), "force_push": False, "main_write": False, "tag_write": False, @@ -213,7 +275,7 @@ class BridgeV2Adapter: @staticmethod def legacy_write_disabled() -> dict[str, object]: - return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": True} + return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": False} def _call(self, operation: str, request: Mapping[str, Any]) -> Any: required = OPERATION_REQUIRED_FIELDS[operation] @@ -239,5 +301,8 @@ class BridgeV2Adapter: def executeApprovedProductionRelease(self, request: Mapping[str, Any]) -> Any: return self._call("executeApprovedProductionRelease", request) def getReleaseConsistency(self, request: Mapping[str, Any]) -> Any: return self._call("getReleaseConsistency", request) def runReleaseAcceptance(self, request: Mapping[str, Any]) -> Any: return self._call("runReleaseAcceptance", request) + def getTaskAdmissionEnvelope(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionEnvelope", request) + def submitTaskAdmissionAuthorization(self, request: Mapping[str, Any]) -> Any: return self._call("submitTaskAdmissionAuthorization", request) + def getTaskAdmissionStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionStatus", request) def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {}) diff --git a/active-runtime/app/aios_chatgpt_bridge_v2_server.py b/active-runtime/app/aios_chatgpt_bridge_v2_server.py index 166a5e9..323a5a1 100644 --- a/active-runtime/app/aios_chatgpt_bridge_v2_server.py +++ b/active-runtime/app/aios_chatgpt_bridge_v2_server.py @@ -11,19 +11,51 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import re import sqlite3 -from typing import Any, Mapping +import stat +import threading +from typing import Any, Callable, Mapping +from urllib.parse import parse_qsl, urlsplit from aios_authority_git_executor import AuthorityGitExecutor -from aios_chatgpt_bridge_v2 import BridgeV2Adapter, BridgeV2Error, OPERATION_REQUIRED_FIELDS +from aios_chatgpt_bridge_v2 import ( + BridgeV2Adapter, + BridgeV2Error, + OPERATION_REQUIRED_FIELDS, + TASK_ADMISSION_OPERATIONS, + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS, + TASK_ADMISSION_ROUTES, +) +from aios_task_admission_bootstrap import TaskAdmissionError from gitea_tag_publication import validate_gitea_api_url from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection from aios_continuation_lease import WriterLeaseRequestV2 from aios_control_plane_snapshot import build_control_plane_snapshot -from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore, _RECOVERABLE, sha256_json -from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBootstrap +from aios_gitops_job_controller import ( + GitOpsJobController, + JobState, + JobStore, + P0_PRE_SIDE_EFFECT_RECOVERY_INTERNAL_JOB_ID, + R10_STAGE9_TASK_ID, + R10_STAGE9_WORKER_LINEAGE, + R10_STAGE9_WORKER_PRINCIPAL, + _RECOVERABLE, + sha256_json, +) +from aios_gitops_job_identity import JobAlias, JobAliasConflict, JobIdentityError, legacy_task_storage_key, request_digest, task_storage_key +from aios_gitops_task_storage_migration import ( + TaskStorageMigrationError, + TaskStorageRuntimeGuard, +) +from aios_governed_task_bootstrap import ( + BootstrapError, + GovernedTaskBinding, + GovernedTaskBootstrap, +) from governed_production_release import build_executor_config MAX_BODY = 2 * 1024 * 1024 +TaskPrincipalVerifier = Callable[[Mapping[str, Any], Mapping[str, Any]], Mapping[str, Any] | None] +_PRIVATE_PRINCIPAL = "_verified_task_principal" ROUTES = { "/v2/runtime": "getRuntimeStatus", "/v2/capabilities": "getGovernedWriteCapabilities", @@ -42,8 +74,21 @@ ROUTES = { "/v2/release/execute": "executeApprovedProductionRelease", "/v2/release/consistency": "getReleaseConsistency", "/v2/release/acceptance": "runReleaseAcceptance", + **TASK_ADMISSION_ROUTES, } +# Only these Task-principal operations can cross the P0 mutation gate. The +# legacy global gate remains permanently false in this immutable staging +# closure; tag, baseline, protected-main, and production operations are not +# activated by this capability. +_TASK_PRINCIPAL_MUTATING_OPERATIONS = frozenset({ + "acquireWriterLease", + "commitFileGoverned", + "commitFilesGoverned", + "resumeGitOpsJob", + "reconcileGitOpsJob", +}) + @dataclass(frozen=True, slots=True) class RuntimeConfig: @@ -65,17 +110,230 @@ class RuntimeConfig: release_plan_path: Path | None = None -def _safe(value: str) -> str: - return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "runtime" +_task_storage_key = task_storage_key + + +def _lexists(path: Path) -> bool: + try: + path.lstat() + return True + except FileNotFoundError: + return False + + +def _secure_task_directory( + parent: Path, + storage_key: str, + *, + allow_missing: bool, + error: str, +) -> Path: + """Resolve one runtime-owned child without following task-controlled links.""" + parent = Path(parent) + try: + parent_metadata = parent.lstat() + parent_resolved = parent.resolve(strict=True) + except OSError as exc: + raise RuntimeError(error) from exc + expected_uid = getattr(os, "geteuid", lambda: parent_metadata.st_uid)() + if ( + not parent.is_absolute() + or stat.S_ISLNK(parent_metadata.st_mode) + or not stat.S_ISDIR(parent_metadata.st_mode) + or parent_metadata.st_mode & 0o022 + or parent_metadata.st_uid != expected_uid + or parent_resolved != parent + ): + raise RuntimeError(error) + candidate = parent / storage_key + try: + metadata = candidate.lstat() + except FileNotFoundError: + if allow_missing: + return candidate + raise RuntimeError(error) from None + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_mode & 0o022 + or metadata.st_uid != expected_uid + ): + raise RuntimeError(error) + try: + if candidate.resolve(strict=True) != parent_resolved / storage_key: + raise RuntimeError(error) + except OSError as exc: + raise RuntimeError(error) from exc + return candidate + + +@dataclass(slots=True) +class DeferredDetachedGitWorkspace: + """Create the branch/worktree only after the Job execution claim exists.""" + + authority: AuthorityGitExecutor + lease: WriterLeaseRequestV2 + path: Path + storage_key: str + worktrees_root: Path + create_branch: bool + delegate: DetachedGitWorkspace | None = None + + def prepare_branch_for_execution(self, fence: Any) -> None: + if self.create_branch: + current = self.authority.read_ref("refs/heads/" + self.lease.target_branch) + if current is None: + self.authority.create_task_branch( + self.lease.target_branch, + self.lease.base_oid, + allow_existing_same_oid=False, + ) + fence.record_created_branch(self.lease.target_branch, self.lease.base_oid) + elif current != self.lease.base_oid or not ( + fence.owns_created_branch(self.lease.target_branch, self.lease.base_oid) + or fence.adopts_pre_side_effect_branch( + self.lease.target_branch, self.lease.base_oid, + ) + ): + raise RuntimeError("TARGET_BRANCH_OWNERSHIP_UNPROVEN") + + def observe_pre_side_effect_recovery(self) -> Mapping[str, object]: + """Observe, but never normalize or write, the historical worktree.""" + return self.authority.inspect_pre_side_effect_recovery_worktree( + self.worktrees_root, self.storage_key, self.lease.expected_branch_oid, + ) + + def prepare_for_execution(self, fence: Any) -> None: + if self.delegate is not None: + return + current = self.authority.read_ref("refs/heads/" + self.lease.target_branch) + if current != self.lease.expected_branch_oid: + raise RuntimeError("TASK_BRANCH_EXPECTATION_MISMATCH") + path = self.worktrees_root / self.storage_key + if _lexists(path): + # The historical 02775 path remains rejected by the normal secure + # guard. Only an append-only same-Job recovery record, inspected + # under the fresh execution claim fence, may normalize that one + # exact inode before it is pinned or used as a Git cwd. + if not fence.adopts_pre_side_effect_branch( + self.lease.target_branch, self.lease.base_oid, + ): + _secure_task_directory( + self.worktrees_root, self.storage_key, + allow_missing=False, error="WORKTREE_PATH_INVALID", + ) + else: + self.authority.normalize_recovery_worktree( + self.worktrees_root, self.storage_key, + ) + # The initial recovery observation intentionally tolerates the + # historical group-writable directory so that it can prove the + # prior failure had no Git side effect. That makes the + # interval before chmod a hostile boundary: re-read the exact + # Authority git-dir, detached HEAD, and clean index *after* + # permissions are private, before pinning it as a Git cwd. + # This is an observation only; a mismatch leaves the Job in + # reconciliation and cannot trigger a new mutation. + self.authority.inspect_pre_side_effect_recovery_worktree( + self.worktrees_root, self.storage_key, + self.lease.expected_branch_oid, + ) + else: + _secure_task_directory( + self.worktrees_root, self.storage_key, + allow_missing=True, error="WORKTREE_PATH_INVALID", + ) + self.authority.create_detached_worktree( + self.lease.expected_branch_oid, + self.storage_key, + self.worktrees_root, + ) + path = _secure_task_directory( + self.worktrees_root, + self.storage_key, + allow_missing=False, + error="WORKTREE_PATH_INVALID", + ) + self.delegate = DetachedGitWorkspace.pinned( + self.worktrees_root, self.storage_key, + ) + if self.delegate.head_oid() != self.lease.expected_branch_oid or not self.delegate.is_detached(): + raise RuntimeError("WORKTREE_HEAD_MISMATCH") + + def _prepared(self) -> DetachedGitWorkspace: + if self.delegate is None: + raise RuntimeError("WORKTREE_NOT_PREPARED") + return self.delegate + + def remote_path(self) -> Path: + return self._prepared().path + + def is_detached(self) -> bool: + return self._prepared().is_detached() + + def head_oid(self) -> str: + if self.delegate is None: + if _lexists(self.path): + path = _secure_task_directory( + self.worktrees_root, + self.storage_key, + allow_missing=False, + error="WORKTREE_PATH_INVALID", + ) + self.delegate = DetachedGitWorkspace.pinned( + self.worktrees_root, self.storage_key, + ) + else: + return self.lease.expected_branch_oid + return self.delegate.head_oid() + + def materialize_files(self, files: Mapping[str, bytes]) -> None: + self._prepared().materialize_files(files) + + def commit_files(self, files: Mapping[str, bytes], message: str) -> str: + return self._prepared().commit_files(files, message) + + +@dataclass(slots=True) +class ObservationOnlyWorkspace: + """Fail closed if a cold recovery path accidentally attempts authoring.""" + + expected_oid: str + + def prepare_branch_for_execution(self, _fence: Any) -> None: + raise RuntimeError("OBSERVATION_ONLY_WORKSPACE") + + def prepare_for_execution(self, _fence: Any) -> None: + raise RuntimeError("OBSERVATION_ONLY_WORKSPACE") + + def is_detached(self) -> bool: + raise RuntimeError("OBSERVATION_ONLY_WORKSPACE") + + def head_oid(self) -> str: + return self.expected_oid + + def materialize_files(self, _files: Mapping[str, bytes]) -> None: + raise RuntimeError("OBSERVATION_ONLY_WORKSPACE") + + def commit_files(self, _files: Mapping[str, bytes], _message: str) -> str: + raise RuntimeError("OBSERVATION_ONLY_WORKSPACE") class GovernedRuntime: - def __init__(self, config: RuntimeConfig) -> None: + def __init__( + self, + config: RuntimeConfig, + *, + storage_activation_task_ids: tuple[str, ...] | None = None, + ) -> None: self.config = config - config.state_root.mkdir(parents=True, exist_ok=True) + config.state_root.mkdir(parents=True, exist_ok=True, mode=0o700) + self._task_storage_runtime_guard = TaskStorageRuntimeGuard(config.state_root) + (config.state_root / "worktrees").mkdir(mode=0o700, exist_ok=True) self.authority = AuthorityGitExecutor(config.authoritative_git_dir, config.state_root / "worktrees") self.bootstrap = GovernedTaskBootstrap(self.authority, config.state_root, remote_name=config.remote_name, token_path=config.token_path, ssh_key_path=config.ssh_key_path, api_base=config.api_base) self.runtimes: dict[tuple[str, int], tuple[Any, GitOpsJobController, ContinuationExecutor]] = {} + self._runtime_lock = threading.RLock() self.controllers: dict[str, GitOpsJobController] = {} self.bindings: dict[str, dict[str, Any]] = {} self.release = build_executor_config(config) @@ -83,11 +341,25 @@ class GovernedRuntime: if metadata_root.is_dir(): for metadata in metadata_root.glob("*.json"): try: + raw = metadata.lstat() + if ( + stat.S_ISLNK(raw.st_mode) + or not stat.S_ISREG(raw.st_mode) + or raw.st_mode & 0o022 + ): + continue value = json.loads(metadata.read_text(encoding="utf-8")) - if isinstance(value, dict) and value.get("task_id"): - self.bindings[str(value["task_id"])] = value - except (OSError, ValueError, TypeError): + task_id = value.get("task_id") if isinstance(value, dict) else None + if ( + isinstance(task_id, str) + and metadata.name == task_storage_key(task_id) + ".json" + ): + self.bindings[task_id] = value + except (OSError, ValueError, TypeError, JobIdentityError): continue + self._storage_activation_blockers = self._discover_storage_activation_blockers( + storage_activation_task_ids + ) askpass = config.state_root / "bridge-v2-askpass.sh" if not askpass.exists(): askpass.write_text('#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" "$GITEA_MACHINE_ACTOR" ;; *) cat "$CREDENTIAL_FILE" ;; esac\n', encoding="utf-8") @@ -95,6 +367,39 @@ class GovernedRuntime: os.environ.setdefault("GIT_ASKPASS", str(askpass)); os.environ.setdefault("GIT_ASKPASS_REQUIRE", "force"); os.environ.setdefault("GIT_TERMINAL_PROMPT", "0"); os.environ.setdefault("GITEA_MACHINE_ACTOR", "aios-autonomy-agent") def runtime_status(self) -> dict[str, Any]: + verifier = getattr(BridgeHandler, "principal_verifier", None) + provider_configured = verifier is not None + readiness_reader = getattr(verifier, "readiness", None) + if callable(readiness_reader): + try: + principal_readiness = dict(readiness_reader()) + except Exception: + principal_readiness = { + "ready": False, + "error": "TASK_PRINCIPAL_READINESS_FAILED", + } + else: + principal_readiness = { + "ready": False, + "error": "TASK_PRINCIPAL_SEALED_READINESS_UNAVAILABLE", + } + provider_ready = principal_readiness.get("ready") is True + legacy_write_gate = str(os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false")).strip().lower() in { + "1", "true", "yes", "on", + } + task_principal_write_gate = str(os.environ.get( + "AIOS_P0_TASK_PRINCIPAL_WRITES_ENABLED", "false", + )).strip().lower() in { + "1", "true", "yes", "on", + } + blockers = set(getattr(self, "_storage_activation_blockers", ())) + if not provider_ready: + blockers.add("TASK_PRINCIPAL_VERIFIER_NOT_READY") + if legacy_write_gate: + blockers.add("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED") + if not task_principal_write_gate: + blockers.add("TASK_PRINCIPAL_WRITES_DISABLED") + governed_writes_enabled = not blockers return { "status": "active", "health": 200, @@ -109,54 +414,421 @@ class GovernedRuntime: "repository": self.config.repository, "repository_identity": self.config.repository, "authoritative_git_dir": str(self.config.authoritative_git_dir), - "legacy_global_writes_enabled": False, + "legacy_global_writes_enabled": legacy_write_gate, + "task_principal_writes_enabled": task_principal_write_gate, + "principal_verifier_configured": provider_configured, + "task_principal_readiness": principal_readiness, + "task_storage_migration_required": any( + "MIGRATION" in blocker or "LEGACY" in blocker + for blocker in blockers + ), + "governed_writes_enabled": governed_writes_enabled, + "governed_write_blockers": sorted(blockers), } + def project_admitted_task( + self, + grant: Any, + _task_result: Mapping[str, Any], + ) -> Mapping[str, Any]: + """Project an officially admitted Task into the local Job lineage. + + Agent Task admission and the local governed Job are distinct stores. + This callback is invoked only after the official Task API has returned + an exact identity readback. It is retry-safe: a crash after the Task + effect but before the admission receipt is recovered by re-reading the + same Task and reconciling this exact local projection. + """ + task_id = str(getattr(grant, "task_id", "")) + repository = str(getattr(grant, "repository", "")) + base_oid = str(getattr(grant, "authority_base_oid", "")) + target_branch = str(getattr(grant, "target_branch", "")) + risk = str(getattr(grant, "risk", "")) + generation = int(getattr(grant, "generation", 0)) + external_job_id = str(_task_result.get("external_job_id") or "") + if ( + not task_id + or repository != self.config.repository + or not base_oid + or not target_branch + or generation < 1 + or re.fullmatch(r"job-[0-9a-f]{32}", external_job_id) is None + ): + raise RuntimeError("TASK_ADMISSION_PROJECTION_COORDINATES_INVALID") + # JobStore client idempotency keys intentionally exclude '/'. The + # official Task execution plan, however, already owns the public + # external Job ID. Keep those identities separate: this durable + # binding stores the official external ID, while the later writer + # lease reserves the internal alias for its own valid commit key. + projection_key = "task-admission:" + task_id + description = "P0 governed execution projection for officially admitted Agent Task." + + try: + existing = self.bootstrap.get_binding(task_id) + except BootstrapError as exc: + if str(exc) != "TASK_EXECUTION_PROJECTION_MISSING": + raise + existing = None + if existing is not None: + if ( + existing.repository != repository + or existing.base_oid != base_oid + or existing.target_branch != target_branch + or existing.risk != risk + or existing.description != description + or existing.idempotency_key != projection_key + or existing.generation != generation + or existing.job_id != external_job_id + ): + raise RuntimeError("TASK_ADMISSION_PROJECTION_BINDING_CONFLICT") + result = existing.response(idempotent=True) + else: + if not self.authority.commit_exists(base_oid): + raise RuntimeError("TASK_ADMISSION_PROJECTION_BASE_NOT_FOUND") + current_branch_oid = self.authority.read_ref( + "refs/heads/" + target_branch + ) + if current_branch_oid is None: + branch_mode = "CREATE_NEW" + expected_branch_oid = base_oid + elif current_branch_oid == base_oid: + branch_mode = "CONTINUE_EXISTING" + expected_branch_oid = current_branch_oid + else: + raise RuntimeError("TASK_ADMISSION_PROJECTION_BRANCH_CONFLICT") + binding = GovernedTaskBinding( + task_id=task_id, + repository=repository, + base_oid=base_oid, + target_branch=target_branch, + risk=risk, + description=description, + idempotency_key=projection_key, + expected_branch_oid=expected_branch_oid, + branch_mode=branch_mode, + generation=generation, + job_id=external_job_id, + ) + # Use the existing bootstrap's atomic durable binding writer. No + # Task DB or JobStore row is synthesized at admission time; the + # formal continuation Job is created only by commitFilesGoverned. + self.bootstrap._write(binding) + readback = self.bootstrap.get_binding(task_id) + if readback != binding: + raise RuntimeError("TASK_ADMISSION_PROJECTION_READBACK_MISMATCH") + result = readback.response() + self.bindings[task_id] = dict(result) + return result + + def _discover_storage_activation_blockers( + self, + activation_task_ids: tuple[str, ...] | None = None, + ) -> tuple[str, ...]: + blockers: set[str] = set() + metadata_root = self.config.state_root / "task-bindings" + if activation_task_ids is not None: + if ( + len(activation_task_ids) != 1 + or len(set(activation_task_ids)) != len(activation_task_ids) + ): + return ("TASK_PRINCIPAL_STORAGE_SCOPE_INVALID",) + jobs = self.config.state_root / "jobs" + worktrees = self.config.state_root / "worktrees" + for task_id in activation_task_ids: + try: + if ( + not isinstance(task_id, str) + or not task_id + or task_id != task_id.strip() + ): + raise ValueError("task id invalid") + expected = task_storage_key(task_id) + legacy = legacy_task_storage_key(task_id) + target_metadata = metadata_root / (expected + ".json") + legacy_metadata = metadata_root / (legacy + ".json") + if legacy != expected and _lexists(legacy_metadata): + blockers.add("TASK_BINDING_LEGACY_MIGRATION_REQUIRED") + if not _lexists(target_metadata): + if not _lexists(legacy_metadata): + blockers.add("TASK_BINDING_METADATA_INVALID") + else: + raw = target_metadata.lstat() + if stat.S_ISLNK(raw.st_mode) or not stat.S_ISREG(raw.st_mode): + raise ValueError("not a regular binding") + value = json.loads(target_metadata.read_text(encoding="utf-8")) + if not isinstance(value, Mapping) or value.get("task_id") != task_id: + raise ValueError("task identity mismatch") + if legacy != expected and _lexists(jobs / legacy): + blockers.add("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED") + if not _lexists(jobs / expected): + if not _lexists(jobs / legacy): + blockers.add("TASK_STORAGE_JOBS_MISSING") + if legacy != expected and _lexists(worktrees / legacy): + blockers.add("TASK_WORKTREE_GIT_AWARE_MIGRATION_REQUIRED") + except (OSError, ValueError, TypeError, JobIdentityError): + blockers.add("TASK_BINDING_METADATA_INVALID") + return tuple(sorted(blockers)) + try: + entries = list(metadata_root.iterdir()) if metadata_root.is_dir() else [] + except OSError: + return ("TASK_BINDING_METADATA_INVALID",) + for metadata in entries: + if metadata.suffix != ".json": + continue + try: + raw = metadata.lstat() + if stat.S_ISLNK(raw.st_mode) or not stat.S_ISREG(raw.st_mode): + raise ValueError("not a regular binding") + value = json.loads(metadata.read_text(encoding="utf-8")) + task_id = value.get("task_id") if isinstance(value, Mapping) else None + if not isinstance(task_id, str) or not task_id: + raise ValueError("task id missing") + expected = task_storage_key(task_id) + if metadata.name != expected + ".json": + blockers.add("TASK_BINDING_LEGACY_MIGRATION_REQUIRED") + jobs = self.config.state_root / "jobs" + worktrees = self.config.state_root / "worktrees" + legacy = legacy_task_storage_key(task_id) + if legacy != expected and _lexists(jobs / legacy): + blockers.add("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED") + if legacy != expected and _lexists(worktrees / legacy): + blockers.add("TASK_WORKTREE_GIT_AWARE_MIGRATION_REQUIRED") + except (OSError, ValueError, TypeError, JobIdentityError): + blockers.add("TASK_BINDING_METADATA_INVALID") + return tuple(sorted(blockers)) + def _job_root(self, task_id: str) -> Path: - return self.config.state_root / "jobs" / _safe(task_id) + parent = self.config.state_root / "jobs" + target = parent / _task_storage_key(task_id) + legacy = parent / legacy_task_storage_key(task_id) + if legacy != target and _lexists(legacy): + if _lexists(target): + raise RuntimeError("TASK_STORAGE_LEGACY_CONFLICT") + raise RuntimeError("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED") + return _secure_task_directory( + parent, + _task_storage_key(task_id), + allow_missing=True, + error="TASK_STORAGE_PATH_INVALID", + ) + + def _worktree_path(self, task_id: str) -> Path: + parent = self.config.state_root / "worktrees" + target = parent / _task_storage_key(task_id) + legacy = parent / legacy_task_storage_key(task_id) + if legacy != target and _lexists(legacy): + if _lexists(target): + raise RuntimeError("TASK_WORKTREE_LEGACY_CONFLICT") + raise RuntimeError("TASK_WORKTREE_LEGACY_MIGRATION_REQUIRED") + return _secure_task_directory( + parent, + _task_storage_key(task_id), + allow_missing=True, + error="TASK_WORKTREE_PATH_INVALID", + ) + + def _validated_task_controller(self, request: Mapping[str, Any], *, capability: str) -> tuple[Any, GitOpsJobController]: + """Validate a server-authenticated principal and its persisted Task binding. + + A task id selects its one controller store. Consequently external ids + are never resolved by scanning sibling task databases. + """ + raw_task_id = request.get("task_id") + if ( + not isinstance(raw_task_id, str) + or not raw_task_id + or raw_task_id != raw_task_id.strip() + ): + raise RuntimeError("TASK_ID_NOT_CANONICAL") + task_id = raw_task_id + external_job_id = str(request.get("job_id") or "").strip() + principal = request.get(_PRIVATE_PRINCIPAL) + if not task_id or not external_job_id: + raise RuntimeError("TASK_SCOPED_BINDING_REQUIRED") + if not isinstance(principal, Mapping): + raise RuntimeError("TASK_PRINCIPAL_REQUIRED") + capabilities = principal.get("capabilities") + if ( + str(principal.get("task_id") or "") != task_id + or str(principal.get("job_id") or "") != external_job_id + or not isinstance(capabilities, (list, tuple, set)) + or capability not in capabilities + ): + raise RuntimeError("TASK_PRINCIPAL_SCOPE_CONFLICT") + binding = self.bootstrap.get_binding(task_id) + if str(getattr(binding, "task_id", "")) != task_id: + raise RuntimeError("TASK_BOOTSTRAP_BINDING_CONFLICT") + if str(getattr(binding, "job_id", "")) != external_job_id: + raise RuntimeError("TASK_BOOTSTRAP_JOB_BINDING_CONFLICT") + principal_checks = ( + ("repository", binding.repository), + ("target_branch", binding.target_branch), + ("generation", binding.generation), + ) + if any(str(principal.get(field)) != str(expected) for field, expected in principal_checks): + raise RuntimeError("TASK_PRINCIPAL_DURABLE_BINDING_CONFLICT") + if ( + not isinstance(principal.get("task_revision"), int) + or int(principal["task_revision"]) < 1 + or not isinstance(principal.get("attempt"), int) + or int(principal["attempt"]) < 1 + or not isinstance(principal.get("execution_digest"), str) + or re.fullmatch(r"[0-9a-f]{64}", str(principal["execution_digest"])) is None + ): + raise RuntimeError("TASK_PRINCIPAL_COORDINATES_INVALID") + checks = ( + ("repository", binding.repository), + ("target_branch", binding.target_branch), + ("expected_branch_oid", binding.expected_branch_oid), + ("generation", binding.generation), + ("branch_mode", binding.branch_mode), + ) + for field, expected in checks: + if field in request and request.get(field) is not None and str(request[field]) != str(expected): + raise RuntimeError("TASK_BINDING_CONFLICT") + root = self._job_root(task_id) + if capability == "git.read": + controller = GitOpsJobController(JobStore.for_existing_readonly(root)) + return binding, controller + controller = self.controllers.get(task_id) + if controller is None: + controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads")) + if controller.scan_legacy_alias_candidates(): + raise RuntimeError("TASK_ALIAS_MIGRATION_REQUIRED") + self.controllers[task_id] = controller + return binding, controller + + def _reserve_alias(self, request: Mapping[str, Any], controller: GitOpsJobController) -> JobAlias: + external = str(request.get("job_id") or "").strip() + commit_key = str(request.get("idempotency_key") or "").strip() + if not external or not commit_key: + raise RuntimeError("EXTERNAL_JOB_ID_AND_COMMIT_IDEMPOTENCY_KEY_REQUIRED") + alias = JobAlias( + task_id=str(request["task_id"]), + external_job_id=external, + internal_job_id=controller.internal_job_id_for_idempotency_key(commit_key), + repository=str(request["repository"]), + target_branch=str(request["target_branch"]), + branch_mode=str(request["branch_mode"]), + generation=int(request["generation"]), + commit_idempotency_key=commit_key, + expected_branch_oid=str(request["expected_branch_oid"]), + request_digest=request_digest({ + "task_id": request["task_id"], "repository": request["repository"], + "target_branch": request["target_branch"], "branch_mode": request["branch_mode"], + "generation": request["generation"], "expected_branch_oid": request["expected_branch_oid"], + "commit_idempotency_key": commit_key, + }), + ) + return controller.reserve_external_alias(alias) def _runtime(self, request: Mapping[str, Any]) -> tuple[Any, GitOpsJobController, ContinuationExecutor]: + lock = getattr(self, "_runtime_lock", None) + if lock is None: + lock = threading.RLock() + self._runtime_lock = lock + with lock: + return self._runtime_locked(request) + + def _runtime_locked(self, request: Mapping[str, Any]) -> tuple[Any, GitOpsJobController, ContinuationExecutor]: key = (str(request["task_id"]), int(request["generation"])) - if key in self.runtimes: return self.runtimes[key] - lease = WriterLeaseRequestV2(task_id=str(request["task_id"]), repository=str(request["repository"]), branch_mode=str(request.get("branch_mode", "CONTINUE_EXISTING")), base_oid=str(request["base_oid"]), expected_branch_oid=str(request["expected_branch_oid"]), target_branch=str(request["target_branch"]), generation=int(request["generation"]), lease_seconds=int(request.get("lease_seconds", 900))) - original_branch_mode = lease.branch_mode - if lease.branch_mode == "CREATE_NEW": + # Happens-before: persisted task binding validation, then durable alias + # reservation, then branch/worktree/Git materialization. No loopback + # property is used as authority. + _, controller = self._validated_task_controller(request, capability="governed.write.branch") + alias = self._reserve_alias(request, controller) + if key in self.runtimes: + cached = self.runtimes[key] + if cached[1] is not controller or cached[0].external_job_id != alias.external_job_id: + raise RuntimeError("CACHED_RUNTIME_ALIAS_CONFLICT") + return cached + requested_lease = WriterLeaseRequestV2(task_id=str(request["task_id"]), repository=str(request["repository"]), branch_mode=str(request.get("branch_mode", "CONTINUE_EXISTING")), base_oid=str(request["base_oid"]), expected_branch_oid=str(request["expected_branch_oid"]), target_branch=str(request["target_branch"]), generation=int(request["generation"]), lease_seconds=int(request.get("lease_seconds", 900))) + original_branch_mode = requested_lease.branch_mode + if requested_lease.branch_mode == "CREATE_NEW": try: - lease.validate(AuthorityGitAdapter(self.authority)) + requested_lease.validate(AuthorityGitAdapter(self.authority)) except Exception as exc: - # A request replay after the authority CAS may observe the - # already-created branch. Only the exact base identity is - # idempotent; all other validation failures remain fail-closed. + # Existing same-head branches are replayable only when this + # exact internal Job durably recorded its branch-creation + # result while holding the ACTIVE execution claim fence. if str(exc) != "TARGET_BRANCH_ALREADY_EXISTS": raise - # Bootstrap owns the durable binding; the runtime performs the one-time - # branch materialization through the authority CAS before continuation - # validation. No direct Git or legacy writer path is used. - self.authority.create_task_branch(lease.target_branch, lease.base_oid) + if not controller.has_branch_creation_provenance( + alias.internal_job_id, + branch=requested_lease.target_branch, + base_oid=requested_lease.base_oid, + ): + raise RuntimeError("TARGET_BRANCH_OWNERSHIP_UNPROVEN") from exc lease = WriterLeaseRequestV2( - task_id=lease.task_id, - repository=lease.repository, + task_id=requested_lease.task_id, + repository=requested_lease.repository, branch_mode="CONTINUE_EXISTING", - base_oid=lease.base_oid, - expected_branch_oid=lease.expected_branch_oid, - target_branch=lease.target_branch, - generation=lease.generation, - lease_seconds=lease.lease_seconds, + base_oid=requested_lease.base_oid, + expected_branch_oid=requested_lease.expected_branch_oid, + target_branch=requested_lease.target_branch, + generation=requested_lease.generation, + lease_seconds=requested_lease.lease_seconds, ) - lease.validate(AuthorityGitAdapter(self.authority)) - worktree_path = self.config.state_root / "worktrees" / _safe(lease.task_id) - if worktree_path.exists(): - workspace = DetachedGitWorkspace(worktree_path) else: - self.authority.create_detached_worktree(lease.expected_branch_oid, _safe(lease.task_id), self.config.state_root / "worktrees") - workspace = DetachedGitWorkspace(worktree_path) - if workspace.head_oid() != lease.expected_branch_oid or not workspace.is_detached(): raise RuntimeError("WORKTREE_HEAD_MISMATCH") - job_root = self._job_root(lease.task_id); controller = GitOpsJobController(JobStore(job_root, payload_root=job_root / "payloads")); credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path); remote = GiteaContinuationRemote(workspace=worktree_path, credential=credential, remote_name=self.config.remote_name, repository=lease.repository, api_base=self.config.api_base); executor = ContinuationExecutor(controller, AuthorityGitAdapter(self.authority), workspace, remote, credential) - binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW", external_job_id=str(request.get("job_id") or "") or None) + requested_lease.validate(AuthorityGitAdapter(self.authority)) + lease = requested_lease + worktree_path = self._worktree_path(lease.task_id) + workspace = DeferredDetachedGitWorkspace( + authority=self.authority, + lease=lease, + path=worktree_path, + storage_key=_task_storage_key(lease.task_id), + worktrees_root=self.config.state_root / "worktrees", + create_branch=original_branch_mode == "CREATE_NEW", + ) + credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path) + remote = GiteaContinuationRemote( + workspace=worktree_path, + credential=credential, + remote_name=self.config.remote_name, + repository=lease.repository, + api_base=self.config.api_base, + workspace_validator=workspace.remote_path, + ) + # The authenticated Stage 9 Task has a distinct execution lineage. + # Carry that durable identity into the continuation Job request; the + # generic ContinuationRuntimeBinding defaults are intentionally kept + # for non-Stage-9 callers. + worker_principal = "aios-autonomy-agent" + worker_lineage = "governed-continuation" + if str(request.get("task_id") or "") == R10_STAGE9_TASK_ID: + worker_principal = R10_STAGE9_WORKER_PRINCIPAL + worker_lineage = R10_STAGE9_WORKER_LINEAGE + executor = ContinuationExecutor(controller, AuthorityGitAdapter(self.authority), workspace, remote, credential) + binding = ContinuationRuntimeBinding(request=lease, workspace_path=worktree_path, lease_not_before=datetime.now(timezone.utc) - timedelta(seconds=1), lease_expires_at=datetime.now(timezone.utc) + timedelta(seconds=lease.lease_seconds), original_receipt_valid=True, original_grant_consumed=True, permissions=ContinuationPermissions(), remote_branch_create_allowed=original_branch_mode == "CREATE_NEW", external_job_id=alias.external_job_id, worker_principal=worker_principal, worker_lineage=worker_lineage) result = (binding, controller, executor); self.runtimes[key] = result; self.controllers[lease.task_id] = controller; return result + @staticmethod + def _validate_create_task_principal(request: Mapping[str, Any]) -> None: + principal = request.get(_PRIVATE_PRINCIPAL) + if not isinstance(principal, Mapping): + raise RuntimeError("TASK_PRINCIPAL_REQUIRED") + capabilities = principal.get("capabilities") + if ( + not isinstance(capabilities, (list, tuple, set)) + or "governed.task.create" not in capabilities + ): + raise RuntimeError("TASK_PRINCIPAL_SCOPE_CONFLICT") + for field in ( + "task_id", "repository", "base_oid", "target_branch", + "idempotency_key", + ): + value = request.get(field) + if not isinstance(value, str) or not value or value != value.strip(): + raise RuntimeError("TASK_CREATE_BINDING_INVALID") + if str(principal.get(field) or "") != value: + raise RuntimeError("TASK_PRINCIPAL_SCOPE_CONFLICT") + def create_task(self, request: Mapping[str, Any]) -> dict[str, Any]: - result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result + self._validate_create_task_principal(request) + result = self.bootstrap.create(request) + self.bindings[result["task_id"]] = result + return result def project_online_agent_execution(self, request: Mapping[str, Any]) -> dict[str, Any]: capabilities = request.get("required_capabilities") or [] @@ -181,7 +853,7 @@ class GovernedRuntime: return replay binding, _, _ = self._runtime(request) req = binding.request - return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True} + return {"schema": WriterLeaseRequestV2.schema, "task_id": req.task_id, "job_id": str(request["job_id"]), "repository": req.repository, "branch_mode": req.branch_mode, "base_oid": req.base_oid, "expected_branch_oid": req.expected_branch_oid, "target_branch": req.target_branch, "generation": req.generation, "lease_seconds": req.lease_seconds, "create_target_branch": req.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if req.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True} @staticmethod def _files(request: Mapping[str, Any]) -> dict[str, bytes]: @@ -190,58 +862,163 @@ class GovernedRuntime: return files def commit_files(self, request: Mapping[str, Any]) -> dict[str, Any]: + self._validated_task_controller(request, capability="governed.write.file") replay = self._durable_completed_replay(request) if replay is not None: return replay runtime = self.runtimes.get((str(request["task_id"]), int(request["generation"]))) if runtime is None: raise RuntimeError("LEASE_NOT_FOUND") - binding, _, executor = runtime + binding, controller, executor = runtime if request["target_branch"] != binding.request.target_branch or request["expected_branch_oid"] != binding.request.expected_branch_oid: raise RuntimeError("LEASE_BINDING_CONFLICT") + alias = controller.require_external_alias( + str(request.get("job_id") or ""), + controller.internal_job_id_for_idempotency_key(str(request["idempotency_key"])), + task_id=str(request["task_id"]), + commit_idempotency_key=str(request["idempotency_key"]), + ) commit = ContinuationCommit(idempotency_key=str(request["idempotency_key"]), files=self._files(request), message=str(request["message"])) - executor.workspace.materialize_files(commit.files) - return executor.execute(binding, commit) + # The continuation executor and controller use the internal id for + # durable audit. This HTTP/worker boundary must never expose it. + result = dict(executor.execute(binding, commit)) + if str(result.get("state") or "") == JobState.COMPLETED.value: + durable = controller.store.read(alias.internal_job_id) + self._ensure_completed_replay_evidence(controller, alias, durable) + result["job_id"] = str(request["job_id"]) + receipt = result.get("receipt") + if isinstance(receipt, Mapping): + result["receipt"] = { + key: value for key, value in receipt.items() if key != "internal_job_id" + } + return result - def _find_completed_replay_job(self, external_job_id: str) -> tuple[Path, dict[str, Any]] | None: - matches: list[tuple[Path, dict[str, Any]]] = [] - roots = [self.config.state_root / "jobs"] - for controller in self.controllers.values(): - roots.append(controller.store.root.parent) - seen: set[str] = set() - for jobs_root in roots: - if not jobs_root.is_dir(): - continue - candidates = [] - if (jobs_root / "state.sqlite3").is_file(): - candidates.append(jobs_root) - candidates.extend(root for root in jobs_root.iterdir() if root.is_dir()) - for root in candidates: - db = root / "state.sqlite3" - key = str(db.resolve()) if db.exists() else str(db) - if key in seen: - continue - seen.add(key) - if not db.is_file() or db.is_symlink(): - continue - try: - connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) - connection.row_factory = sqlite3.Row - try: - rows = connection.execute("SELECT * FROM jobs").fetchall() - finally: - connection.close() - for raw in rows: - row = dict(raw) - durable = json.loads(row["request_json"]) - binding = durable.get("binding", durable) - if str(binding.get("external_job_id") or "") == external_job_id: - matches.append((root, row)) - except (OSError, ValueError, TypeError, sqlite3.Error): - continue - if not matches: + def _find_completed_replay_job(self, request: Mapping[str, Any], *, capability: str) -> tuple[GitOpsJobController, JobAlias, dict[str, Any] | None] | None: + """Resolve public id inside its authenticated Task controller only.""" + external_job_id = str(request.get("job_id") or "").strip() + if not external_job_id: return None - if len(matches) != 1: - raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS") - return matches[0] + _, controller = self._validated_task_controller(request, capability=capability) + task_id = str(request.get("task_id") or "") + alias = controller.alias_by_external(external_job_id, task_id=task_id) + if alias is None: + return None + if alias.task_id != task_id: + raise RuntimeError("EXTERNAL_JOB_ALIAS_TASK_CONFLICT") + try: + return controller, alias, controller.store.read(alias.internal_job_id) + except Exception as exc: + if str(exc) == "JOB_NOT_FOUND": + return controller, alias, None + raise + + @staticmethod + def _require_exact_replay_commit_key( + request: Mapping[str, Any], alias: JobAlias, row: Mapping[str, Any] | None, + ) -> None: + key = str(request.get("idempotency_key") or "") + if not key or key != alias.commit_idempotency_key: + raise RuntimeError("COMPLETED_REPLAY_COMMIT_KEY_CONFLICT") + if row is not None: + durable = json.loads(row["request_json"]) + binding = durable.get("binding", durable) + internal_key = sha256_json({"client_idempotency_key": alias.commit_idempotency_key}) + if ( + row.get("idempotency_key") != internal_key + or binding.get("idempotency_key") != alias.commit_idempotency_key + ): + raise RuntimeError("COMPLETED_REPLAY_ALIAS_BINDING_CONFLICT") + + @staticmethod + def _require_used_replay_lease(controller: GitOpsJobController, row: Mapping[str, Any], epoch: int, generation: int, historical_head: str) -> None: + """A completed replay is admissible only for the exact final USED lease.""" + with controller.store._connect() as connection: + columns = {str(item[1]) for item in connection.execute("PRAGMA table_info(branch_leases)").fetchall()} + if "epoch" in columns: + lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1", (row["job_id"], epoch)).fetchone() + else: + if epoch != 1: + raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE") + lease = connection.execute("SELECT lease_id, state, current_head_oid, generation FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1", (row["job_id"],)).fetchone() + if lease is None: + raise RuntimeError("COMPLETED_REPLAY_LEASE_MISSING") + state, head, actual_generation = str(lease["state"]), str(lease["current_head_oid"]), int(lease["generation"]) + events = connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='branch_lease_events'").fetchone() + if events is not None: + event = connection.execute("SELECT state, current_head_oid, generation FROM branch_lease_events WHERE lease_id=? ORDER BY rowid DESC LIMIT 1", (lease["lease_id"],)).fetchone() + if event is not None: + state, head, actual_generation = str(event["state"]), str(event["current_head_oid"]), int(event["generation"]) + if state != "USED": + raise RuntimeError("COMPLETED_REPLAY_LEASE_NOT_USED") + if actual_generation != generation: + raise RuntimeError("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT") + if head != historical_head: + raise RuntimeError("COMPLETED_REPLAY_HISTORICAL_LEASE_CONFLICT") + + def _require_exact_completed_lease_request( + self, + controller: GitOpsJobController, + alias: JobAlias, + row: Mapping[str, Any], + request: Mapping[str, Any], + ) -> None: + """Validate exact lease replay before a crash-gap evidence append.""" + binding = controller.store.effective_binding(str(row["job_id"])) + target_branch = str(binding.get("target_branch") or binding.get("branch") or "") + for field, incoming in ( + ("task_id", request.get("task_id")), + ("repository", request.get("repository")), + ("target_branch", request.get("target_branch")), + ("generation", request.get("generation")), + ("branch_mode", request.get("branch_mode")), + ): + durable_value = target_branch if field == "target_branch" else binding.get(field) + if str(durable_value) != str(incoming): + raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT") + if alias.task_id != str(request.get("task_id") or ""): + raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT") + requested_epoch = request.get("source_lease_epoch") + with controller.store._connect() as connection: + columns = { + str(item[1]) for item in connection.execute( + "PRAGMA table_info(branch_leases)" + ).fetchall() + } + if "epoch" in columns: + if requested_epoch is None: + lease = connection.execute( + "SELECT epoch,current_head_oid,generation FROM branch_leases " + "WHERE job_id=? ORDER BY epoch DESC,rowid DESC LIMIT 1", + (str(row["job_id"]),), + ).fetchone() + else: + lease = connection.execute( + "SELECT epoch,current_head_oid,generation FROM branch_leases " + "WHERE job_id=? AND epoch=? ORDER BY rowid DESC LIMIT 1", + (str(row["job_id"]), int(requested_epoch)), + ).fetchone() + epoch = int(lease["epoch"]) if lease is not None else 0 + else: + if requested_epoch is not None and int(requested_epoch) != 1: + raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_UNAVAILABLE") + lease = connection.execute( + "SELECT current_head_oid,generation FROM branch_leases " + "WHERE job_id=? ORDER BY rowid DESC LIMIT 1", + (str(row["job_id"]),), + ).fetchone() + epoch = 1 + if lease is None: + raise RuntimeError("COMPLETED_REPLAY_LEASE_MISSING") + historical_head = str(lease["current_head_oid"]) + generation = int(lease["generation"]) + self._require_used_replay_lease( + controller, row, epoch, generation, historical_head, + ) + if generation != int(request.get("generation") or 0): + raise RuntimeError("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT") + if ( + str(request.get("base_oid") or "") != historical_head + or str(request.get("expected_branch_oid") or "") != historical_head + ): + raise RuntimeError("COMPLETED_REPLAY_EXPECTED_HEAD_CONFLICT") def _durable_completed_replay_lease(self, request: Mapping[str, Any]) -> dict[str, Any] | None: """Admit an exact completed replay before ordinary lease validation.""" @@ -250,13 +1027,41 @@ class GovernedRuntime: if request.get("source_lease_epoch") is not None: raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED") return None - match = self._find_completed_replay_job(external_job_id) + match = self._find_completed_replay_job(request, capability="governed.write.branch") if match is None: return None - root, row = match + controller, alias, row = match + self._require_exact_replay_commit_key(request, alias, row) + if row is None: + cached = self.runtimes.get((str(request["task_id"]), int(request["generation"]))) + if ( + cached is not None + and cached[1] is controller + and cached[0].external_job_id == alias.external_job_id + ): + # Normal acquireWriterLease -> first commit handoff. A cold + # alias without its authenticated in-memory lease remains a + # reconciliation record and is never guessed/adopted here. + return None + return {"job_id": alias.external_job_id, "state": JobState.RECONCILIATION_REQUIRED.value, "side_effect_state": "ALIAS_RESERVED_INTERNAL_JOB_MISSING", "http_status": 202} + if str(row["state"]) == JobState.REMOTE_VERIFIED.value: + self._finalization_executor_from_row(row, controller).reconcile( + str(row["job_id"]), expected_revision=int(row["revision"]), + ) + row = controller.store.read(str(row["job_id"])) + self._ensure_completed_replay_evidence(controller, alias, row) if str(row["state"]) != JobState.COMPLETED.value: return None - db = root / "state.sqlite3" + # Completion and replay evidence intentionally live in separate + # transactions. A crash between them is closed here by rebuilding + # only from the immutable COMPLETED receipt, final USED lease, and a + # fresh authority readback. The append itself is idempotent. + self._require_exact_completed_lease_request( + controller, alias, row, request, + ) + if controller.store.completed_replay_evidence(str(row["job_id"])) is None: + self._ensure_completed_replay_evidence(controller, alias, row) + db = controller.store.db_path connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) connection.row_factory = sqlite3.Row try: @@ -272,8 +1077,7 @@ class GovernedRuntime: immutable = tuple(key for key in evidence if key != "created_at") if any(any(item[key] != evidence[key] for key in immutable) for item in evidence_rows[1:]): raise RuntimeError("COMPLETED_REPLAY_AMBIGUOUS") - durable = json.loads(row["request_json"]) - binding = durable.get("binding", durable) + binding = controller.store.effective_binding(str(row["job_id"])) target_branch = str(binding.get("target_branch") or binding.get("branch") or "") for field, incoming in ( ("task_id", request.get("task_id")), @@ -286,12 +1090,20 @@ class GovernedRuntime: if str(durable_value) != str(incoming): raise RuntimeError("COMPLETED_REPLAY_BINDING_CONFLICT") historical_head = str(evidence.get("historical_lease_head_oid") or "") + self._require_used_replay_lease(controller, row, int(evidence["source_lease_epoch"]), int(evidence["generation"]), historical_head) if str(request.get("base_oid") or "") != historical_head or str(request.get("expected_branch_oid") or "") != historical_head: raise RuntimeError("COMPLETED_REPLAY_EXPECTED_HEAD_CONFLICT") if request.get("source_lease_epoch") is not None and int(request["source_lease_epoch"]) != int(evidence["source_lease_epoch"]): raise RuntimeError("COMPLETED_REPLAY_LEASE_EPOCH_CONFLICT") receipt = json.loads(row["receipt_json"] or "null") - if not isinstance(receipt, Mapping) or receipt.get("commit_oid") != receipt.get("authority_oid"): + if not isinstance(receipt, Mapping) or not ( + receipt.get("remote_verified") is True + and receipt.get("commit_visible") is True + and receipt.get("commit_oid") + == receipt.get("authority_oid") + == receipt.get("git_remote_oid") + == receipt.get("api_branch_oid") + ): raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID") authority_head = self.authority.read_ref("refs/heads/" + target_branch) if authority_head != evidence.get("current_head_oid"): @@ -331,50 +1143,30 @@ class GovernedRuntime: if request.get("source_lease_epoch") is not None: raise RuntimeError("COMPLETED_REPLAY_EXTERNAL_JOB_ID_REQUIRED") return None - matches: list[tuple[Path, dict[str, Any]]] = [] - roots = [self.config.state_root / "jobs"] - for controller in self.controllers.values(): - roots.append(controller.store.root.parent) - seen: set[str] = set() - for jobs_root in roots: - if not jobs_root.is_dir(): - continue - candidates = [] - if (jobs_root / "state.sqlite3").is_file(): - candidates.append(jobs_root) - candidates.extend(root for root in jobs_root.iterdir() if root.is_dir()) - for root in candidates: - db = root / "state.sqlite3" - key = str(db.resolve()) if db.exists() else str(db) - if key in seen: - continue - seen.add(key) - if not db.is_file() or db.is_symlink(): - continue - try: - connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) - connection.row_factory = sqlite3.Row - try: - rows = connection.execute("SELECT * FROM jobs").fetchall() - finally: - connection.close() - for raw in rows: - row = dict(raw) - durable = json.loads(row["request_json"]) - binding = durable.get("binding", durable) - if str(binding.get("external_job_id") or "") == external_job_id: - matches.append((root, row)) - except (OSError, ValueError, TypeError, sqlite3.Error): - continue - if not matches: + match = self._find_completed_replay_job(request, capability="governed.write.file") + if match is None: return None - if len(matches) != 1: - raise RuntimeError("EXTERNAL_JOB_MAPPING_AMBIGUOUS") - root, row = matches[0] + controller, alias, row = match + self._require_exact_replay_commit_key(request, alias, row) + if row is None: + cached = self.runtimes.get((str(request["task_id"]), int(request["generation"]))) + if ( + cached is not None + and cached[1] is controller + and cached[0].external_job_id == alias.external_job_id + ): + return None + return {"job_id": alias.external_job_id, "state": JobState.RECONCILIATION_REQUIRED.value, "side_effect_state": "ALIAS_RESERVED_INTERNAL_JOB_MISSING", "http_status": 202} + if str(row["state"]) == JobState.REMOTE_VERIFIED.value: + self._finalization_executor_from_row(row, controller).reconcile( + str(row["job_id"]), expected_revision=int(row["revision"]), + ) + row = controller.store.read(str(row["job_id"])) + self._ensure_completed_replay_evidence(controller, alias, row) + root = controller.store.root if str(row["state"]) != JobState.COMPLETED.value: return None - durable = json.loads(row["request_json"]) - binding = durable.get("binding", durable) + binding = controller.store.effective_binding(str(row["job_id"])) source_lease_epoch = request.get("source_lease_epoch") if source_lease_epoch is None: db = root / "state.sqlite3" @@ -398,11 +1190,11 @@ class GovernedRuntime: incoming = { "task_id": str(request.get("task_id") or ""), "external_job_id": external_job_id, - "internal_job_id": str(row["job_id"]), + "internal_job_id": alias.internal_job_id, "repository": str(request.get("repository") or ""), "target_branch": str(request.get("target_branch") or ""), "generation": int(request.get("generation") or 0), - "branch_mode": str(request.get("branch_mode") or ""), + "branch_mode": str(request.get("branch_mode") or alias.branch_mode), "payload_digest": hashlib.sha256(payload).hexdigest(), "files_digest": hashlib.sha256(json.dumps({path: hashlib.sha256(content).hexdigest() for path, content in sorted(files.items())}, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(), "message_digest": hashlib.sha256(str(request["message"]).encode("utf-8")).hexdigest(), @@ -445,8 +1237,8 @@ class GovernedRuntime: lease_state = str(event["state"]) historical_lease_head = str(event["current_head_oid"]) lease_generation = int(event["generation"]) - if lease_state not in {"ACTIVE", "USED", "EXPIRED"}: - raise RuntimeError("COMPLETED_REPLAY_LEASE_STATE_INVALID") + if lease_state != "USED": + raise RuntimeError("COMPLETED_REPLAY_LEASE_NOT_USED") if lease_generation != incoming["generation"]: raise RuntimeError("COMPLETED_REPLAY_LEASE_GENERATION_CONFLICT") finally: @@ -455,7 +1247,14 @@ class GovernedRuntime: authority_head = self.authority.read_ref("refs/heads/" + incoming["target_branch"]) if authority_head is None: raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_MISSING") - if receipt.get("commit_oid") != receipt.get("authority_oid"): + if not ( + receipt.get("remote_verified") is True + and receipt.get("commit_visible") is True + and receipt.get("commit_oid") + == receipt.get("authority_oid") + == receipt.get("git_remote_oid") + == receipt.get("api_branch_oid") + ): raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID") incoming["commit_oid"] = str(receipt["commit_oid"]) incoming["authority_head_oid"] = authority_head @@ -511,46 +1310,9 @@ class GovernedRuntime: evidence = {**receipt, "commit_oid": reconciliation["commit_oid"], "authority_oid": reconciliation["authority_head_oid"], "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"], "external_job_id": external_job_id} return {"job_id": external_job_id, "task_id": incoming["task_id"], "repository": incoming["repository"], "branch_mode": incoming["branch_mode"], "base_oid": request.get("base_oid"), "expected_branch_oid": request.get("expected_branch_oid"), "target_branch": incoming["target_branch"], "generation": incoming["generation"], "commit_oid": reconciliation["commit_oid"], "idempotency_key": row["idempotency_key"], "state": JobState.COMPLETED.value, "side_effect_state": row["side_effect_state"], "http_status": 200, "revision": int(row["revision"]), "receipt": evidence, "current_head_oid": reconciliation["current_head_oid"], "completed_replay_reconciliation_id": reconciliation["reconciliation_id"]} - def _controller_for_job(self, job_id: str) -> GitOpsJobController: - for controller in self.controllers.values(): - try: - controller.store.read(job_id) - return controller - except Exception: - continue - jobs_root = self.config.state_root / "jobs" - if jobs_root.is_dir(): - for root in jobs_root.iterdir(): - if not root.is_dir(): - continue - db = root / "state.sqlite3" - if not db.is_file() or db.is_symlink(): - continue - try: - connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) - connection.row_factory = sqlite3.Row - try: - rows = connection.execute("SELECT job_id, request_json FROM jobs").fetchall() - finally: - connection.close() - matched = False - for row in rows: - if str(row["job_id"]) == job_id: - matched = True - break - durable = json.loads(row["request_json"]) - binding = durable.get("binding", durable) - if str(binding.get("external_job_id") or "") == job_id: - matched = True - break - if not matched: - continue - controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads")) - self.controllers[str(job_id)] = controller - return controller - except (OSError, ValueError, TypeError, sqlite3.Error): - continue - raise RuntimeError("JOB_NOT_FOUND") + def _controller_for_job(self, task_id: str) -> GitOpsJobController: + """Compatibility seam: task scope, not a global job scan, selects store.""" + raise RuntimeError("TASK_PRINCIPAL_REQUIRED") @staticmethod def _valid_oid(value: object) -> bool: @@ -561,8 +1323,7 @@ class GovernedRuntime: row: Mapping[str, Any], controller: GitOpsJobController, ) -> dict[str, Any]: - raw = json.loads(row["request_json"]) - binding = raw.get("binding", raw) + binding = controller.store.effective_binding(str(row["job_id"])) if not isinstance(binding, dict): raise RuntimeError("DURABLE_BINDING_INVALID") task_id = str(binding.get("task_id") or binding.get("campaign_id") or "").strip() @@ -619,31 +1380,169 @@ class GovernedRuntime: "lease_seconds": int(binding.get("lease_seconds") or 900), } - def _executor_from_row( + def _ensure_completed_replay_evidence( + self, + controller: GitOpsJobController, + alias: JobAlias, + row: Mapping[str, Any], + ) -> dict[str, Any]: + """Materialize immutable replay evidence from one completed durable Job.""" + if JobState(str(row["state"])) != JobState.COMPLETED: + raise RuntimeError("COMPLETED_REPLAY_STATE_INVALID") + binding = controller.store.effective_binding(str(row["job_id"])) + receipt = json.loads(row.get("receipt_json") or "null") + if not isinstance(binding, Mapping) or not isinstance(receipt, Mapping): + raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID") + commit_oid = receipt.get("commit_oid") + if not ( + receipt.get("remote_verified") is True + and receipt.get("commit_visible") is True + and commit_oid + == receipt.get("authority_oid") + == receipt.get("git_remote_oid") + == receipt.get("api_branch_oid") + ): + raise RuntimeError("COMPLETED_REPLAY_RECEIPT_INVALID") + with controller.store._connect() as connection: + columns = { + str(item[1]) for item in connection.execute( + "PRAGMA table_info(branch_leases)" + ).fetchall() + } + lease = connection.execute( + "SELECT * FROM branch_leases WHERE job_id=? ORDER BY rowid DESC LIMIT 1", + (str(row["job_id"]),), + ).fetchone() + if lease is None: + raise RuntimeError("COMPLETED_REPLAY_LEASE_MISSING") + source_epoch = int(lease["epoch"]) if "epoch" in columns else 1 + historical_head = str(lease["current_head_oid"]) + generation = int(binding.get("generation") or 0) + target_branch = str(binding.get("target_branch") or binding.get("branch") or "") + authority_head = self.authority.read_ref("refs/heads/" + target_branch) + identity = { + "task_id": str(binding.get("task_id") or binding.get("campaign_id") or ""), + "external_job_id": alias.external_job_id, + "internal_job_id": alias.internal_job_id, + "repository": str(binding.get("repository") or ""), + "target_branch": target_branch, + "generation": generation, + "branch_mode": str(binding.get("branch_mode") or alias.branch_mode), + "payload_digest": str(binding.get("payload_digest") or ""), + "files_digest": str(binding.get("files_digest") or ""), + "message_digest": str(binding.get("message_digest") or ""), + "commit_oid": str(commit_oid or ""), + "authority_head_oid": str(authority_head or ""), + "worker_principal": str(binding.get("worker_principal") or ""), + "worker_lineage": str(binding.get("worker_lineage") or ""), + "source_lease_epoch": source_epoch, + "historical_lease_head_oid": historical_head, + "durable_revision": int(row["revision"]), + "completion_identity": sha256_json({ + "job_id": row["job_id"], + "revision": int(row["revision"]), + "receipt": receipt, + }), + "reason": "COMPLETED_REPLAY_LEASE_EVIDENCE_RECONCILIATION", + "provenance": { + "source": "governed-completed-replay", + "authority_readback": True, + }, + } + return controller.store.append_completed_replay_reconciliation( + str(row["job_id"]), identity, authority_head_oid=str(authority_head or "") + ) + + def _finalization_executor_from_row( self, row: Mapping[str, Any], controller: GitOpsJobController, ) -> ContinuationExecutor: request = self._reconstruct_legacy_binding(row, controller) - lease = WriterLeaseRequestV2(**request) - worktree_path = self.config.state_root / "worktrees" / _safe(lease.task_id) - if not worktree_path.exists(): - current = self.authority.read_ref("refs/heads/" + lease.target_branch) - if current is None: - raise RuntimeError("DURABLE_WORKTREE_BRANCH_MISSING") - self.authority.create_detached_worktree( - current, - _safe(lease.task_id), - self.config.state_root / "worktrees", - ) - workspace = DetachedGitWorkspace(worktree_path) credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path) remote = GiteaContinuationRemote( - workspace=worktree_path, + workspace=self.config.authoritative_git_dir, + credential=credential, + remote_name=self.config.remote_name, + repository=request["repository"], + api_base=self.config.api_base, + ) + return ContinuationExecutor( + controller, + AuthorityGitAdapter(self.authority), + ObservationOnlyWorkspace(str(request["expected_branch_oid"])), + remote, + credential, + ) + + def _executor_from_row( + self, + row: Mapping[str, Any], + controller: GitOpsJobController, + *, + recovery_lease_expires_at: str | None = None, + ) -> ContinuationExecutor: + request = self._reconstruct_legacy_binding(row, controller) + lease = WriterLeaseRequestV2(**request) + # This exact job has an observed pre-side-effect worktree whose old + # 02775 mode must remain unusable by generic reconciliation. Build a + # read-only observer over the Authority git-dir instead; only the + # controller-approved recovery path can later normalize and pin it. + if ( + str(row.get("job_id") or "") == P0_PRE_SIDE_EFFECT_RECOVERY_INTERNAL_JOB_ID + and recovery_lease_expires_at is not None + ): + workspace_root = self.config.state_root / "worktrees" + workspace = DeferredDetachedGitWorkspace( + authority=self.authority, + lease=lease, + path=workspace_root / _task_storage_key(lease.task_id), + storage_key=_task_storage_key(lease.task_id), + worktrees_root=workspace_root, + create_branch=str(request.get("branch_mode")) == "CREATE_NEW", + ) + credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path) + remote = GiteaContinuationRemote( + workspace=self.config.authoritative_git_dir, + credential=credential, + remote_name=self.config.remote_name, + repository=lease.repository, + api_base=self.config.api_base, + git_dir=self.config.authoritative_git_dir, + ) + return ContinuationExecutor( + controller, + AuthorityGitAdapter(self.authority), + workspace, + remote, + credential, + recovery_lease_expires_at=recovery_lease_expires_at, + ) + worktree_path = self._worktree_path(lease.task_id) + if _lexists(worktree_path): + remote_workspace = _secure_task_directory( + self.config.state_root / "worktrees", + _task_storage_key(lease.task_id), + allow_missing=False, + error="WORKTREE_PATH_INVALID", + ) + workspace = DetachedGitWorkspace.pinned( + self.config.state_root / "worktrees", _task_storage_key(lease.task_id), + ) + workspace_validator = lambda: workspace.path + remote_workspace = workspace.path + else: + workspace = ObservationOnlyWorkspace(lease.expected_branch_oid) + remote_workspace = self.config.authoritative_git_dir + workspace_validator = None + credential = ServerSideCredentialProjection(self.config.ssh_key_path, self.config.token_path) + remote = GiteaContinuationRemote( + workspace=remote_workspace, credential=credential, remote_name=self.config.remote_name, repository=lease.repository, api_base=self.config.api_base, + workspace_validator=workspace_validator, ) return ContinuationExecutor( controller, @@ -654,19 +1553,51 @@ class GovernedRuntime: ) def job(self, request: Mapping[str, Any], action: str) -> dict[str, Any]: - job_id = str(request["job_id"]) - controller = self._controller_for_job(job_id) - row = controller.store.read(job_id) + external_job_id = str(request["job_id"]) + capability = "git.read" if action == "get" else "governed.write.file" + principal, controller = self._validated_task_controller(request, capability=capability) + alias = controller.alias_by_external(external_job_id, task_id=str(request["task_id"])) + if alias is None: + if action == "get" and controller.scan_legacy_alias_candidates(): + raise RuntimeError("TASK_ALIAS_MIGRATION_REQUIRED") + raise RuntimeError("JOB_NOT_FOUND") + if alias.task_id != str(request["task_id"]): + raise RuntimeError("EXTERNAL_JOB_ALIAS_TASK_CONFLICT") + job_id = alias.internal_job_id + try: + row = controller.store.read(job_id) + except Exception as exc: + if str(exc) != "JOB_NOT_FOUND": + raise + return { + "job_id": alias.external_job_id, + "task_id": alias.task_id, + "state": JobState.RECONCILIATION_REQUIRED.value, + "side_effect_state": "ALIAS_RESERVED_INTERNAL_JOB_MISSING", + "http_status": 202, + } + + def public(result: dict[str, Any]) -> dict[str, Any]: + result["job_id"] = alias.external_job_id + receipt = result.get("receipt") + if isinstance(receipt, Mapping): + result["receipt"] = {key: value for key, value in receipt.items() if key != "internal_job_id"} + return result if action == "get": result = controller.store._receipt(row, duplicate=True).as_dict() evidence_reader = getattr(controller.store, "completed_replay_evidence", None) evidence = evidence_reader(job_id) if callable(evidence_reader) else None if evidence is not None: - branch = str(json.loads(row["request_json"]).get("binding", {}).get("branch") or row["branch"]) + effective = controller.store.effective_binding(job_id) + branch = str( + effective.get("branch") + or effective.get("target_branch") + or row["branch"] + ) authority_head = self.authority.read_ref("refs/heads/" + branch) if authority_head != evidence["authority_head_oid"] or evidence["current_head_oid"] != authority_head: raise RuntimeError("COMPLETED_REPLAY_AUTHORITY_CONFLICT") - result["job_id"] = evidence["external_job_id"] + result["job_id"] = alias.external_job_id result["current_head_oid"] = evidence["current_head_oid"] result["completed_replay_reconciliation_id"] = evidence["reconciliation_id"] result["receipt"] = { @@ -677,12 +1608,41 @@ class GovernedRuntime: "external_job_id": evidence["external_job_id"], "completed_replay_reconciliation_id": evidence["reconciliation_id"], } - return result + return public(result) + try: + expected_revision = int(request["expected_revision"]) + except (KeyError, TypeError, ValueError) as exc: + raise RuntimeError("EXPECTED_REVISION_INVALID") from exc + if expected_revision < 0 or expected_revision != int(row["revision"]): + raise RuntimeError("REVISION_CAS_CONFLICT") if action == "resume": - return controller.resume(job_id).as_dict() + if JobState(row["state"]) == JobState.REMOTE_VERIFIED: + result = self._finalization_executor_from_row(row, controller).reconcile( + job_id, expected_revision=expected_revision, + ) + durable = controller.store.read(job_id) + self._ensure_completed_replay_evidence(controller, alias, durable) + return public(result) + return public(controller.resume(job_id, expected_revision=expected_revision).as_dict()) + if JobState(row["state"]) == JobState.REMOTE_VERIFIED: + result = self._finalization_executor_from_row(row, controller).reconcile( + job_id, expected_revision=expected_revision, + ) + durable = controller.store.read(job_id) + self._ensure_completed_replay_evidence(controller, alias, durable) + return public(result) if JobState(row["state"]) not in _RECOVERABLE: - return controller.store._receipt(row, duplicate=True).as_dict() - return self._executor_from_row(row, controller).reconcile(job_id) + return public(controller.store._receipt(row, duplicate=True).as_dict()) + recovery_expiry = ( + str(principal.get("task_lease_expires_at")) + if action == "reconcile" and job_id == P0_PRE_SIDE_EFFECT_RECOVERY_INTERNAL_JOB_ID + else None + ) + return public(self._executor_from_row( + row, controller, recovery_lease_expires_at=recovery_expiry, + ).reconcile( + job_id, expected_revision=expected_revision, + )) def _snapshot_task(self) -> dict[str, Any]: candidates: list[dict[str, Any]] = [] @@ -698,7 +1658,10 @@ class GovernedRuntime: generation = 0 reconciliation_state = "OBSERVATION_REQUIRED" projection: dict[str, Any] = {} - db = self.config.state_root / "jobs" / _safe(task_id) / "state.sqlite3" + try: + db = self._job_root(task_id) / "state.sqlite3" + except RuntimeError: + continue if db.is_file() and not db.is_symlink(): connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True) connection.row_factory = sqlite3.Row @@ -732,7 +1695,8 @@ class GovernedRuntime: def snapshot(self) -> dict[str, Any]: task = self._snapshot_task() - return build_control_plane_snapshot(target_version=self.config.target_version, runtime=self.runtime_status(), repository={"identity": self.config.repository, "authority_git_dir": str(self.config.authoritative_git_dir), "authority_main_oid": self.authority.read_ref("refs/heads/main")}, production={"baseline_version": "V2R1C7", "target_oid": "c7adece1cc8beffe7c718d53c775898b45d6c7f8", "deployment_state": "ACTIVE"}, task=task, capabilities={"legacy_writes_enabled": False, "governed_writes_enabled": True, "force_push": False, "main_write": False, "tag_write": False, "baseline_write": False, "production_write": False}, credential_broker={"status": "SERVER_SIDE_ONLY", "provider": "FileCredentialProvider"}) + runtime = self.runtime_status() + return build_control_plane_snapshot(target_version=self.config.target_version, runtime=runtime, repository={"identity": self.config.repository, "authority_git_dir": str(self.config.authoritative_git_dir), "authority_main_oid": self.authority.read_ref("refs/heads/main")}, production={"baseline_version": "V2R1C7", "target_oid": "c7adece1cc8beffe7c718d53c775898b45d6c7f8", "deployment_state": "ACTIVE"}, task=task, capabilities={"legacy_writes_enabled": False, "governed_writes_enabled": runtime["governed_writes_enabled"], "force_push": False, "main_write": False, "tag_write": False, "baseline_write": False, "production_write": False}, credential_broker={"status": "SERVER_SIDE_ONLY", "provider": "FileCredentialProvider"}) def handlers(self) -> dict[str, Any]: return { @@ -760,47 +1724,191 @@ def build_governed_handlers(runtime_config: RuntimeConfig | Mapping[str, Any]) - return GovernedRuntime(runtime_config).handlers() +def build_bridge_adapter( + runtime: GovernedRuntime, + *, + task_admission_handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None, +) -> BridgeV2Adapter: + handlers = dict(runtime.handlers()) + if task_admission_handlers is not None: + if ( + set(task_admission_handlers) != set(TASK_ADMISSION_OPERATIONS) + or any( + not callable(task_admission_handlers.get(operation)) + for operation in TASK_ADMISSION_OPERATIONS + ) + ): + raise ValueError("TASK_ADMISSION_HANDLERS_INCOMPLETE") + handlers.update(task_admission_handlers) + return BridgeV2Adapter( + runtime_status=runtime.runtime_status, + handlers=handlers, + ) + + class _HTTPError(RuntimeError): pass class BridgeHandler(BaseHTTPRequestHandler): adapter: BridgeV2Adapter | None = None + # Installed by the hosting service from trusted transport/auth middleware. + # No default provider exists: consequential task operations fail closed. + principal_verifier: TaskPrincipalVerifier | None = None def log_message(self, *_args: Any) -> None: return def _json(self, value: Any, status: int = 200) -> None: raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode(); self.send_response(status); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw) + @staticmethod + def _result_status(value: Any) -> int: + status = value.get("http_status") if isinstance(value, Mapping) else None + return status if isinstance(status, int) and 100 <= status <= 599 else 200 def _body(self) -> dict[str, Any]: try: length = int(self.headers.get("Content-Length", "-1")) except ValueError as exc: raise _HTTPError("CONTENT_LENGTH_INVALID") from exc if length < 0 or length > MAX_BODY: raise _HTTPError("REQUEST_TOO_LARGE") - value = json.loads(self.rfile.read(length).decode("utf-8")) + def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise _HTTPError("JSON_DUPLICATE_FIELD") + value[key] = item + return value + value = json.loads( + self.rfile.read(length).decode("utf-8"), + object_pairs_hook=strict_object, + ) if not isinstance(value, dict): raise _HTTPError("JSON_OBJECT_REQUIRED") return value def _allowed(self, operation: str, body: Mapping[str, Any]) -> None: - allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "generation", "branch_mode", "source_lease_epoch"} + if operation in TASK_ADMISSION_OPERATIONS: + allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | set( + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation] + ) + else: + allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "expected_revision", "generation", "branch_mode", "source_lease_epoch"} + if operation == "projectOnlineAgentExecution": allowed.add("execution") if operation == "commitFilesGoverned": allowed.add("files") - allowed.update({"owner_approval", "execute", "job_id", "historical_lease_head_oid"}) + if operation not in TASK_ADMISSION_OPERATIONS: + allowed.update({"owner_approval", "execute", "job_id", "historical_lease_head_oid"}) + if "principal" in body or _PRIVATE_PRINCIPAL in body: + raise _HTTPError("CALLER_PRINCIPAL_FORBIDDEN") if set(body) - allowed: raise _HTTPError("UNKNOWN_FIELDS") + + def _verified_request(self, operation: str, body: Mapping[str, Any]) -> dict[str, Any]: + if operation not in {"createGovernedTask", "projectOnlineAgentExecution", "acquireWriterLease", "commitFileGoverned", "commitFilesGoverned", "getGitOpsJob", "resumeGitOpsJob", "reconcileGitOpsJob"}: + return dict(body) + if operation in _TASK_PRINCIPAL_MUTATING_OPERATIONS: + adapter = self.adapter + if adapter is None: + raise _HTTPError("TASK_PRINCIPAL_WRITES_DISABLED") + try: + runtime = adapter.getRuntimeStatus() + except Exception as exc: + raise _HTTPError("TASK_PRINCIPAL_WRITES_DISABLED") from exc + if ( + not isinstance(runtime, Mapping) + or runtime.get("governed_writes_enabled") is not True + or runtime.get("legacy_global_writes_enabled") is not False + or runtime.get("task_principal_writes_enabled") is not True + ): + raise _HTTPError("TASK_PRINCIPAL_WRITES_DISABLED") + verifier = type(self).principal_verifier + if verifier is None: + raise _HTTPError("TASK_PRINCIPAL_VERIFIER_UNAVAILABLE") + get_all = getattr(self.headers, "get_all", None) + if callable(get_all): + authorization_values = get_all("Authorization") or [] + else: + authorization = self.headers.get("Authorization") + authorization_values = [] if authorization is None else [authorization] + transport = { + "operation": operation, + "client_address": self.client_address[0] if self.client_address else None, + "headers": { + "authorization": authorization_values[0] if len(authorization_values) == 1 else None, + "x-request-id": self.headers.get("X-Request-Id"), + }, + } + principal = verifier(transport, dict(body)) + if not isinstance(principal, Mapping): + raise _HTTPError("TASK_PRINCIPAL_REJECTED") + return {**body, _PRIVATE_PRINCIPAL: dict(principal)} def do_GET(self) -> None: if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return if self.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return if self.path == "/v2/capabilities": self._json(self.adapter.getGovernedWriteCapabilities()); return if self.path == "/v2/control-plane-snapshot": self._json(self.adapter.getControlPlaneSnapshot({})); return + parsed = urlsplit(self.path) + if parsed.path == "/v2/task-admission/status": + if not callable(self.adapter.handlers.get("getTaskAdmissionStatus")): + self._json({"error": "NOT_FOUND"}, 404) + return + try: + parameters = parse_qsl(parsed.query, keep_blank_values=True) + if ( + len(parameters) != 1 + or parameters[0][0] != "envelope_id" + or not parameters[0][1] + ): + raise _HTTPError("TASK_ADMISSION_QUERY_INVALID") + result = self.adapter.getTaskAdmissionStatus( + {"envelope_id": parameters[0][1]} + ) + self._json(result, self._result_status(result)) + except TaskAdmissionError as exc: + self._json({"error": str(exc)}, exc.status) + except _HTTPError as exc: + self._json({"error": str(exc)}, 400) + except (ValueError, BridgeV2Error) as exc: + self._json({"error": str(exc)}, 400) + except Exception as exc: + error = str(exc) or type(exc).__name__ + self._json({"error": error}, 503) + return self._json({"error": "NOT_FOUND"}, 404) def do_POST(self) -> None: operation = ROUTES.get(self.path) if operation is None or operation in ("getRuntimeStatus", "getGovernedWriteCapabilities") or self.adapter is None: self._json({"error": "NOT_FOUND"}, 404); return + if ( + operation in TASK_ADMISSION_OPERATIONS + and not callable(self.adapter.handlers.get(operation)) + ): + self._json({"error": "NOT_FOUND"}, 404) + return try: - body = self._body(); self._allowed(operation, body); self._json(getattr(self.adapter, operation)(body)) - except _HTTPError as exc: self._json({"error": str(exc)}, 413 if str(exc) == "REQUEST_TOO_LARGE" else 400) + body = self._body(); self._allowed(operation, body) + result = getattr(self.adapter, operation)(self._verified_request(operation, body)) + self._json(result, self._result_status(result)) + except _HTTPError as exc: + error = str(exc) + if error == "REQUEST_TOO_LARGE": + status = 413 + elif error == "TASK_PRINCIPAL_WRITES_DISABLED": + status = 423 + else: + status = 401 if error.startswith("TASK_PRINCIPAL_") else 400 + self._json({"error": error}, status) + except TaskAdmissionError as exc: self._json({"error": str(exc)}, exc.status) except (BootstrapError, ValueError, BridgeV2Error) as exc: self._json({"error": str(exc)}, 409 if "CONFLICT" in str(exc) else 400) - except Exception as exc: self._json({"error": str(exc) or type(exc).__name__}, 409 if any(token in str(exc) for token in ("CONFLICT", "EXPECTATION", "MONOTONIC")) else 503) + except Exception as exc: + error = str(exc) or type(exc).__name__ + if error.startswith("TASK_PRINCIPAL_"): + status = 403 + elif any(token in error for token in ("CONFLICT", "EXPECTATION", "MONOTONIC", "REVISION_CAS")): + status = 409 + else: + status = 503 + self._json({"error": error}, status) def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892) -> None: BridgeHandler.adapter = adapter; ThreadingHTTPServer((host, port), BridgeHandler).serve_forever() -def main() -> None: +def main(*, task_admission_handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None: + activation_raw = os.environ.get( + "AIOS_TASK_ADMISSION_ACTIVATION_FILE", + "/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json", + ).strip() state_root = Path("/var/lib/aios-gitops/bridge-v2-staging") token_path = Path(os.environ.get("AIOS_GITEA_TOKEN_FILE", "/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token")) ssh_key_path = Path(os.environ.get("AIOS_GITEA_SSH_KEY_PATH", "/home/aiman/.ssh/id_ed25519_codex_tmp")) @@ -821,9 +1929,53 @@ def main() -> None: "/var/lib/aios-gitops-runtime-anchor/baseline/current.json", )), ) - runtime = GovernedRuntime(config) + from aios_task_principal_admission import build_task_principal_verifier + from aios_task_principal_issuer_server import _read_public_manifest + + credential_directory = Path(os.environ.get( + "CREDENTIALS_DIRECTORY", + "/run/credentials/aios-chatgpt-bridge-v2-staging.service", + )) + trust_path = Path(os.environ.get( + "AIOS_TASK_PRINCIPAL_TRUST_MANIFEST_FILE", + str(credential_directory / "task_principal_trust_manifest"), + )) + bridge_id = os.environ.get("AIOS_TASK_PRINCIPAL_BRIDGE_ID", "").strip() + if not bridge_id: + raise RuntimeError("TASK_PRINCIPAL_BRIDGE_ID_REQUIRED") + verifier = build_task_principal_verifier( + Path(os.environ.get("AIOS_AGENT_TASK_DB_PATH", "/var/lib/aios-agent-task/state.sqlite3")), + _read_public_manifest(trust_path), + bridge_id, + ) + if verifier.readiness().get("ready") is not True: + raise RuntimeError("TASK_PRINCIPAL_VERIFIER_NOT_READY") + BridgeHandler.principal_verifier = verifier + runtime = GovernedRuntime( + config, + storage_activation_task_ids=verifier.activation_scope_task_ids(), + ) + if task_admission_handlers is None: + if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip(): + from aios_task_admission_live_adapters import ( + build_task_admission_handlers_from_environment, + ) + task_admission_handlers = build_task_admission_handlers_from_environment( + post_admission=runtime.project_admitted_task, + ) + elif activation_raw and Path(activation_raw).is_file(): + from aios_task_admission_live_adapters import ( + build_task_admission_handlers_from_activation_file, + ) + task_admission_handlers = build_task_admission_handlers_from_activation_file( + Path(activation_raw), + post_admission=runtime.project_admitted_task, + ) serve( - BridgeV2Adapter(runtime_status=runtime.runtime_status(), handlers=runtime.handlers()), + build_bridge_adapter( + runtime, + task_admission_handlers=task_admission_handlers, + ), port=int(os.environ.get("AIOS_BRIDGE_V2_PORT", "18892")), ) diff --git a/active-runtime/app/aios_task_admission_bootstrap.py b/active-runtime/app/aios_task_admission_bootstrap.py new file mode 100644 index 0000000..19e4cc3 --- /dev/null +++ b/active-runtime/app/aios_task_admission_bootstrap.py @@ -0,0 +1,927 @@ +"""Fail-closed bootstrap for narrow Owner-delegated Agent Task admission. + +This module solves the authorization bootstrap problem without turning the +runtime anchor into a writer and without weakening TaskPrincipal semantics. + +Security model: + +* A task-admission envelope is created from a server-normalized request. +* The envelope is authorized either by an already-valid OwnerDelegationEpoch + or by an Owner signature verified by an injected trust backend. +* The derived TaskAdmissionGrant can only narrow the parent authorization. +* The grant is handed to the existing Agent Task control plane through an + injected TaskAdmissionPort. This module never writes the Task database. +* TaskPrincipal issuance remains downstream of a real Task claim/TaskLease. +* Exact idempotent replay returns the same admitted Task; conflicting reuse is + rejected. +""" + +from __future__ import annotations + +import base64 +from contextlib import closing, contextmanager +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import secrets +import sqlite3 +import threading +from typing import Any, Callable, Mapping, Protocol, Sequence + + +SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1" +SCHEMA_GRANT = "aios.task-admission-grant.v1" +SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1" +SCHEMA_RECEIPT = "aios.task-admission-receipt.v1" + +OID_RE = re.compile(r"^[0-9a-f]{40}$") +TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$") +BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$") +IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,255}$") +CAPABILITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +KEY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{2,255}$") + +PROHIBITED_CAPABILITIES = frozenset( + { + "git.global_write", + "git.main_write", + "git.force_push", + "tag.publish", + "baseline.finalize", + "production.write", + "production.switch", + "runtime_anchor.write", + "shell.arbitrary", + "credential.export", + } +) + + +class TaskAdmissionError(RuntimeError): + """Stable fail-closed task-admission error.""" + + def __init__( + self, + code: str, + status: int = 400, + *, + details: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(code) + self.code = code + self.status = status + self.details = dict(details or {}) + + +@dataclass(frozen=True, slots=True) +class TaskAdmissionPolicy: + repository: str = "aios/aios" + trust_domain: str = "aios.owner" + branch_prefix: str = "agent/" + allowed_capabilities: frozenset[str] = frozenset( + { + "governed.task.create", + "agent-task:control", + "task.principal.issue", + "writer.lease", + "governed.write.files", + "git.read", + "git.test", + "governed.write.branch", + "governed.write.file", + "testing", + "verification", + } + ) + max_file_count: int = 64 + max_task_ttl_seconds: int = 3600 + max_envelope_ttl_seconds: int = 900 + max_risk: str = "medium" + policy_digest: str = "" + + def normalized_policy_digest(self) -> str: + if self.policy_digest: + return self.policy_digest + return sha256_json( + { + "repository": self.repository, + "trust_domain": self.trust_domain, + "branch_prefix": self.branch_prefix, + "allowed_capabilities": sorted(self.allowed_capabilities), + "max_file_count": self.max_file_count, + "max_task_ttl_seconds": self.max_task_ttl_seconds, + "max_envelope_ttl_seconds": self.max_envelope_ttl_seconds, + "max_risk": self.max_risk, + "prohibited_capabilities": sorted(PROHIBITED_CAPABILITIES), + } + ) + + +@dataclass(frozen=True, slots=True) +class OwnerDelegationEpoch: + """Already-verified durable Owner authorization. + + The verifier/backend is responsible for authenticating the epoch before + returning it to this service. + """ + + epoch_id: str + owner_identity: str + trust_domain: str + repository: str + allowed_branch_prefixes: tuple[str, ...] + allowed_capabilities: frozenset[str] + allowed_file_prefixes: tuple[str, ...] + risk_ceiling: str + issued_at: int + not_before: int + expires_at: int + revocation_epoch: int + policy_digest: str + schema: str = SCHEMA_EPOCH + + def as_public_dict(self) -> dict[str, Any]: + value = asdict(self) + value["allowed_capabilities"] = sorted(self.allowed_capabilities) + value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes) + value["allowed_file_prefixes"] = list(self.allowed_file_prefixes) + return value + + +@dataclass(frozen=True, slots=True) +class TaskAdmissionEnvelope: + envelope_id: str + idempotency_key: str + task_id: str + repository: str + authority_base_oid: str + target_branch: str + generation: int + execution_digest: str + requested_capabilities: tuple[str, ...] + file_allowlist: tuple[str, ...] + risk: str + trust_domain: str + policy_digest: str + issued_at: int + not_before: int + expires_at: int + nonce: str + request_digest: str + schema: str = SCHEMA_ENVELOPE + + def as_dict(self) -> dict[str, Any]: + value = asdict(self) + value["requested_capabilities"] = list(self.requested_capabilities) + value["file_allowlist"] = list(self.file_allowlist) + return value + + +@dataclass(frozen=True, slots=True) +class TaskAdmissionGrant: + grant_id: str + envelope_id: str + task_id: str + repository: str + authority_base_oid: str + target_branch: str + generation: int + execution_digest: str + capabilities: tuple[str, ...] + file_allowlist: tuple[str, ...] + risk: str + trust_domain: str + policy_digest: str + authorization_kind: str + owner_identity: str + parent_epoch_id: str | None + issued_at: int + expires_at: int + one_time_use: bool = True + schema: str = SCHEMA_GRANT + + def as_dict(self) -> dict[str, Any]: + value = asdict(self) + value["capabilities"] = list(self.capabilities) + value["file_allowlist"] = list(self.file_allowlist) + return value + + +class OwnerAuthorizationBackend(Protocol): + """Bridge to existing trusted Owner authorization. + + No permissive default implementation is provided. + """ + + def verify_delegation_epoch(self, epoch_id: str) -> OwnerDelegationEpoch: + ... + + def verify_owner_signature( + self, + *, + key_id: str, + canonical_payload: bytes, + signature_b64: str, + ) -> str: + """Return the verified Owner identity or raise.""" + ... + + +class TaskAdmissionPort(Protocol): + """Existing Agent Task control-plane port.""" + + def admit(self, grant: TaskAdmissionGrant) -> Mapping[str, Any]: + ... + + +def utc_seconds() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def sha256_json(value: Any) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _normalize_files(raw: Sequence[object], *, maximum: int) -> tuple[str, ...]: + if not isinstance(raw, (list, tuple)): + raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403) + if not raw or len(raw) > maximum: + raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403) + normalized: list[str] = [] + seen: set[str] = set() + for item in raw: + if ( + not isinstance(item, str) + or not item + or "\\" in item + or any(ord(char) < 32 or ord(char) == 127 for char in item) + ): + raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403) + path = PurePosixPath(item) + if path.is_absolute() or ".." in path.parts or "." in path.parts: + raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403) + text = path.as_posix() + # Do not normalize a caller supplied path. A signature must bind the + # exact spelling, not a convenient equivalent such as ``a//b``. + if text != item: + raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403) + if text.startswith(".git/") or text == ".git": + raise TaskAdmissionError("TASK_ADMISSION_FILE_PATH_INVALID", 403) + if text not in seen: + normalized.append(text) + seen.add(text) + if not normalized: + raise TaskAdmissionError("TASK_ADMISSION_FILE_ALLOWLIST_REJECTED", 403) + return tuple(sorted(normalized)) + + +def _normalize_capabilities( + raw: Sequence[object], + *, + policy: TaskAdmissionPolicy, +) -> tuple[str, ...]: + if not raw: + raise TaskAdmissionError("TASK_ADMISSION_CAPABILITIES_REQUIRED", 403) + values: set[str] = set() + for item in raw: + if not isinstance(item, str) or not CAPABILITY_RE.fullmatch(item): + raise TaskAdmissionError("TASK_ADMISSION_CAPABILITY_INVALID", 403) + if "*" in item: + raise TaskAdmissionError("TASK_ADMISSION_WILDCARD_CAPABILITY_REJECTED", 403) + values.add(item) + if values & PROHIBITED_CAPABILITIES: + raise TaskAdmissionError("TASK_ADMISSION_PROHIBITED_CAPABILITY", 403) + if not values.issubset(policy.allowed_capabilities): + raise TaskAdmissionError("TASK_ADMISSION_CAPABILITY_OUT_OF_POLICY", 403) + return tuple(sorted(values)) + + +def _risk_rank(value: str) -> int: + ranks = {"low": 0, "medium": 1, "high": 2} + try: + return ranks[value] + except KeyError: + raise TaskAdmissionError("TASK_ADMISSION_RISK_INVALID", 403) from None + + +def _request_for_digest( + *, + task_id: str, + repository: str, + authority_base_oid: str, + target_branch: str, + generation: int, + execution_digest: str, + requested_capabilities: tuple[str, ...], + file_allowlist: tuple[str, ...], + risk: str, + trust_domain: str, + policy_digest: str, +) -> dict[str, Any]: + return { + "task_id": task_id, + "repository": repository, + "authority_base_oid": authority_base_oid, + "target_branch": target_branch, + "generation": generation, + "execution_digest": execution_digest, + "requested_capabilities": list(requested_capabilities), + "file_allowlist": list(file_allowlist), + "risk": risk, + "trust_domain": trust_domain, + "policy_digest": policy_digest, + } + + +class TaskAdmissionStore: + """Portable durable state; all replay decisions are transactionally stored.""" + + def __init__(self, path: Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.lock_root = Path(str(self.path) + ".locks") + self.lock_root.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + with self._connection() as con: + con.executescript( + """ + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS task_admission_envelopes( + idempotency_key TEXT PRIMARY KEY, + envelope_id TEXT NOT NULL UNIQUE, + request_digest TEXT NOT NULL, + envelope_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_admission_grants( + envelope_id TEXT PRIMARY KEY, + grant_id TEXT NOT NULL UNIQUE, + grant_digest TEXT NOT NULL, + grant_json TEXT NOT NULL, + authorization_digest TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_admission_receipts( + envelope_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + receipt_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + """ + ) + columns = { + row["name"] for row in con.execute("PRAGMA table_info(task_admission_grants)") + } + if "authorization_digest" not in columns: + con.execute( + "ALTER TABLE task_admission_grants ADD COLUMN authorization_digest TEXT NOT NULL DEFAULT ''" + ) + + def _connect(self) -> sqlite3.Connection: + con = sqlite3.connect(self.path, timeout=30, isolation_level=None) + con.row_factory = sqlite3.Row + return con + + @contextmanager + def _connection(self): + """Yield an owned main-store connection and close it deterministically.""" + with closing(self._connect()) as con: + try: + yield con + finally: + if con.in_transaction: + con.rollback() + + @contextmanager + def admission_lock(self, envelope_id: str, *, timeout_seconds: float = 5.0): + """Hold a crash-releasing lock for one envelope, outside the main DB.""" + lock_name = hashlib.sha256(envelope_id.encode("utf-8")).hexdigest() + ".sqlite3" + lock_path = self.lock_root / lock_name + with closing(sqlite3.connect(lock_path, timeout=timeout_seconds, isolation_level=None)) as con: + try: + con.execute("BEGIN EXCLUSIVE") + except sqlite3.OperationalError as exc: + raise TaskAdmissionError("TASK_ADMISSION_IN_PROGRESS", 503) from exc + try: + yield + con.execute("COMMIT") + finally: + if con.in_transaction: + con.rollback() + + def get_by_idempotency(self, key: str) -> dict[str, Any] | None: + with self._connection() as con: + row = con.execute( + "SELECT * FROM task_admission_envelopes WHERE idempotency_key=?", + (key,), + ).fetchone() + return dict(row) if row else None + + def get_envelope(self, envelope_id: str) -> dict[str, Any] | None: + with self._connection() as con: + row = con.execute( + "SELECT * FROM task_admission_envelopes WHERE envelope_id=?", + (envelope_id,), + ).fetchone() + return dict(row) if row else None + + def save_envelope( + self, + *, + idempotency_key: str, + envelope_id: str, + request_digest: str, + envelope_json: str, + created_at: int, + ) -> dict[str, Any]: + with self._lock, self._connection() as con: + con.execute("BEGIN IMMEDIATE") + row = con.execute( + "SELECT request_digest,envelope_id FROM task_admission_envelopes WHERE idempotency_key=?", + (idempotency_key,), + ).fetchone() + if row is not None: + con.execute("ROLLBACK") + if row["request_digest"] != request_digest: + raise TaskAdmissionError("TASK_ADMISSION_IDEMPOTENCY_CONFLICT", 409) + return json.loads( + con.execute( + "SELECT envelope_json FROM task_admission_envelopes WHERE idempotency_key=?", + (idempotency_key,), + ).fetchone()["envelope_json"] + ) + con.execute( + """ + INSERT INTO task_admission_envelopes( + idempotency_key,envelope_id,request_digest,envelope_json,status,created_at + ) VALUES(?,?,?,?,?,?) + """, + ( + idempotency_key, + envelope_id, + request_digest, + envelope_json, + "PENDING_AUTHORIZATION", + created_at, + ), + ) + con.execute("COMMIT") + return json.loads(envelope_json) + + def claim_grant( + self, + *, + envelope_id: str, + grant: TaskAdmissionGrant, + authorization_digest: str, + created_at: int, + ) -> tuple[TaskAdmissionGrant, bool]: + """Persist the immutable grant before the external Task side effect. + + ``BEGIN IMMEDIATE`` serializes only this SQLite writer transaction; + it does not serialize unrelated envelopes. A second proof cannot + replace the winning proof/grant for the same envelope. + """ + grant_json = canonical_json_bytes(grant.as_dict()).decode("utf-8") + grant_digest = hashlib.sha256(grant_json.encode("utf-8")).hexdigest() + with self._lock, self._connection() as con: + con.execute("BEGIN IMMEDIATE") + row = con.execute( + "SELECT grant_json,authorization_digest FROM task_admission_grants WHERE envelope_id=?", + (envelope_id,), + ).fetchone() + if row is not None: + con.execute("ROLLBACK") + if row["authorization_digest"] != authorization_digest: + raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_CONFLICT", 409) + raw = json.loads(row["grant_json"]) + return ( + TaskAdmissionGrant( + **{ + **raw, + "capabilities": tuple(raw["capabilities"]), + "file_allowlist": tuple(raw["file_allowlist"]), + } + ), + False, + ) + con.execute( + "INSERT INTO task_admission_grants(envelope_id,grant_id,grant_digest,grant_json,authorization_digest,created_at) VALUES(?,?,?,?,?,?)", + (envelope_id, grant.grant_id, grant_digest, grant_json, authorization_digest, created_at), + ) + con.execute( + "UPDATE task_admission_envelopes SET status='ADMITTING' WHERE envelope_id=?", + (envelope_id,), + ) + con.execute("COMMIT") + return grant, True + + def receipt(self, envelope_id: str) -> dict[str, Any] | None: + with self._connection() as con: + row = con.execute( + "SELECT receipt_json FROM task_admission_receipts WHERE envelope_id=?", + (envelope_id,), + ).fetchone() + return json.loads(row["receipt_json"]) if row else None + + def save_grant_and_receipt( + self, + *, + envelope_id: str, + grant: TaskAdmissionGrant, + receipt: Mapping[str, Any], + created_at: int, + ) -> dict[str, Any]: + grant_json = canonical_json_bytes(grant.as_dict()).decode("utf-8") + grant_digest = hashlib.sha256(grant_json.encode("utf-8")).hexdigest() + receipt_json = canonical_json_bytes(dict(receipt)).decode("utf-8") + with self._lock, self._connection() as con: + con.execute("BEGIN IMMEDIATE") + existing = con.execute( + "SELECT receipt_json FROM task_admission_receipts WHERE envelope_id=?", + (envelope_id,), + ).fetchone() + if existing is not None: + con.execute("ROLLBACK") + return json.loads(existing["receipt_json"]) + stored = con.execute( + "SELECT grant_id,grant_digest FROM task_admission_grants WHERE envelope_id=?", + (envelope_id,), + ).fetchone() + if stored is None: + con.execute( + "INSERT INTO task_admission_grants(envelope_id,grant_id,grant_digest,grant_json,authorization_digest,created_at) VALUES(?,?,?,?,?,?)", + (envelope_id, grant.grant_id, grant_digest, grant_json, "", created_at), + ) + elif stored["grant_id"] != grant.grant_id or stored["grant_digest"] != grant_digest: + con.execute("ROLLBACK") + raise TaskAdmissionError("TASK_ADMISSION_GRANT_CONFLICT", 409) + con.execute( + """ + INSERT INTO task_admission_receipts( + envelope_id,task_id,receipt_json,created_at + ) VALUES(?,?,?,?) + """, + ( + envelope_id, + grant.task_id, + receipt_json, + created_at, + ), + ) + con.execute( + "UPDATE task_admission_envelopes SET status='ADMITTED' WHERE envelope_id=?", + (envelope_id,), + ) + con.execute("COMMIT") + return dict(receipt) + + def status(self, envelope_id: str) -> dict[str, Any]: + row = self.get_envelope(envelope_id) + if row is None: + raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_NOT_FOUND", 404) + receipt = self.receipt(envelope_id) + return { + "envelope_id": envelope_id, + "status": row["status"], + "task": receipt, + } + + +class TaskAdmissionBootstrapService: + def __init__( + self, + *, + store: TaskAdmissionStore, + authorization: OwnerAuthorizationBackend, + task_port: TaskAdmissionPort, + policy: TaskAdmissionPolicy | None = None, + clock=utc_seconds, + post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None, + ) -> None: + self.store = store + self.authorization = authorization + self.task_port = task_port + self.policy = policy or TaskAdmissionPolicy() + self.clock = clock + # This is a narrow server-side projection boundary. The official + # Agent Task port remains the admission authority; a projection may + # only establish local execution state after its exact Task readback. + self.post_admission = post_admission + self._admit_lock = threading.RLock() + + def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]: + required = { + "idempotency_key", + "task_id", + "repository", + "authority_base_oid", + "target_branch", + "generation", + "execution_digest", + "requested_capabilities", + "file_allowlist", + "risk", + } + optional = {"expires_in_seconds"} + if set(raw) - (required | optional) or not required.issubset(raw): + raise TaskAdmissionError("TASK_ADMISSION_REQUEST_FIELDS_INVALID") + idempotency_key = str(raw["idempotency_key"]) + task_id = str(raw["task_id"]) + repository = str(raw["repository"]) + authority_base_oid = str(raw["authority_base_oid"]) + target_branch = str(raw["target_branch"]) + execution_digest = str(raw["execution_digest"]) + if not IDEMPOTENCY_RE.fullmatch(idempotency_key): + raise TaskAdmissionError("TASK_ADMISSION_IDEMPOTENCY_KEY_INVALID") + if not TASK_RE.fullmatch(task_id): + raise TaskAdmissionError("TASK_ADMISSION_TASK_ID_INVALID") + if repository != self.policy.repository: + raise TaskAdmissionError("TASK_ADMISSION_REPOSITORY_REJECTED", 403) + if not OID_RE.fullmatch(authority_base_oid): + raise TaskAdmissionError("TASK_ADMISSION_BASE_OID_INVALID") + if ( + not BRANCH_RE.fullmatch(target_branch) + or not target_branch.startswith(self.policy.branch_prefix) + or "//" in target_branch + or ".." in target_branch + or "@{" in target_branch + or target_branch.endswith(("/", ".")) + or any( + not component + or component.startswith(".") + or component.endswith(".lock") + for component in target_branch.split("/") + ) + ): + raise TaskAdmissionError("TASK_ADMISSION_BRANCH_REJECTED", 403) + generation = raw["generation"] + if not isinstance(generation, int) or isinstance(generation, bool): + raise TaskAdmissionError("TASK_ADMISSION_GENERATION_INVALID") + if generation < 1: + raise TaskAdmissionError("TASK_ADMISSION_GENERATION_INVALID") + if not re.fullmatch(r"^[0-9a-f]{64}$", execution_digest): + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_DIGEST_INVALID") + requested_capabilities = _normalize_capabilities( + raw["requested_capabilities"], + policy=self.policy, + ) + file_allowlist = _normalize_files( + raw["file_allowlist"], + maximum=self.policy.max_file_count, + ) + risk = str(raw["risk"]) + if _risk_rank(risk) > _risk_rank(self.policy.max_risk): + raise TaskAdmissionError("TASK_ADMISSION_RISK_OUT_OF_POLICY", 403) + ttl = raw.get("expires_in_seconds", self.policy.max_envelope_ttl_seconds) + if ( + not isinstance(ttl, int) + or isinstance(ttl, bool) + or ttl < 60 + or ttl > self.policy.max_envelope_ttl_seconds + ): + raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_TTL_INVALID") + now = int(self.clock()) + policy_digest = self.policy.normalized_policy_digest() + request_value = _request_for_digest( + task_id=task_id, + repository=repository, + authority_base_oid=authority_base_oid, + target_branch=target_branch, + generation=generation, + execution_digest=execution_digest, + requested_capabilities=requested_capabilities, + file_allowlist=file_allowlist, + risk=risk, + trust_domain=self.policy.trust_domain, + policy_digest=policy_digest, + ) + request_digest = sha256_json(request_value) + nonce = secrets.token_urlsafe(24) + envelope_id = "task-admission-" + hashlib.sha256( + (idempotency_key + "\0" + request_digest + "\0" + nonce).encode("utf-8") + ).hexdigest()[:32] + envelope = TaskAdmissionEnvelope( + envelope_id=envelope_id, + idempotency_key=idempotency_key, + task_id=task_id, + repository=repository, + authority_base_oid=authority_base_oid, + target_branch=target_branch, + generation=generation, + execution_digest=execution_digest, + requested_capabilities=requested_capabilities, + file_allowlist=file_allowlist, + risk=risk, + trust_domain=self.policy.trust_domain, + policy_digest=policy_digest, + issued_at=now, + not_before=now, + expires_at=now + ttl, + nonce=nonce, + request_digest=request_digest, + ) + envelope_dict = envelope.as_dict() + persisted = self.store.save_envelope( + idempotency_key=idempotency_key, + envelope_id=envelope_id, + request_digest=request_digest, + envelope_json=canonical_json_bytes(envelope_dict).decode("utf-8"), + created_at=now, + ) + won = persisted["envelope_id"] == envelope_id + return { + "schema": SCHEMA_ENVELOPE, + "envelope": persisted, + "canonical_payload_base64": base64.b64encode( + canonical_json_bytes(persisted) + ).decode("ascii"), + "idempotent": not won, + } + + def _load_envelope(self, envelope_id: str) -> TaskAdmissionEnvelope: + row = self.store.get_envelope(envelope_id) + if row is None: + raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_NOT_FOUND", 404) + raw = json.loads(row["envelope_json"]) + return TaskAdmissionEnvelope( + **{ + **raw, + "requested_capabilities": tuple(raw["requested_capabilities"]), + "file_allowlist": tuple(raw["file_allowlist"]), + } + ) + + def _verify_epoch( + self, + *, + epoch_id: str, + envelope: TaskAdmissionEnvelope, + now: int, + ) -> OwnerDelegationEpoch: + epoch = self.authorization.verify_delegation_epoch(epoch_id) + if epoch.schema != SCHEMA_EPOCH: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403) + if epoch.epoch_id != epoch_id: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403) + if ( + epoch.trust_domain != envelope.trust_domain + or epoch.repository != envelope.repository + or epoch.policy_digest != envelope.policy_digest + ): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BINDING_MISMATCH", 403) + if epoch.revocation_epoch != 0: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403) + if not epoch.not_before <= now < epoch.expires_at: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403) + if not any( + envelope.target_branch.startswith(prefix) + for prefix in epoch.allowed_branch_prefixes + ): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BRANCH_REJECTED", 403) + caps = set(envelope.requested_capabilities) + if not caps.issubset(epoch.allowed_capabilities): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_CAPABILITY_WIDENING", 403) + if _risk_rank(envelope.risk) > _risk_rank(epoch.risk_ceiling): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_RISK_WIDENING", 403) + for path in envelope.file_allowlist: + if not any( + path == prefix.rstrip("/") + or path.startswith(prefix.rstrip("/") + "/") + for prefix in epoch.allowed_file_prefixes + ): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FILE_WIDENING", 403) + return epoch + + def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]: + required = {"envelope_id", "authorization_kind"} + allowed = required | { + "delegation_epoch_id", + "owner_key_id", + "signature_base64", + } + if set(raw) - allowed or not required.issubset(raw): + raise TaskAdmissionError("TASK_ADMISSION_AUTH_FIELDS_INVALID") + envelope_id = str(raw["envelope_id"]) + with self.store.admission_lock(envelope_id): + # Every replay revalidates policy, temporal bounds, revocation and + # proof after the crash-releasing lock is owned. + envelope = self._load_envelope(envelope_id) + now = int(self.clock()) + if envelope.policy_digest != self.policy.normalized_policy_digest(): + raise TaskAdmissionError("TASK_ADMISSION_POLICY_DIGEST_DRIFT", 403) + if now < envelope.not_before or now >= envelope.expires_at: + raise TaskAdmissionError("TASK_ADMISSION_ENVELOPE_EXPIRED", 403) + + kind = str(raw["authorization_kind"]) + parent_epoch: OwnerDelegationEpoch | None = None + if kind == "delegation_epoch": + epoch_id = str(raw.get("delegation_epoch_id") or "") + if not epoch_id: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REQUIRED") + parent_epoch = self._verify_epoch(epoch_id=epoch_id, envelope=envelope, now=now) + owner_identity = parent_epoch.owner_identity + parent_epoch_id: str | None = parent_epoch.epoch_id + elif kind == "owner_signature": + key_id = str(raw.get("owner_key_id") or "") + signature = str(raw.get("signature_base64") or "") + if not KEY_ID_RE.fullmatch(key_id) or not signature: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_REQUIRED") + owner_identity = self.authorization.verify_owner_signature( + key_id=key_id, + canonical_payload=canonical_json_bytes(envelope.as_dict()), + signature_b64=signature, + ) + if not isinstance(owner_identity, str) or not owner_identity: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) + parent_epoch_id = None + else: + raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_KIND_REJECTED", 403) + + authorization_digest = sha256_json(dict(raw)) + grant_id = "task-grant-" + hashlib.sha256( + (envelope.envelope_id + "\0" + kind + "\0" + owner_identity + "\0" + (parent_epoch_id or "")).encode("utf-8") + ).hexdigest()[:32] + grant = TaskAdmissionGrant( + grant_id=grant_id, envelope_id=envelope.envelope_id, task_id=envelope.task_id, + repository=envelope.repository, authority_base_oid=envelope.authority_base_oid, + target_branch=envelope.target_branch, generation=envelope.generation, + execution_digest=envelope.execution_digest, capabilities=envelope.requested_capabilities, + file_allowlist=envelope.file_allowlist, risk=envelope.risk, + trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest, + authorization_kind=kind, owner_identity=owner_identity, + parent_epoch_id=parent_epoch_id, issued_at=now, + expires_at=min( + envelope.expires_at, + now + self.policy.max_task_ttl_seconds, + parent_epoch.expires_at if parent_epoch is not None else envelope.expires_at, + ), + ) + persisted_grant, _ = self.store.claim_grant( + envelope_id=envelope_id, grant=grant, + authorization_digest=authorization_digest, created_at=now, + ) + if now >= persisted_grant.expires_at: + raise TaskAdmissionError("TASK_ADMISSION_GRANT_EXPIRED", 403) + if parent_epoch is not None and persisted_grant.expires_at > parent_epoch.expires_at: + raise TaskAdmissionError("TASK_ADMISSION_GRANT_PARENT_EXPIRY_CONFLICT", 409) + existing_receipt = self.store.receipt(envelope_id) + if existing_receipt is not None: + return {**existing_receipt, "idempotent": True} + + try: + result = self.task_port.admit(persisted_grant) + except TaskAdmissionError: + raise + except Exception as exc: + raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503) from exc + if not isinstance(result, Mapping): + raise TaskAdmissionError("TASK_ADMISSION_PORT_RESPONSE_INVALID", 502) + state = str(result.get("state") or result.get("status") or "") + if str(result.get("task_id") or "") != persisted_grant.task_id: + raise TaskAdmissionError("TASK_ADMISSION_PORT_TASK_MISMATCH", 502) + if state not in {"QUEUED", "CLAIMED", "RUNNING"}: + raise TaskAdmissionError("TASK_ADMISSION_PORT_STATE_INVALID", 502, details={"state": state}) + if self.post_admission is not None: + try: + projection = self.post_admission(persisted_grant, result) + except TaskAdmissionError: + raise + except Exception as exc: + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PROJECTION_FAILED", 503 + ) from exc + if projection is not None and not isinstance(projection, Mapping): + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PROJECTION_INVALID", 503 + ) + receipt = { + "schema": SCHEMA_RECEIPT, "envelope_id": persisted_grant.envelope_id, + "grant_id": persisted_grant.grant_id, "task_id": persisted_grant.task_id, + "task_state": state, "authorization_kind": persisted_grant.authorization_kind, + "owner_identity": persisted_grant.owner_identity, "parent_epoch_id": persisted_grant.parent_epoch_id, + "authority_base_oid": persisted_grant.authority_base_oid, + "target_branch": persisted_grant.target_branch, "generation": persisted_grant.generation, + "execution_digest": persisted_grant.execution_digest, + "grant_digest_sha256": sha256_json(persisted_grant.as_dict()), + "admitted_at": now, "idempotent": False, + } + return self.store.save_grant_and_receipt( + envelope_id=envelope_id, grant=persisted_grant, receipt=receipt, created_at=now, + ) + + def status(self, envelope_id: str) -> dict[str, Any]: + return self.store.status(envelope_id) diff --git a/active-runtime/app/aios_task_admission_bridge_contract.py b/active-runtime/app/aios_task_admission_bridge_contract.py new file mode 100644 index 0000000..8ba5e11 --- /dev/null +++ b/active-runtime/app/aios_task_admission_bridge_contract.py @@ -0,0 +1,87 @@ +"""Bridge contract surface for Task Admission Bootstrap. + +This module is intentionally transport-agnostic. The live Bridge may merge +these routes/operations into its existing authenticated/HTTPS surface, but the +authorization proof for the authorize operation is the verified delegation +epoch or Owner signature carried by the request itself. + +No handler here can issue TaskPrincipal assertions or WriterLeases. Those remain +strictly downstream of a real admitted/claimed Agent Task. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from aios_task_admission_bootstrap import TaskAdmissionBootstrapService + + +TASK_ADMISSION_ROUTES = { + "/v2/task-admission/envelope": "getTaskAdmissionEnvelope", + "/v2/task-admission/authorize": "submitTaskAdmissionAuthorization", + "/v2/task-admission/status": "getTaskAdmissionStatus", +} + +TASK_ADMISSION_OPERATION_REQUIRED_FIELDS = { + "getTaskAdmissionEnvelope": ( + "idempotency_key", + "task_id", + "repository", + "authority_base_oid", + "target_branch", + "generation", + "execution_digest", + "requested_capabilities", + "file_allowlist", + "risk", + ), + "submitTaskAdmissionAuthorization": ( + "envelope_id", + "authorization_kind", + ), + "getTaskAdmissionStatus": ("envelope_id",), +} + +TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS = { + "getTaskAdmissionEnvelope": ("expires_in_seconds",), + "submitTaskAdmissionAuthorization": ( + "delegation_epoch_id", + "owner_key_id", + "signature_base64", + ), + "getTaskAdmissionStatus": (), +} + + +class TaskAdmissionBridgeContract: + def __init__(self, service: TaskAdmissionBootstrapService) -> None: + self.service = service + + @staticmethod + def _validate_fields(operation: str, request: Mapping[str, Any]) -> None: + required = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation]) + allowed = required | set(TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]) + if not required.issubset(request): + raise ValueError("TASK_ADMISSION_BRIDGE_REQUIRED_FIELDS_MISSING") + if set(request) - allowed: + raise ValueError("TASK_ADMISSION_BRIDGE_UNKNOWN_FIELDS") + + def dispatch(self, operation: str, request: Mapping[str, Any]) -> dict[str, Any]: + if operation not in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS: + raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND") + self._validate_fields(operation, request) + if operation == "getTaskAdmissionEnvelope": + return self.service.create_envelope(request) + if operation == "submitTaskAdmissionAuthorization": + return self.service.authorize_and_admit(request) + if operation == "getTaskAdmissionStatus": + return self.service.status(str(request["envelope_id"])) + raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND") + + def handlers(self) -> dict[str, Any]: + return { + operation: ( + lambda request, operation=operation: self.dispatch(operation, request) + ) + for operation in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS + } diff --git a/active-runtime/app/aios_task_admission_live_adapters.py b/active-runtime/app/aios_task_admission_live_adapters.py new file mode 100644 index 0000000..db46f7a --- /dev/null +++ b/active-runtime/app/aios_task_admission_live_adapters.py @@ -0,0 +1,818 @@ +"""Narrow adapters for the already-authorized Owner and Agent Task seams. + +This file intentionally contains no credential lookup, HTTP client construction, +or fallback identity. The service host injects its authenticated durable-epoch +provider and its service-managed Agent Task transport; a request body can never +provide either authority. +""" +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import time +from typing import Any, Callable, Mapping, Protocol +import urllib.error +import urllib.request + +from aios_task_admission_bootstrap import ( + OwnerDelegationEpoch, + TaskAdmissionError, + TaskAdmissionGrant, + canonical_json_bytes, + sha256_json, +) + + +OWNER_NAMESPACE = "aios-owner-approval-v1" +OWNER_SIGNER_IDENTITY = "aios-task-grant-signer" + + +class OfficialTaskTransport(Protocol): + """Service-managed transport; it owns the Task API control credential.""" + + def request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> Mapping[str, Any]: + ... + + +class BoundOwnerAuthorizationBackend: + """Bind epoch lookup to trusted transport identity, never caller JSON.""" + + def __init__( + self, + *, + authenticated_caller: Mapping[str, Any], + verified_epoch_provider: Callable[..., OwnerDelegationEpoch], + signature_verifier: Any, + ) -> None: + if not isinstance(authenticated_caller, Mapping) or not authenticated_caller: + raise ValueError("AUTHENTICATED_CALLER_REQUIRED") + self._caller = dict(authenticated_caller) + self._epoch_provider = verified_epoch_provider + self._signature_verifier = signature_verifier + + def verify_delegation_epoch(self, epoch_id: str) -> OwnerDelegationEpoch: + try: + epoch = self._epoch_provider(epoch_id=epoch_id, caller=dict(self._caller)) + except TypeError as exc: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BACKEND_INVALID", 503) from exc + if not isinstance(epoch, OwnerDelegationEpoch): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_BACKEND_INVALID", 503) + return epoch + + def verify_owner_signature( + self, *, key_id: str, canonical_payload: bytes, signature_b64: str + ) -> str: + try: + armored = base64.b64decode(signature_b64, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) from exc + verify = getattr(self._signature_verifier, "verify", None) + if not callable(verify) or verify( + namespace=OWNER_NAMESPACE, + payload=canonical_payload, + signature=armored, + key_id=key_id, + ) is not True: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) + return OWNER_SIGNER_IDENTITY + + +class AgentTaskAdmissionPort: + """Official Task API adapter with exact identity reconciliation. + + It never exposes or returns the API control bearer. A timeout or 5xx is + reconciled by exact GET; if that cannot prove the state, the durable caller + receives UNKNOWN instead of a second blind POST. + """ + + def __init__( + self, + transport: OfficialTaskTransport, + *, + execution_plan_provider: Callable[[TaskAdmissionGrant], Mapping[str, Any]] | None = None, + ) -> None: + if not callable(getattr(transport, "request", None)): + raise ValueError("OFFICIAL_TASK_TRANSPORT_REQUIRED") + self._transport = transport + self._execution_plan_provider = execution_plan_provider + + def _execution_plan(self, grant: TaskAdmissionGrant) -> dict[str, Any]: + if self._execution_plan_provider is None: + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_REQUIRED", 503) + try: + raw = self._execution_plan_provider(grant) + except TaskAdmissionError: + raise + except Exception as exc: + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE", 503) from exc + if not isinstance(raw, Mapping): + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_INVALID", 403) + expected = { + "repository": grant.repository, + "target_branch": grant.target_branch, + "generation": grant.generation, + "execution_digest": grant.execution_digest, + } + if any(canonical_json_bytes(raw.get(key)) != canonical_json_bytes(value) for key, value in expected.items()): + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_CONFLICT", 409) + external_job_id = raw.get("external_job_id") + if not isinstance(external_job_id, str) or not external_job_id.strip(): + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_INVALID", 403) + # The trusted provider resolves the immutable execution payload by its + # signed digest. Preserve that payload: returning just coordinates + # would discard the executable plan needed by the real worker. + return dict(raw) + + @staticmethod + def _metadata(grant: TaskAdmissionGrant, execution: Mapping[str, Any]) -> dict[str, Any]: + return { + "task_admission_schema": "aios.task-admission-grant.v1", + "grant_id": grant.grant_id, + "grant_digest_sha256": sha256_json(grant.as_dict()), + "repository": grant.repository, + "authority_base_oid": grant.authority_base_oid, + "target_branch": grant.target_branch, + "generation": grant.generation, + "execution_digest": grant.execution_digest, + "file_allowlist": list(grant.file_allowlist), + "capabilities": list(grant.capabilities), + "risk": grant.risk, + "execution": dict(execution), + } + + @staticmethod + def _deadline(epoch: int) -> str: + # TaskStore.iso preserves the explicit UTC offset; exact reconciliation + # compares against that stored representation rather than a Z variant. + return datetime.fromtimestamp(epoch, timezone.utc).isoformat() + + def _body(self, grant: TaskAdmissionGrant, execution: Mapping[str, Any]) -> dict[str, Any]: + metadata = self._metadata(grant, execution) + immutable = canonical_json_bytes(metadata).decode("utf-8") + return { + "task_id": grant.task_id, + "goal": "Execute the immutable governed task admission grant.", + "instructions": "Use only the admitted immutable metadata; execution plan digest: " + grant.execution_digest, + "success_criteria": ["Task processing preserves the admitted immutable identity."], + "required_capabilities": sorted(grant.capabilities), + "constraints": { + "repository": grant.repository, + "authority_base_oid": grant.authority_base_oid, + "target_branch": grant.target_branch, + "generation": grant.generation, + "file_allowlist": list(grant.file_allowlist), + "immutable_metadata": immutable, + }, + "metadata": metadata, + "deadline": self._deadline(grant.expires_at), + "priority": 0, + "max_attempts": 1, + "idempotency_key": "task-admission/" + grant.grant_id, + } + + def _exact( + self, task: Mapping[str, Any], grant: TaskAdmissionGrant, execution: Mapping[str, Any] + ) -> dict[str, Any]: + expected = self._body(grant, execution) + immutable_fields = ( + "task_id", "goal", "instructions", "success_criteria", + "required_capabilities", "constraints", "metadata", "deadline", + "priority", "max_attempts", + ) + def stable(field: str, value: Any) -> Any: + """Exclude only re-admission provenance, never execution scope.""" + if field == "metadata" and isinstance(value, Mapping): + return { + key: item + for key, item in value.items() + if key not in {"grant_id", "grant_digest_sha256"} + } + if field == "constraints" and isinstance(value, Mapping): + return { + key: item + for key, item in value.items() + if key != "immutable_metadata" + } + return value + + if any( + canonical_json_bytes(stable(field, task.get(field))) + != canonical_json_bytes(stable(field, expected[field])) + for field in immutable_fields + ): + raise TaskAdmissionError("TASK_ADMISSION_TASK_IDENTITY_CONFLICT", 409) + state = str(task.get("state") or task.get("status") or "") + if state not in {"QUEUED", "CLAIMED", "RUNNING"}: + raise TaskAdmissionError("TASK_ADMISSION_PORT_STATE_INVALID", 502, details={"state": state}) + # Preserve the immutable external execution identity for the + # post-admission Bridge projection. The Task API remains the source + # of this value; the caller cannot provide or override it. + execution_job_id = execution.get("external_job_id") + if not isinstance(execution_job_id, str) or not execution_job_id.strip(): + raise TaskAdmissionError("TASK_ADMISSION_EXECUTION_PLAN_INVALID", 403) + return { + "task_id": grant.task_id, + "state": state, + "external_job_id": execution_job_id, + } + + def _read(self, grant: TaskAdmissionGrant, execution: Mapping[str, Any]) -> dict[str, Any] | None: + try: + task = self._transport.request("GET", "/v1/tasks/" + grant.task_id) + except Exception as exc: + # A transport is expected to expose a status for normal 404s. A + # generic failure is ambiguous and must not be treated as absent. + statuses = [ + getattr(exc, attribute, None) + for attribute in ("status", "http_status", "code") + if getattr(exc, attribute, None) is not None + ] + if statuses and all(status == 404 for status in statuses): + return None + raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503) from exc + if not isinstance(task, Mapping): + raise TaskAdmissionError("TASK_ADMISSION_PORT_RESPONSE_INVALID", 502) + return self._exact(task, grant, execution) + + def admit(self, grant: TaskAdmissionGrant) -> Mapping[str, Any]: + execution = self._execution_plan(grant) + existing = self._read(grant, execution) + if existing is not None: + return existing + try: + created = self._transport.request("POST", "/v1/tasks", self._body(grant, execution)) + except Exception as exc: + recovered = self._read(grant, execution) + if recovered is not None: + return recovered + raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503) from exc + if not isinstance(created, Mapping): + raise TaskAdmissionError("TASK_ADMISSION_PORT_RESPONSE_INVALID", 502) + # Authoritative GET prevents a POST response from becoming the source + # of task identity/state and handles a task claimed before readback. + recovered = self._read(grant, execution) + if recovered is None: + raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503) + return recovered + + +class TaskAdmissionHostError(RuntimeError): + """Stable fail-closed host-composition error.""" + + +class OfficialTaskHTTPError(RuntimeError): + def __init__(self, status: int, body: object | None = None) -> None: + super().__init__(f"AGENT_TASK_HTTP_{status}") + self.status = status + self.http_status = status + self.code = status + self.body = body + + +def _host_strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise TaskAdmissionHostError("DUPLICATE_JSON_FIELD") + value[key] = item + return value + + +def _host_read_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_host_strict_object, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise TaskAdmissionHostError("JSON_INPUT_INVALID") from exc + if not isinstance(value, dict): + raise TaskAdmissionHostError("JSON_OBJECT_REQUIRED") + return value + + +def _host_posix_metadata_enforced() -> bool: + """Return whether POSIX ownership/mode bits are security-significant here. + + The live AIOS authority/runtime is Linux, where mode/uid checks are a real + trust boundary. Windows development/test files expose synthesized POSIX + mode bits that do not represent ACL authority, so interpreting ``022`` + there would reject every secure temporary directory without improving + live security. + """ + return os.name == "posix" + + +def _host_require_regular(path: Path, *, root_owned: bool) -> None: + try: + info = path.lstat() + except OSError as exc: + raise TaskAdmissionHostError("SECURE_FILE_MISSING") from exc + if path.is_symlink() or not stat.S_ISREG(info.st_mode): + raise TaskAdmissionHostError("SECURE_FILE_INVALID") + if _host_posix_metadata_enforced(): + if stat.S_IMODE(info.st_mode) & 0o022: + raise TaskAdmissionHostError("SECURE_FILE_WRITABLE_BY_GROUP_OR_OTHER") + if root_owned and info.st_uid != 0: + raise TaskAdmissionHostError("SECURE_FILE_NOT_ROOT_OWNED") + + +def _host_require_directory(path: Path, *, root_owned: bool) -> None: + try: + info = path.lstat() + except OSError as exc: + raise TaskAdmissionHostError("SECURE_DIRECTORY_MISSING") from exc + if path.is_symlink() or not stat.S_ISDIR(info.st_mode): + raise TaskAdmissionHostError("SECURE_DIRECTORY_INVALID") + if _host_posix_metadata_enforced(): + if stat.S_IMODE(info.st_mode) & 0o022: + raise TaskAdmissionHostError("SECURE_DIRECTORY_WRITABLE_BY_GROUP_OR_OTHER") + if root_owned and info.st_uid != 0: + raise TaskAdmissionHostError("SECURE_DIRECTORY_NOT_ROOT_OWNED") + + +def load_owner_approval_verifier(module_path: Path) -> Any: + """Load the already-installed trusted public Owner verifier.""" + _host_require_regular(module_path, root_owned=True) + spec = importlib.util.spec_from_file_location( + "aios_live_owner_approval_verifier", + module_path, + ) + if spec is None or spec.loader is None: + raise TaskAdmissionHostError("OWNER_VERIFIER_IMPORT_INVALID") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls = getattr(module, "GrantRootOwnerApprovalVerifier", None) + if cls is None: + raise TaskAdmissionHostError("OWNER_VERIFIER_CLASS_MISSING") + verifier = cls() + if not callable(getattr(verifier, "verify", None)): + raise TaskAdmissionHostError("OWNER_VERIFIER_INVALID") + return verifier + + +@dataclass(frozen=True, slots=True) +class SignedEpochDirectoryProvider: + """Re-verify the exact signed Owner epoch on every admission use.""" + + root: Path + verifier: Any + revocation_file: Path + expected_caller: Mapping[str, Any] + require_root_owner: bool = True + clock: Any = time.time + + def _revoked_ids(self) -> set[str]: + _host_require_regular( + self.revocation_file, + root_owned=self.require_root_owner, + ) + value = _host_read_json_object(self.revocation_file) + if ( + set(value) != {"schema", "revoked_epoch_ids"} + or value.get("schema") != "aios.owner-delegation-revocations.v1" + or not isinstance(value.get("revoked_epoch_ids"), list) + or not all( + isinstance(item, str) and item + for item in value["revoked_epoch_ids"] + ) + ): + raise TaskAdmissionHostError( + "OWNER_EPOCH_REVOCATION_REGISTRY_INVALID" + ) + return set(value["revoked_epoch_ids"]) + + def __call__( + self, + *, + epoch_id: str, + caller: Mapping[str, Any], + ) -> OwnerDelegationEpoch: + if dict(caller) != dict(self.expected_caller): + raise TaskAdmissionError( + "TASK_ADMISSION_CALLER_BINDING_MISMATCH", + 403, + ) + _host_require_directory( + self.root, + root_owned=self.require_root_owner, + ) + canonical = self.root / "owner-delegation-epoch.canonical.json" + signature = self.root / "owner-delegation-epoch.sig" + key_id_file = self.root / "owner-delegation-epoch.key-id" + for path in (canonical, signature, key_id_file): + _host_require_regular( + path, + root_owned=self.require_root_owner, + ) + + payload = canonical.read_bytes() + value = _host_read_json_object(canonical) + if canonical_json_bytes(value) != payload: + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH", + 403, + ) + if value.get("schema") != "aios.owner-delegation-epoch.v1": + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_SCHEMA_INVALID", + 403, + ) + if value.get("epoch_id") != epoch_id: + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_ID_MISMATCH", + 403, + ) + if value.get("owner_identity") != OWNER_SIGNER_IDENTITY: + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_OWNER_IDENTITY_INVALID", + 403, + ) + if value.get("revocation_epoch") != 0: + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403) + if epoch_id in self._revoked_ids(): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403) + + key_id = key_id_file.read_text(encoding="utf-8").strip() + armored = signature.read_text(encoding="utf-8") + if self.verifier.verify( + namespace=OWNER_NAMESPACE, + payload=payload, + signature=armored, + key_id=key_id, + ) is not True: + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_SIGNATURE_INVALID", + 403, + ) + + now = int(self.clock()) + not_before = value.get("not_before") + expires_at = value.get("expires_at") + if ( + not isinstance(not_before, int) + or isinstance(not_before, bool) + or not isinstance(expires_at, int) + or isinstance(expires_at, bool) + or not not_before <= now < expires_at + ): + raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403) + + branches = value.get("allowed_branch_prefixes") + capabilities = value.get("allowed_capabilities") + files = value.get("allowed_file_prefixes") + if ( + not isinstance(branches, list) + or not all(isinstance(item, str) and item for item in branches) + or not isinstance(capabilities, list) + or not all( + isinstance(item, str) and item + for item in capabilities + ) + or not isinstance(files, list) + or not all(isinstance(item, str) and item for item in files) + ): + raise TaskAdmissionError( + "TASK_ADMISSION_EPOCH_SCOPE_INVALID", + 403, + ) + + return OwnerDelegationEpoch( + epoch_id=str(value["epoch_id"]), + owner_identity=str(value["owner_identity"]), + trust_domain=str(value["trust_domain"]), + repository=str(value["repository"]), + allowed_branch_prefixes=tuple(branches), + allowed_capabilities=frozenset(capabilities), + allowed_file_prefixes=tuple(files), + risk_ceiling=str(value["risk_ceiling"]), + issued_at=int(value["issued_at"]), + not_before=not_before, + expires_at=expires_at, + revocation_epoch=int(value["revocation_epoch"]), + policy_digest=str(value["policy_digest"]), + schema=str(value["schema"]), + ) + + +@dataclass(frozen=True, slots=True) +class ExecutionPlanDirectoryProvider: + """Resolve a trusted execution plan by its canonical SHA256 digest.""" + + root: Path + require_root_owner: bool = True + + def __call__(self, grant: TaskAdmissionGrant) -> Mapping[str, Any]: + digest = str(grant.execution_digest) + if ( + len(digest) != 64 + or any(ch not in "0123456789abcdef" for ch in digest) + ): + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PLAN_DIGEST_INVALID", + 403, + ) + _host_require_directory( + self.root, + root_owned=self.require_root_owner, + ) + path = self.root / (digest + ".json") + _host_require_regular( + path, + root_owned=self.require_root_owner, + ) + value = _host_read_json_object(path) + if hashlib.sha256(canonical_json_bytes(value)).hexdigest() != digest: + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PLAN_DIGEST_MISMATCH", + 403, + ) + expected = { + "task_id": grant.task_id, + "repository": grant.repository, + "target_branch": grant.target_branch, + "generation": grant.generation, + } + if any(value.get(key) != item for key, item in expected.items()): + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PLAN_CONFLICT", + 409, + ) + external_job_id = value.get("external_job_id") + if not isinstance(external_job_id, str) or not external_job_id.strip(): + raise TaskAdmissionError( + "TASK_ADMISSION_EXECUTION_PLAN_INVALID", + 403, + ) + return {**value, "execution_digest": digest} + + +@dataclass(frozen=True, slots=True) +class LocalAgentTaskHTTPTransport: + """Server-side localhost Task API transport; token never enters caller JSON.""" + + base_url: str + token_file: Path + timeout_seconds: float = 5.0 + require_root_owner: bool = True + + def _token(self) -> str: + _host_require_regular( + self.token_file, + root_owned=self.require_root_owner, + ) + token = self.token_file.read_text(encoding="utf-8").strip() + if len(token) < 16 or any(ch.isspace() for ch in token): + raise TaskAdmissionHostError("AGENT_TASK_CONTROL_TOKEN_INVALID") + return token + + def request( + self, + method: str, + path: str, + body: Mapping[str, Any] | None = None, + ) -> Mapping[str, Any]: + if method not in {"GET", "POST"}: + raise TaskAdmissionHostError("AGENT_TASK_HTTP_METHOD_INVALID") + if not path.startswith("/v1/tasks"): + raise TaskAdmissionHostError("AGENT_TASK_HTTP_PATH_INVALID") + raw = None if body is None else canonical_json_bytes(dict(body)) + request = urllib.request.Request( + self.base_url.rstrip("/") + path, + data=raw, + method=method, + headers={ + "Accept": "application/json", + "Authorization": "Bearer " + self._token(), + **( + {"Content-Type": "application/json"} + if raw is not None + else {} + ), + }, + ) + try: + with urllib.request.urlopen( + request, + timeout=self.timeout_seconds, + ) as response: + value = json.loads( + response.read().decode("utf-8"), + object_pairs_hook=_host_strict_object, + ) + except urllib.error.HTTPError as exc: + body_value: object | None = None + try: + body_value = json.loads(exc.read().decode("utf-8")) + except Exception: + body_value = None + raise OfficialTaskHTTPError( + exc.code, + body_value, + ) from exc + if not isinstance(value, Mapping): + raise TaskAdmissionHostError("AGENT_TASK_HTTP_RESPONSE_INVALID") + return dict(value) + + +def task_admission_policy_from_epoch( + epoch: OwnerDelegationEpoch, +) -> Any: + """Build a local ceiling exactly narrowed to the signed epoch.""" + from aios_task_admission_bootstrap import TaskAdmissionPolicy + + return TaskAdmissionPolicy( + repository=epoch.repository, + trust_domain=epoch.trust_domain, + branch_prefix="agent/", + allowed_capabilities=frozenset(epoch.allowed_capabilities), + max_file_count=min( + 64, + max(1, len(epoch.allowed_file_prefixes)), + ), + max_task_ttl_seconds=min( + 3600, + max(60, epoch.expires_at - epoch.not_before), + ), + max_envelope_ttl_seconds=900, + max_risk=epoch.risk_ceiling, + policy_digest=epoch.policy_digest, + ) + + +def _build_task_admission_handlers( + *, + caller_service: str, + epoch_root: Path, + epoch_id: str, + revocation_file: Path, + execution_plan_dir: Path, + state_db: Path, + task_api_url: str, + task_control_token_file: Path, + owner_verifier_module: Path, + post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None, +) -> Mapping[str, Any]: + """Compose handlers from already-trusted server-side coordinates.""" + from aios_task_admission_bootstrap import ( + TaskAdmissionBootstrapService, + TaskAdmissionStore, + ) + from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract + + verifier = load_owner_approval_verifier(owner_verifier_module) + caller = { + "service": caller_service, + "trust_domain": "aios.owner", + } + provider = SignedEpochDirectoryProvider( + root=epoch_root, + verifier=verifier, + revocation_file=revocation_file, + expected_caller=caller, + require_root_owner=True, + ) + epoch = provider(epoch_id=epoch_id, caller=caller) + authorization = BoundOwnerAuthorizationBackend( + authenticated_caller=caller, + verified_epoch_provider=provider, + signature_verifier=verifier, + ) + task_port = AgentTaskAdmissionPort( + LocalAgentTaskHTTPTransport( + base_url=task_api_url, + token_file=task_control_token_file, + require_root_owner=True, + ), + execution_plan_provider=ExecutionPlanDirectoryProvider( + root=execution_plan_dir, + require_root_owner=True, + ), + ) + service = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(state_db), + authorization=authorization, + task_port=task_port, + policy=task_admission_policy_from_epoch(epoch), + post_admission=post_admission, + ) + return TaskAdmissionBridgeContract(service).handlers() + + +def build_task_admission_handlers_from_activation_file( + activation_file: Path, + *, + caller_service: str = "aios-chatgpt-bridge-v2-staging.service", + post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None, +) -> Mapping[str, Any]: + """Load one root-owned, fail-closed staging activation descriptor.""" + _host_require_regular(activation_file, root_owned=True) + value = _host_read_json_object(activation_file) + required = { + "schema", + "epoch_root", + "epoch_id", + "revocation_file", + "execution_plan_dir", + "state_db", + "task_api_url", + "task_control_token_file", + "owner_verifier_module", + } + if set(value) != required or value.get("schema") != "aios.task-admission-host-activation.v1": + raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_FILE_INVALID") + + path_fields = ( + "epoch_root", + "revocation_file", + "execution_plan_dir", + "state_db", + "task_control_token_file", + "owner_verifier_module", + ) + paths: dict[str, Path] = {} + for key in path_fields: + raw = value.get(key) + if not isinstance(raw, str) or not raw.startswith("/"): + raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_PATH_INVALID") + path = Path(raw) + if ".." in path.parts: + raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_PATH_INVALID") + paths[key] = path + + epoch_id = value.get("epoch_id") + task_api_url = value.get("task_api_url") + if not isinstance(epoch_id, str) or not epoch_id: + raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_EPOCH_INVALID") + if task_api_url != "http://127.0.0.1:18940": + raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_TASK_API_INVALID") + + return _build_task_admission_handlers( + caller_service=caller_service, + epoch_root=paths["epoch_root"], + epoch_id=epoch_id, + revocation_file=paths["revocation_file"], + execution_plan_dir=paths["execution_plan_dir"], + state_db=paths["state_db"], + task_api_url=task_api_url, + task_control_token_file=paths["task_control_token_file"], + owner_verifier_module=paths["owner_verifier_module"], + post_admission=post_admission, + ) + + +def build_task_admission_handlers_from_environment( + *, + caller_service: str = "aios-chatgpt-bridge-v2-staging.service", + post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None, +) -> Mapping[str, Any]: + """Compose all three handlers from server-side trusted environment only.""" + required = ( + "AIOS_TASK_ADMISSION_EPOCH_ROOT", + "AIOS_TASK_ADMISSION_EPOCH_ID", + "AIOS_TASK_ADMISSION_REVOCATION_FILE", + "AIOS_TASK_ADMISSION_EXECUTION_PLAN_DIR", + ) + missing = [ + key + for key in required + if not os.environ.get(key, "").strip() + ] + if missing: + raise TaskAdmissionHostError( + "TASK_ADMISSION_HOST_ENV_MISSING:" + + ",".join(sorted(missing)) + ) + return _build_task_admission_handlers( + caller_service=caller_service, + epoch_root=Path(os.environ["AIOS_TASK_ADMISSION_EPOCH_ROOT"]), + epoch_id=os.environ["AIOS_TASK_ADMISSION_EPOCH_ID"], + revocation_file=Path(os.environ["AIOS_TASK_ADMISSION_REVOCATION_FILE"]), + execution_plan_dir=Path(os.environ["AIOS_TASK_ADMISSION_EXECUTION_PLAN_DIR"]), + state_db=Path(os.environ.get( + "AIOS_TASK_ADMISSION_STATE_DB", + "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3", + )), + task_api_url=os.environ.get( + "AIOS_AGENT_TASK_API_URL", + "http://127.0.0.1:18940", + ), + task_control_token_file=Path(os.environ.get( + "AIOS_AGENT_TASK_CONTROL_TOKEN_FILE", + "/etc/aios-gitops/agent-task-control.token", + )), + owner_verifier_module=Path(os.environ.get( + "AIOS_OWNER_APPROVAL_VERIFIER_MODULE", + "/opt/aios/agent-task-api/" + "914eb324f657a695e2635f80cf53202eba636eb350630b12883e2741ce67e535" + "-p0-verification/runtime/app/aios_owner_approval_verifier.py", + )), + post_admission=post_admission, + ) diff --git a/active-runtime/app/aios_task_principal_admission_p0_generic.py b/active-runtime/app/aios_task_principal_admission_p0_generic.py new file mode 100644 index 0000000..186cb06 --- /dev/null +++ b/active-runtime/app/aios_task_principal_admission_p0_generic.py @@ -0,0 +1,642 @@ +"""Generic append-only TaskPrincipal scope transition for P0 admission. + +The live issuer currently imports an R10 overlay whose public supersession +function is intentionally bound to one historical recovery chain. This +module is a controlled staging import override for the next P0 task lines. It +loads that overlay by its immutable path, preserves its validation and issuer +implementation, and adds one small generic transition table. + +The transition is deliberately narrower than a new trust bootstrap: + +* the existing issuer key, public key, purpose, and Bridge identity remain + unchanged; +* the new scope must preserve the existing operation capability and audience + set, while binding to a distinct claimed Agent Task; +* the signed grant is verified by the registered Owner verifier; +* only INSERTs are performed, and SQLite UPDATE/DELETE triggers protect the + new table as well as the existing principal tables; +* TaskPrincipal issuance still authenticates the exact live TaskLease later. + +No serving constructor performs the migration. Deployment calls +``offline_migrate_task_principal_schema`` once through this module before the +issuer is restarted. +""" + +from __future__ import annotations + +from contextlib import closing +import importlib.util +from pathlib import Path +import hashlib +import json +import sqlite3 +from typing import Any, Callable, Mapping + + +_CURRENT_OVERLAY_PATH = Path( + "/opt/aios/stage9-r10-formal-lineage-20260902/app/" + "aios_task_principal_admission.py" +) +_SPEC = importlib.util.spec_from_file_location( + "aios_task_principal_admission_r10_overlay_for_p0_generic", + _CURRENT_OVERLAY_PATH, +) +if _SPEC is None or _SPEC.loader is None: + raise ImportError("TASK_PRINCIPAL_R10_OVERLAY_UNAVAILABLE") +_current = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_current) + +# Keep the public legacy API and the R10 implementation available to the +# issuer/Bridge. The generic functions below replace only the two explicit +# semantic-port entry points and the active-chain/readiness globals. +for _name, _value in _current.__dict__.items(): + if not _name.startswith("__"): + globals()[_name] = _value + +_legacy = _current._legacy +_BASE_R10_ACTIVE_SCOPE_CHAIN = _current._active_scope_chain + + +GENERIC_SCOPE_TABLE = "task_principal_p0_task_scope_supersession_grants" +GENERIC_SCOPE_SCHEMA = "aios.task-principal-task-scope-supersession.v1" +GENERIC_SIGNER_NAMESPACE = "aios-owner-approval-v1" +GENERIC_SIGNER_IDENTITY = "aios-task-grant-signer" +GENERIC_SUPERSESSION_REASON = "P0_TASK_ADMISSION_SCOPE_SUPERSESSION" +MAX_GENERIC_TRANSITIONS = 8 +STABLE_OPERATION_CAPABILITIES = ( + "git.read", + "git.test", + "governed.write.branch", + "governed.write.file", +) + + +def _table_statement() -> str: + return """CREATE TABLE IF NOT EXISTS task_principal_p0_task_scope_supersession_grants( + supersession_id TEXT PRIMARY KEY, grant_sha256 TEXT NOT NULL UNIQUE, + previous_task_id TEXT NOT NULL, previous_external_job_id TEXT NOT NULL, + previous_generation INTEGER NOT NULL, previous_bridge_id TEXT NOT NULL, + previous_trust_manifest_sha256 TEXT NOT NULL, + previous_scope_manifest_sha256 TEXT NOT NULL, previous_scope_id TEXT NOT NULL, + task_id TEXT NOT NULL, external_job_id TEXT NOT NULL, repository TEXT NOT NULL, + target_branch TEXT NOT NULL, generation INTEGER NOT NULL, + execution_digest TEXT NOT NULL, capabilities_json TEXT NOT NULL, + audiences_json TEXT NOT NULL, new_bridge_id TEXT NOT NULL, + new_trust_manifest_sha256 TEXT NOT NULL UNIQUE, + new_scope_manifest_sha256 TEXT NOT NULL, new_scope_id TEXT NOT NULL UNIQUE, + signer_namespace TEXT NOT NULL, signer_identity TEXT NOT NULL, + owner_key_id TEXT NOT NULL, issued_at INTEGER NOT NULL, + not_before INTEGER NOT NULL, expires_at INTEGER NOT NULL, + one_time_use INTEGER NOT NULL, supersession_reason TEXT NOT NULL, + signature TEXT NOT NULL, applied_at INTEGER NOT NULL +)""" + + +def _trigger_name(event: str) -> str: + return "aios_append_only_%s_%s" % (GENERIC_SCOPE_TABLE, event.lower()) + + +def _trigger_statement(event: str, *, if_not_exists: bool = False) -> str: + qualifier = " IF NOT EXISTS" if if_not_exists else "" + return ( + "CREATE TRIGGER%s %s BEFORE %s ON %s " + "BEGIN SELECT RAISE(ABORT,'TASK_PRINCIPAL_APPEND_ONLY'); END" + ) % (qualifier, _trigger_name(event), event.upper(), GENERIC_SCOPE_TABLE) + + +def _normalized_schema_sql(value: Any) -> str: + if not isinstance(value, str): + return "" + return ( + " ".join(value.strip().split()).lower() + .replace("create table if not exists ", "create table ") + .replace("create trigger if not exists ", "create trigger ") + ) + + +def _validate_generic_schema(con: sqlite3.Connection) -> None: + table = con.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + (GENERIC_SCOPE_TABLE,), + ).fetchone() + if table is None or _normalized_schema_sql(table[0]) != _normalized_schema_sql(_table_statement()): + _legacy._fail("P0_GENERIC_SCOPE_SCHEMA_INVALID") + + expected = { + _trigger_name("UPDATE"): _trigger_statement("UPDATE"), + _trigger_name("DELETE"): _trigger_statement("DELETE"), + } + observed = { + str(row[0]): row[1] + for row in con.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name=?", + (GENERIC_SCOPE_TABLE,), + ) + } + if set(observed) != set(expected) or any( + _normalized_schema_sql(observed[name]) != _normalized_schema_sql(sql) + for name, sql in expected.items() + ): + _legacy._fail("P0_GENERIC_SCOPE_APPEND_ONLY_GUARD_INVALID") + + +def offline_migrate_task_principal_schema(db_path: str | Path) -> None: + """Run the existing offline migration and add the generic table safely.""" + _legacy.offline_migrate_task_principal_schema(db_path) + with closing(_legacy._db(db_path)) as con: + con.execute("BEGIN IMMEDIATE") + existing = con.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + (GENERIC_SCOPE_TABLE,), + ).fetchone() + if existing is not None and _normalized_schema_sql(existing[0]) != _normalized_schema_sql(_table_statement()): + _legacy._fail("P0_GENERIC_SCOPE_SCHEMA_INVALID") + observed = { + str(row[0]): row[1] + for row in con.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name=?", + (GENERIC_SCOPE_TABLE,), + ) + } + expected = { + _trigger_name("UPDATE"): _trigger_statement("UPDATE"), + _trigger_name("DELETE"): _trigger_statement("DELETE"), + } + if set(observed) - set(expected): + _legacy._fail("P0_GENERIC_SCOPE_TRIGGER_INVENTORY_INVALID") + con.execute(_table_statement()) + for event, statement in (("UPDATE", expected[_trigger_name("UPDATE")]), ("DELETE", expected[_trigger_name("DELETE")])): + name = _trigger_name(event) + if name not in observed: + con.execute(_trigger_statement(event, if_not_exists=True)) + else: + if _normalized_schema_sql(observed[name]) != _normalized_schema_sql(statement): + _legacy._fail("P0_GENERIC_SCOPE_APPEND_ONLY_GUARD_INVALID") + _validate_generic_schema(con) + con.commit() + + +def _text(value: Any, field: str, *, max_len: int = 512) -> str: + return _legacy._text(value, field, max_len=max_len) + + +def _hex(value: Any, field: str) -> str: + return _legacy._hex(value, field) + + +def _integer(value: Any, field: str, low: int = 0) -> int: + return _legacy._integer(value, field, low, 2**63 - 1) + + +def _scope_from_row(row: sqlite3.Row) -> dict[str, Any]: + return _legacy._scope_manifest({ + "scope_id": row["scope_id"], + "task_id": row["task_id"], + "external_job_id": row["external_job_id"], + "repository": row["repository"], + "target_branch": row["target_branch"], + "generation": int(row["generation"]), + "execution_digest": row["execution_digest"], + "capabilities": _legacy._load_json_text(row["capabilities_json"], "P0_GENERIC_SCOPE_CAPABILITIES_INVALID"), + "audiences": _legacy._load_json_text(row["audiences_json"], "P0_GENERIC_SCOPE_AUDIENCES_INVALID"), + }) + + +def _register_trust(con: sqlite3.Connection, scope: sqlite3.Row, digest: str) -> dict[str, Any]: + event = con.execute( + "SELECT * FROM task_principal_trust_events " + "WHERE event_type='REGISTER' AND scope_id=? AND trust_manifest_sha256=?", + (scope["scope_id"], digest), + ).fetchone() + if event is None: + _legacy._fail("P0_GENERIC_SCOPE_TRUST_EVENT_MISSING") + trust = { + "schema": _legacy.ASSERTION_SCHEMA, + "issuer_id": event["issuer_id"], + "key_id": event["key_id"], + "public_key": event["public_key"], + "purpose": "aios.task-principal.p0", + "bridge_id": _bridge_for_scope(con, scope["scope_id"], digest), + "scope_manifest_sha256": _legacy.sha256_canonical(_scope_from_row(scope)), + } + if _legacy.sha256_canonical(trust) != digest: + _legacy._fail("P0_GENERIC_SCOPE_TRUST_EVENT_INVALID") + return trust + + +def _bridge_for_scope(con: sqlite3.Connection, scope_id: str, digest: str) -> str: + """Resolve the bridge identity from its immutable transition record.""" + row = con.execute( + "SELECT new_bridge_id FROM " + GENERIC_SCOPE_TABLE + + " WHERE new_scope_id=? AND new_trust_manifest_sha256=?", + (scope_id, digest), + ).fetchone() + if row is not None: + return str(row[0]) + rebind = con.execute( + "SELECT new_bridge_id FROM task_principal_artifact_rebind_grants " + "WHERE new_scope_id=? AND new_trust_manifest_sha256=?", + (scope_id, digest), + ).fetchone() + if rebind is not None: + return str(rebind[0]) + rollover = con.execute( + "SELECT new_bridge_id FROM task_principal_execution_scope_rollover_grants " + "WHERE new_scope_id=? AND new_trust_manifest_sha256=?", + (scope_id, digest), + ).fetchone() + if rollover is not None: + return str(rollover[0]) + bootstrap = con.execute( + "SELECT bridge_id FROM task_principal_owner_bootstrap_grants " + "WHERE trust_manifest_sha256=?", + (digest,), + ).fetchone() + if bootstrap is not None: + return str(bootstrap[0]) + # The base/R10 chain has already authenticated this scope, but it does not + # expose a bridge column on scope rows. A matching REGISTER event without + # a transition record is only acceptable for the initial bootstrap row; + # anything else is fail-closed. + _legacy._fail("P0_GENERIC_SCOPE_BRIDGE_UNRESOLVED") + return "" + + +def _grant_payload(value: Mapping[str, Any]) -> dict[str, Any]: + x = _legacy._json_object(value, "P0_GENERIC_SCOPE_GRANT_INVALID") + fields = { + "schema", "supersession_id", "previous_task_id", "previous_external_job_id", + "previous_generation", "previous_bridge_id", "previous_trust_manifest_sha256", + "previous_scope_manifest_sha256", "previous_scope_id", "task_id", + "external_job_id", "repository", "target_branch", "generation", + "execution_digest", "capabilities", "audiences", "new_bridge_id", + "new_trust_manifest_sha256", "new_scope_manifest_sha256", "new_scope_id", + "signer_namespace", "signer_identity", "owner_key_id", "issued_at", + "not_before", "expires_at", "one_time_use", "supersession_reason", "signature", + } + if set(x) != fields or x.get("schema") != GENERIC_SCOPE_SCHEMA or x.get("one_time_use") is not True: + _legacy._fail("P0_GENERIC_SCOPE_GRANT_SCHEMA_INVALID") + for key in ( + "supersession_id", "previous_task_id", "previous_external_job_id", "previous_bridge_id", + "previous_scope_id", "task_id", "external_job_id", "repository", "target_branch", + "new_bridge_id", "new_scope_id", "signer_namespace", "signer_identity", "owner_key_id", + "supersession_reason", + ): + _text(x.get(key), key) + for key in ( + "previous_trust_manifest_sha256", "previous_scope_manifest_sha256", + "execution_digest", "new_trust_manifest_sha256", "new_scope_manifest_sha256", + ): + _hex(x.get(key), key) + for key in ("previous_generation", "generation", "issued_at", "not_before", "expires_at"): + _integer(x.get(key), key) + if not 1 <= int(x["expires_at"]) - int(x["issued_at"]) <= 3600: + _legacy._fail("P0_GENERIC_SCOPE_GRANT_TIME_INVALID") + if x["issued_at"] != x["not_before"]: + _legacy._fail("P0_GENERIC_SCOPE_GRANT_TIME_INVALID") + if not str(x["supersession_id"]).startswith("p0-task-scope-supersession-"): + _legacy._fail("P0_GENERIC_SCOPE_ID_INVALID") + if x["signer_namespace"] != GENERIC_SIGNER_NAMESPACE or x["signer_identity"] != GENERIC_SIGNER_IDENTITY: + _legacy._fail("P0_GENERIC_SCOPE_SIGNER_INVALID") + if x["supersession_reason"] != GENERIC_SUPERSESSION_REASON: + _legacy._fail("P0_GENERIC_SCOPE_REASON_INVALID") + if not isinstance(x["capabilities"], list) or not x["capabilities"]: + _legacy._fail("P0_GENERIC_SCOPE_CAPABILITIES_INVALID") + if not isinstance(x["audiences"], list) or not x["audiences"]: + _legacy._fail("P0_GENERIC_SCOPE_AUDIENCES_INVALID") + if not isinstance(x["signature"], str) or not x["signature"] or len(x["signature"]) > 32768: + _legacy._fail("P0_GENERIC_SCOPE_SIGNATURE_INVALID") + return x + + +def _verify_grant_signature(grant: Mapping[str, Any], owner_verifier: Any) -> None: + verifier = getattr(owner_verifier, "verify", owner_verifier) + if not callable(verifier): + _legacy._fail("P0_GENERIC_SCOPE_VERIFIER_REQUIRED") + try: + verified = verifier( + namespace=GENERIC_SIGNER_NAMESPACE, + payload=_legacy.canonical_json({key: value for key, value in grant.items() if key != "signature"}), + signature=grant["signature"], + key_id=grant["owner_key_id"], + ) + except Exception as exc: + raise _legacy.TaskPrincipalError("TASK_PRINCIPAL_P0_GENERIC_SCOPE_SIGNATURE_INVALID") from exc + if verified is not True: + _legacy._fail("P0_GENERIC_SCOPE_SIGNATURE_INVALID") + + +def _row_payload(record: sqlite3.Row) -> dict[str, Any]: + return { + "schema": GENERIC_SCOPE_SCHEMA, + "supersession_id": record["supersession_id"], + "previous_task_id": record["previous_task_id"], + "previous_external_job_id": record["previous_external_job_id"], + "previous_generation": int(record["previous_generation"]), + "previous_bridge_id": record["previous_bridge_id"], + "previous_trust_manifest_sha256": record["previous_trust_manifest_sha256"], + "previous_scope_manifest_sha256": record["previous_scope_manifest_sha256"], + "previous_scope_id": record["previous_scope_id"], + "task_id": record["task_id"], + "external_job_id": record["external_job_id"], + "repository": record["repository"], + "target_branch": record["target_branch"], + "generation": int(record["generation"]), + "execution_digest": record["execution_digest"], + "capabilities": _legacy._load_json_text(record["capabilities_json"], "P0_GENERIC_SCOPE_CAPABILITIES_INVALID"), + "audiences": _legacy._load_json_text(record["audiences_json"], "P0_GENERIC_SCOPE_AUDIENCES_INVALID"), + "new_bridge_id": record["new_bridge_id"], + "new_trust_manifest_sha256": record["new_trust_manifest_sha256"], + "new_scope_manifest_sha256": record["new_scope_manifest_sha256"], + "new_scope_id": record["new_scope_id"], + "signer_namespace": record["signer_namespace"], + "signer_identity": record["signer_identity"], + "owner_key_id": record["owner_key_id"], + "issued_at": int(record["issued_at"]), + "not_before": int(record["not_before"]), + "expires_at": int(record["expires_at"]), + "one_time_use": bool(record["one_time_use"]), + "supersession_reason": record["supersession_reason"], + "signature": record["signature"], + } + + +def _validate_generic_record( + con: sqlite3.Connection, + record: sqlite3.Row, + previous_digest: str, + previous_scope: sqlite3.Row, + previous_bridge: str, + previous_applied_at: int | None, +) -> tuple[str, sqlite3.Row, str, int]: + if int(record["one_time_use"]) != 1: + _legacy._fail("P0_GENERIC_SCOPE_CHAIN_INVALID") + grant = _grant_payload(_row_payload(record)) + grant_sha = _legacy.sha256_canonical(grant) + if record["grant_sha256"] != grant_sha: + _legacy._fail("P0_GENERIC_SCOPE_GRANT_DIGEST_INVALID") + if ( + grant["previous_trust_manifest_sha256"] != previous_digest + or grant["previous_scope_id"] != previous_scope["scope_id"] + or grant["previous_bridge_id"] != previous_bridge + or grant["previous_task_id"] != previous_scope["task_id"] + or grant["previous_external_job_id"] != previous_scope["external_job_id"] + or int(grant["previous_generation"]) != int(previous_scope["generation"]) + or grant["previous_scope_manifest_sha256"] != _legacy.sha256_canonical(_legacy._scope_material(previous_scope)) + or record["applied_at"] < grant["not_before"] + or record["applied_at"] >= grant["expires_at"] + or (previous_applied_at is not None and record["applied_at"] < previous_applied_at) + ): + _legacy._fail("P0_GENERIC_SCOPE_CHAIN_INVALID") + + scope = con.execute( + "SELECT * FROM task_principal_admission_scopes WHERE scope_id=?", + (grant["new_scope_id"],), + ).fetchone() + if scope is None: + _legacy._fail("P0_GENERIC_SCOPE_CHAIN_INVALID") + material = _scope_from_row(scope) + new_digest = str(grant["new_trust_manifest_sha256"]) + new_trust = _register_trust(con, scope, new_digest) + if ( + grant["new_scope_manifest_sha256"] != _legacy.sha256_canonical(material) + or grant["task_id"] != material["task_id"] + or grant["external_job_id"] != material["external_job_id"] + or grant["repository"] != material["repository"] + or grant["target_branch"] != material["target_branch"] + or int(grant["generation"]) != int(material["generation"]) + or grant["execution_digest"] != material["execution_digest"] + or grant["capabilities"] != material["capabilities"] + or grant["audiences"] != material["audiences"] + or grant["new_bridge_id"] != new_trust["bridge_id"] + or new_trust["bridge_id"] != previous_bridge + ): + _legacy._fail("P0_GENERIC_SCOPE_CHAIN_INVALID") + if set(material["capabilities"]) != set(STABLE_OPERATION_CAPABILITIES): + _legacy._fail("P0_GENERIC_SCOPE_CAPABILITY_EXPANSION") + if _legacy._scope_suffixes(material, new_trust["bridge_id"]) != _legacy._scope_suffixes( + _legacy._scope_material(previous_scope), previous_bridge + ): + _legacy._fail("P0_GENERIC_SCOPE_AUDIENCE_EXPANSION") + if con.execute( + "SELECT 1 FROM task_principal_revocations WHERE kind='SCOPE' AND target=?", + (previous_scope["scope_id"],), + ).fetchone() is None: + _legacy._fail("P0_GENERIC_SCOPE_PREVIOUS_NOT_REVOKED") + return new_digest, scope, new_trust["bridge_id"], int(record["applied_at"]) + + +def _active_scope_chain(con: sqlite3.Connection) -> tuple[str, sqlite3.Row]: + _validate_generic_schema(con) + current_digest, current_scope = _BASE_R10_ACTIVE_SCOPE_CHAIN(con) + first_event = con.execute( + "SELECT bridge_id FROM task_principal_owner_bootstrap_grants WHERE trust_manifest_sha256=?", + (current_digest,), + ).fetchone() + current_bridge = str(first_event[0]) if first_event is not None else _bridge_for_scope(con, current_scope["scope_id"], current_digest) + records = con.execute( + "SELECT * FROM " + GENERIC_SCOPE_TABLE + " ORDER BY applied_at,supersession_id" + ).fetchall() + if len(records) > MAX_GENERIC_TRANSITIONS: + _legacy._fail("P0_GENERIC_SCOPE_TRANSITION_LIMIT_EXCEEDED") + seen_digests = {current_digest} + seen_scopes = {str(current_scope["scope_id"])} + previous_applied_at: int | None = None + for record in records: + new_digest, new_scope, current_bridge, previous_applied_at = _validate_generic_record( + con, record, current_digest, current_scope, current_bridge, previous_applied_at + ) + if new_digest in seen_digests or str(new_scope["scope_id"]) in seen_scopes: + _legacy._fail("P0_GENERIC_SCOPE_CHAIN_REUSE") + seen_digests.add(new_digest) + seen_scopes.add(str(new_scope["scope_id"])) + current_digest, current_scope = new_digest, new_scope + return current_digest, current_scope + + +def _task_is_currently_claimed(task: sqlite3.Row, now: int) -> None: + if ( + task["status"] not in ("CLAIMED", "RUNNING") + or int(task["attempt"]) <= 0 + or not isinstance(task["claimed_by"], str) + or not task["claimed_by"] + or not isinstance(task["lease_token_hash"], str) + or not task["lease_token_hash"] + or task["result_json"] not in (None, "") + ): + _legacy._fail("P0_GENERIC_SCOPE_TASK_NOT_CLAIMED") + lease_expires = _legacy._lease_expiry(task["lease_expires_at"]) + if lease_expires <= now: + _legacy._fail("P0_GENERIC_SCOPE_TASK_LEASE_EXPIRED") + + +def supersede_p0_scope( + db_path: str | Path, + old_trust_manifest: Mapping[str, Any], + old_scope_manifest: Mapping[str, Any], + new_trust_manifest: Mapping[str, Any], + new_scope_manifest: Mapping[str, Any], + *, + owner_grant: Mapping[str, Any], + owner_verifier: Any, + clock: Callable[[], Any] | None = None, +) -> dict[str, Any]: + """Append one generic scope transition for a real claimed Task.""" + old_trust = _legacy._manifest(old_trust_manifest) + old_scope = _legacy._scope_manifest(old_scope_manifest) + new_trust = _legacy._manifest(new_trust_manifest) + new_scope = _legacy._scope_manifest(new_scope_manifest) + grant = _grant_payload(owner_grant) + now = _legacy._now_epoch(clock) + if now < int(grant["not_before"]) or now >= int(grant["expires_at"]): + _legacy._fail("P0_GENERIC_SCOPE_GRANT_EXPIRED") + _verify_grant_signature(grant, owner_verifier) + old_digest = _legacy.sha256_canonical(old_trust) + new_digest = _legacy.sha256_canonical(new_trust) + old_scope_digest = _legacy.sha256_canonical(old_scope) + new_scope_digest = _legacy.sha256_canonical(new_scope) + if ( + old_trust["issuer_id"] != new_trust["issuer_id"] + or old_trust["key_id"] != new_trust["key_id"] + or old_trust["public_key"] != new_trust["public_key"] + or old_trust["purpose"] != new_trust["purpose"] + or old_trust["bridge_id"] != new_trust["bridge_id"] + or old_scope["scope_id"] == new_scope["scope_id"] + or old_trust["scope_manifest_sha256"] != old_scope_digest + or new_trust["scope_manifest_sha256"] != new_scope_digest + or set(new_scope["capabilities"]) != set(STABLE_OPERATION_CAPABILITIES) + or _legacy._scope_suffixes(new_scope, new_trust["bridge_id"]) != _legacy._scope_suffixes(old_scope, old_trust["bridge_id"]) + ): + _legacy._fail("P0_GENERIC_SCOPE_IDENTITY_INVALID") + if any( + grant.get(key) != value + for key, value in { + "previous_task_id": old_scope["task_id"], + "previous_external_job_id": old_scope["external_job_id"], + "previous_generation": int(old_scope["generation"]), + "previous_bridge_id": old_trust["bridge_id"], + "previous_trust_manifest_sha256": old_digest, + "previous_scope_manifest_sha256": old_scope_digest, + "previous_scope_id": old_scope["scope_id"], + "task_id": new_scope["task_id"], + "external_job_id": new_scope["external_job_id"], + "repository": new_scope["repository"], + "target_branch": new_scope["target_branch"], + "generation": int(new_scope["generation"]), + "execution_digest": new_scope["execution_digest"], + "capabilities": new_scope["capabilities"], + "audiences": new_scope["audiences"], + "new_bridge_id": new_trust["bridge_id"], + "new_trust_manifest_sha256": new_digest, + "new_scope_manifest_sha256": new_scope_digest, + "new_scope_id": new_scope["scope_id"], + }.items() + ): + _legacy._fail("P0_GENERIC_SCOPE_BINDING_MISMATCH") + with closing(_legacy._db(db_path)) as con: + con.execute("PRAGMA synchronous=FULL") + con.execute("BEGIN IMMEDIATE") + _legacy._ready(con) + _validate_generic_schema(con) + existing = con.execute( + "SELECT * FROM " + GENERIC_SCOPE_TABLE + " WHERE supersession_id=?", + (grant["supersession_id"],), + ).fetchone() + grant_sha = _legacy.sha256_canonical(grant) + if existing is not None: + if existing["grant_sha256"] != grant_sha: + _legacy._fail("P0_GENERIC_SCOPE_REPLAY_CONFLICT") + active_digest, active_scope = _active_scope_chain(con) + if active_digest != new_digest or active_scope["scope_id"] != new_scope["scope_id"]: + _legacy._fail("P0_GENERIC_SCOPE_REPLAY_CONFLICT") + con.commit() + return {"scope_id": new_scope["scope_id"], "trust_manifest_sha256": new_digest, "replayed": True} + if con.execute("SELECT count(*) FROM " + GENERIC_SCOPE_TABLE).fetchone()[0] >= MAX_GENERIC_TRANSITIONS: + _legacy._fail("P0_GENERIC_SCOPE_TRANSITION_LIMIT_EXCEEDED") + active_digest, active_scope = _active_scope_chain(con) + if active_digest != old_digest or _legacy.sha256_canonical(_legacy._scope_material(active_scope)) != old_scope_digest: + _legacy._fail("P0_GENERIC_SCOPE_PRESTATE_MISMATCH") + task = con.execute("SELECT * FROM tasks WHERE task_id=?", (new_scope["task_id"],)).fetchone() + if task is None: + _legacy._fail("TASK_NOT_FOUND") + _task_is_currently_claimed(task, now) + execution = _legacy._execution(task) + if tuple(execution[key] for key in ("external_job_id", "repository", "target_branch", "generation", "execution_digest")) != tuple(new_scope[key] for key in ("external_job_id", "repository", "target_branch", "generation", "execution_digest")): + _legacy._fail("P0_GENERIC_SCOPE_TASK_BINDING_MISMATCH") + if not set(new_scope["capabilities"]).issubset(set(_legacy._capabilities(task))): + _legacy._fail("P0_GENERIC_SCOPE_CAPABILITIES_MISMATCH") + if con.execute("SELECT 1 FROM task_principal_admission_scopes WHERE scope_id=?", (new_scope["scope_id"],)).fetchone() is not None: + _legacy._fail("P0_GENERIC_SCOPE_ID_REUSE") + values = ( + grant["supersession_id"], grant_sha, old_scope["task_id"], old_scope["external_job_id"], + int(old_scope["generation"]), old_trust["bridge_id"], old_digest, old_scope_digest, + old_scope["scope_id"], new_scope["task_id"], new_scope["external_job_id"], + new_scope["repository"], new_scope["target_branch"], int(new_scope["generation"]), + new_scope["execution_digest"], _legacy.canonical_json(new_scope["capabilities"]).decode(), + _legacy.canonical_json(new_scope["audiences"]).decode(), new_trust["bridge_id"], new_digest, + new_scope_digest, new_scope["scope_id"], grant["signer_namespace"], grant["signer_identity"], + grant["owner_key_id"], int(grant["issued_at"]), int(grant["not_before"]), int(grant["expires_at"]), + 1, grant["supersession_reason"], grant["signature"], now, + ) + con.execute( + "INSERT INTO " + GENERIC_SCOPE_TABLE + " VALUES(" + ",".join("?" for _ in values) + ")", + values, + ) + generic_row = con.execute( + "SELECT * FROM " + GENERIC_SCOPE_TABLE + " WHERE supersession_id=?", + (grant["supersession_id"],), + ).fetchone() + if generic_row is None or tuple(generic_row) != values: + _legacy._fail("P0_GENERIC_SCOPE_READBACK_MISMATCH") + scope_row = ( + new_scope["scope_id"], new_trust["issuer_id"], new_trust["key_id"], new_scope["task_id"], + new_scope["external_job_id"], new_scope["repository"], new_scope["target_branch"], + int(new_scope["generation"]), new_scope["execution_digest"], + _legacy.canonical_json(new_scope["capabilities"]).decode(), + _legacy.canonical_json(new_scope["audiences"]).decode(), new_digest, now, + ) + _legacy._insert_exact( + con, + "INSERT INTO task_principal_admission_scopes VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + scope_row, + "SELECT scope_id,issuer_id,key_id,task_id,external_job_id,repository,target_branch,generation,execution_digest,capabilities_json,audiences_json,trust_manifest_sha256,created_at FROM task_principal_admission_scopes WHERE scope_id=?", + (new_scope["scope_id"],), + scope_row, + ) + event_row = ( + "REGISTER", new_trust["issuer_id"], new_trust["key_id"], new_trust["public_key"], + new_digest, new_scope["scope_id"], now, + ) + _legacy._insert_exact( + con, + "INSERT INTO task_principal_trust_events(event_type,issuer_id,key_id,public_key,trust_manifest_sha256,scope_id,created_at) VALUES(?,?,?,?,?,?,?)", + event_row, + "SELECT event_type,issuer_id,key_id,public_key,trust_manifest_sha256,scope_id,created_at FROM task_principal_trust_events WHERE scope_id=? AND trust_manifest_sha256=?", + (new_scope["scope_id"], new_digest), + event_row, + ) + revocation_row = ( + "SCOPE", old_scope["scope_id"], + "p0_task_scope_supersession:" + grant["supersession_id"], now, + ) + _legacy._insert_exact( + con, + "INSERT INTO task_principal_revocations(kind,target,reason,created_at) VALUES(?,?,?,?)", + revocation_row, + "SELECT kind,target,reason,created_at FROM task_principal_revocations WHERE kind=? AND target=?", + ("SCOPE", old_scope["scope_id"]), + revocation_row, + ) + active_after, scope_after = _active_scope_chain(con) + if active_after != new_digest or scope_after["scope_id"] != new_scope["scope_id"]: + _legacy._fail("P0_GENERIC_SCOPE_READBACK_MISMATCH") + con.commit() + return {"scope_id": new_scope["scope_id"], "trust_manifest_sha256": new_digest, "replayed": False} + + +# The current R10 module has already patched the legacy classes to use its +# overlay globals. Point those same globals at this validated chain so the +# existing issuer/verifier classes retain their exact lease/assertion logic. +_current._active_scope_chain = _active_scope_chain +_current._legacy._active_scope_chain = _active_scope_chain +_current._legacy._sealed_readiness = _current._sealed_readiness diff --git a/docs/architecture/adr-task-admission-bootstrap-p0.md b/docs/architecture/adr-task-admission-bootstrap-p0.md new file mode 100644 index 0000000..a6997fd --- /dev/null +++ b/docs/architecture/adr-task-admission-bootstrap-p0.md @@ -0,0 +1,102 @@ +# ADR: P0-0 Task Admission Bootstrap without weakening TaskPrincipal + +Date: 2026-09-03 +Status: Candidate for semantic port + +## Context + +The live control plane can expose governed writer operations while still being +unable to create the Agent Task/TaskLease required by TaskPrincipal. This is a +bootstrap deadlock, not a reason to enable global repository writes. + +The safety invariant is: + +```text +Owner / durable delegation + -> Task admission grant + -> real Agent Task + -> claim + heartbeat + TaskLease + -> TaskPrincipal + -> WriterLease + -> governed commit +``` + +`GovernedTaskBootstrap.create()` may create a GitOps binding/Job. It is not, by +itself, evidence of an Agent Task claim or TaskLease. + +## Decision + +Introduce a narrow Task Admission Bootstrap substrate with three operations: + +- `getTaskAdmissionEnvelope` +- `submitTaskAdmissionAuthorization` +- `getTaskAdmissionStatus` + +The envelope normalizes and binds repository, source OID, target branch, +generation, execution digest, capabilities, file allowlist, risk, policy digest, +expiry and nonce. + +Authorization is accepted only from: + +1. an already-verified `OwnerDelegationEpoch`; or +2. an Owner signature over the exact canonical envelope. + +There is deliberately no permissive default authorization backend. + +The derived `TaskAdmissionGrant` can only narrow the parent authorization. +Production, tag, baseline, main, global-write, force-push, runtime-anchor and +arbitrary-shell capabilities are explicitly rejected. + +The grant is passed to the existing Agent Task control plane via an injected +`TaskAdmissionPort`. This component never writes the Task database. + +The production adapter accepts only a service-managed official Task transport; +it never returns its control credential. It first reconciles the exact task +identity, then POSTs the official `/v1/tasks` contract only if absent, and +reconciles an ambiguous response before reporting `UNKNOWN`. Task receipts use +actual `QUEUED`, `CLAIMED`, or `RUNNING` state, never a synthetic ready state. + +Durable epochs are resolved through an injected provider that receives the +authenticated caller identity from trusted middleware. A body-supplied epoch +identifier is only a lookup key. The existing durable preparation authorization +does not become file-write or TaskPrincipal authority. + +TaskPrincipal is not used to bootstrap the Task. It remains downstream of a +real claim/TaskLease. + +## Crash / replay semantics + +Envelope creation is idempotent by exact request digest. + +Authorization/admission is durable and exact replay returns the same Task +receipt. A SQLite `BEGIN IMMEDIATE` claim persists the immutable grant and its +authorization-proof digest before the Task side effect. Cross-instance callers +observe the durable envelope winner; a different proof cannot replace the +stored grant. The live Task API retains the second exact-identity boundary. + +A crash after authorization but before Task API admission must resume the same +envelope/grant identity. It must not create a replacement task or widen scope. + +## Integration rule for current live authority + +The source snapshot used for this candidate is not proven byte-identical to the +2026-09-03 live authority. Therefore the code is a semantic-port candidate. + +The live integration must: + +- preserve existing TaskPrincipal verification; +- adapt `TaskAdmissionPort` to the actual Agent Task API; +- adapt `OwnerAuthorizationBackend` to the actual trusted Owner/delegation + verifier; +- expose admission routes outside the TaskPrincipal gate, because the + cryptographic/delegation proof is the bootstrap authority; +- never turn the runtime anchor into a writer; +- never use Gitea/SSH/downstream credentials as Owner authority; +- publish OpenAPI/capability metadata only when handlers are actually wired. + +## Consequences + +The control plane can become unattended after one durable Owner delegation +epoch, while retaining fail-closed boundaries. If no valid delegation exists, +the only legitimate stop is an already-generated canonical signing envelope +awaiting the Owner's cryptographic signature. diff --git a/docs/gitops/openapi-task-admission-bootstrap-fragment.yaml b/docs/gitops/openapi-task-admission-bootstrap-fragment.yaml new file mode 100644 index 0000000..e6fbb57 --- /dev/null +++ b/docs/gitops/openapi-task-admission-bootstrap-fragment.yaml @@ -0,0 +1,109 @@ +# AIOS Task Admission Bootstrap OpenAPI fragment. +# Integrate into the canonical Bridge OpenAPI only when the corresponding live +# handlers and authorization backend are configured. This fragment does not +# enable legacy repository mutation or change TaskPrincipal/WriterLease gates. + +components: + schemas: + TaskAdmissionEnvelopeRequest: + type: object + additionalProperties: false + required: + - idempotency_key + - task_id + - repository + - authority_base_oid + - target_branch + - generation + - execution_digest + - requested_capabilities + - file_allowlist + - risk + properties: + idempotency_key: {type: string, minLength: 8, maxLength: 256} + task_id: {type: string, minLength: 3, maxLength: 180} + repository: {type: string, enum: [aios/aios]} + authority_base_oid: {type: string, pattern: '^[0-9a-f]{40}$'} + target_branch: {type: string, pattern: '^agent/.+'} + generation: {type: integer, minimum: 1} + execution_digest: {type: string, pattern: '^[0-9a-f]{64}$'} + requested_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string} + file_allowlist: + type: array + minItems: 1 + uniqueItems: true + items: {type: string} + risk: {type: string, enum: [low, medium]} + expires_in_seconds: + type: integer + minimum: 60 + maximum: 900 + + TaskAdmissionAuthorizationRequest: + type: object + additionalProperties: false + required: [envelope_id, authorization_kind] + properties: + envelope_id: {type: string} + authorization_kind: + type: string + enum: [delegation_epoch, owner_signature] + delegation_epoch_id: {type: string} + owner_key_id: {type: string} + signature_base64: {type: string} + +paths: + /v2/task-admission/envelope: + post: + operationId: getTaskAdmissionEnvelope + summary: Build and persist a canonical narrow Task admission envelope + description: > + No Git/Task mutation occurs. The server normalizes repository, branch, + capabilities, file allowlist, risk, policy digest, expiry and nonce. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/TaskAdmissionEnvelopeRequest'} + responses: + '200': {description: Canonical envelope and signing payload} + '403': {description: Requested scope exceeds bootstrap policy} + '409': {description: Idempotency key conflicts with a different request} + + /v2/task-admission/authorize: + post: + operationId: submitTaskAdmissionAuthorization + summary: Verify durable delegation or Owner signature and admit one exact Task + description: > + This operation does not accept a general-purpose bearer as authority. + Authorization is proven by a verified OwnerDelegationEpoch or by the + Owner signature over the exact canonical envelope. The resulting grant + can only narrow scope and is handed to the existing Agent Task API. + A delegation identifier in caller JSON is insufficient: the backend + binds the verified parent epoch to the authenticated service caller. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/TaskAdmissionAuthorizationRequest'} + responses: + '200': {description: Exact QUEUED, CLAIMED, or RUNNING Task receipt or idempotent replay} + '403': {description: Invalid, expired, revoked or widened authorization} + '409': {description: Conflicting idempotent admission} + + /v2/task-admission/status: + get: + operationId: getTaskAdmissionStatus + summary: Read durable Task-admission state + parameters: + - in: query + name: envelope_id + required: true + schema: {type: string} + responses: + '200': {description: Pending or admitted receipt} + '404': {description: Envelope not found} diff --git a/docs/gitops/task-admission-bootstrap-p0-runbook.md b/docs/gitops/task-admission-bootstrap-p0-runbook.md new file mode 100644 index 0000000..d133fb4 --- /dev/null +++ b/docs/gitops/task-admission-bootstrap-p0-runbook.md @@ -0,0 +1,102 @@ +# Task Admission Bootstrap P0 Runbook + +## Goal + +Repair the Task-admission bootstrap chain without enabling legacy/global writes. + +## Preconditions + +Re-read live authority, runtime mode, OpenAPI, capabilities and current Task +API. Do not assume the package source paths are current. + +Required invariants: + +```text +AIOS_GITOPS_WRITES_ENABLED=false +legacy_global_writes_enabled=false +18893=IMMUTABLE_STAGING +main/tag/baseline/production writes=false +``` + +## Semantic-port sequence + +1. Locate the live Agent Task create/admission implementation. +2. Locate the current Owner/delegation signature verifier. +3. Port `aios_task_admission_bootstrap.py` as the common substrate. +4. Implement `TaskAdmissionPort.admit()` with the official Agent Task API. +5. Implement `OwnerAuthorizationBackend` with the current trusted verifier. +6. Wire the three task-admission operations into the existing Bridge. +7. Keep TaskPrincipal issuance downstream of real Task claim/TaskLease. +8. Merge the OpenAPI fragment only after the live handlers are wired. +9. Run impacted Linux/POSIX, TaskPrincipal, Bridge and writer regression. +10. Create the bootstrap-repair governed commit through the existing sanctioned + recovery path. +11. Use the repaired admission path to create the Repository Discovery P0 Task. +12. Claim/heartbeat -> TaskLease -> TaskPrincipal -> WriterLease. +13. Commit only the existing Repository Discovery allowlist through + `commitFilesGoverned`. +14. Reconcile ambiguous outcomes before any retry. + Admission uses a per-envelope, crash-releasing SQLite lock under the + admission state path's `.locks` directory. It persists the immutable + grant before an effect, then the official Task API GET reconciles any + crashed or ambiguous effect before a POST is permitted. +15. Replays revalidate the supplied proof, policy, expiry and revocation before + reading a receipt. A different otherwise-valid proof conflicts with the + persisted authorization digest; it never becomes a harmless receipt replay. +16. The Task API adapter must receive a server-resolved trusted execution plan + for the signed execution digest. It supplies `metadata.execution` with + the exact repository, branch, generation, digest and nonempty external job + identifier. Do not derive a job identity from request JSON or generate one. +17. Require Authority / remote Git / API OID equality and idempotent replay. +18. Publish immutable staging artifact and run Repository Discovery live E2E. + +## Never do + +- direct Task DB inserts/updates; +- direct bare-repo/ref changes; +- raw Git push as a substitute for governed publication; +- borrow SSH/Gitea/service credentials as Owner authorization; +- disable TaskPrincipal verification; +- issue TaskPrincipal without a live TaskLease; +- set global writes true; +- turn 18893 into a writer; +- include production/tag/baseline/main permissions in a task grant. + +## Acceptance + +Bootstrap repair: + +```text +ENVELOPE_EXACT_REPLAY=PASS +SCOPE_NARROWING=PASS +DELEGATION_REVOKE_EXPIRY=PASS +OWNER_SIGNATURE_VERIFY=PASS +CONCURRENT_ADMISSION_SINGLE_TASK=PASS +TASK_DB_DIRECT_WRITE=false +TASKPRINCIPAL_BYPASS=false +GLOBAL_WRITES=false +CRASH_AFTER_EFFECT_REPLAY=PASS +CRASH_BEFORE_EFFECT_REPLAY=PASS +CHANGED_VALID_PROOF_CONFLICT=PASS +TASK_API_EXACT_IDENTITY=PASS +``` + +These are implementation and test acceptance conditions only. They are not +evidence of a live claim, governed publication, remote OID equality, or staging +E2E execution; obtain those independently through the sanctioned live runbook. + +Repository Discovery publication: + +```text +TASK_CREATED=PASS +TASK_CLAIMED=PASS +TASK_HEARTBEAT=PASS +TASK_LEASE=PASS +TASK_PRINCIPAL=PASS +WRITER_LEASE=PASS +COMMIT_FILES_GOVERNED=PASS +AUTHORITY_REMOTE_API_OID_EQUAL=PASS +IDEMPOTENCY_REPLAY=PASS +STAGING_LIVE_E2E=PASS +TASK_VERIFIED=SUCCEEDED +``` diff --git a/scripts/phase2_introspect_and_freeze.py b/scripts/phase2_introspect_and_freeze.py new file mode 100644 index 0000000..92bcc21 --- /dev/null +++ b/scripts/phase2_introspect_and_freeze.py @@ -0,0 +1,543 @@ +"""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()) diff --git a/tests/test_bridge_v2_governed_http_bootstrap.py b/tests/test_bridge_v2_governed_http_bootstrap.py index 53c09e5..a8bbf59 100644 --- a/tests/test_bridge_v2_governed_http_bootstrap.py +++ b/tests/test_bridge_v2_governed_http_bootstrap.py @@ -1,108 +1,111 @@ -import base64 -import subprocess -from pathlib import Path - -from aios_authority_git_executor import AuthorityGitExecutor -from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig -from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection -from aios_continuation_lease import WriterLeaseRequestV2 - - -class Probe: - def __init__(self, present=False): - self.present = present - - def branch_exists(self, _branch): - return self.present - - def branch_head(self, _branch): - return "a" * 40 - - def is_ancestor(self, _older, _newer): - return True - - def commit_exists(self, _oid): - return True - - -def test_authority_create_task_branch_is_agent_scoped_and_idempotent(tmp_path): - bare = tmp_path / "repo.git" - subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) - source = tmp_path / "source" - subprocess.run(["git", "init", str(source)], check=True, capture_output=True) - (source / "README.md").write_text("fixture\n", encoding="utf-8") - subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True) - subprocess.run(["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"], check=True, capture_output=True) - base = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() - executor = AuthorityGitExecutor(bare, tmp_path / "worktrees") - subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True) - assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base - assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base - assert executor.read_ref("refs/heads/agent/contract-test") == base - - -def test_create_new_lease_is_exact_base_and_absent(): - request = WriterLeaseRequestV2( - task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809", - repository="aios/aios", - branch_mode="CREATE_NEW", - base_oid="a" * 40, - expected_branch_oid="a" * 40, - target_branch="agent/p0-bridge-v21-durable-reconciliation-20260809", - generation=1, - ) - request.validate(Probe()) - - -def test_create_new_lease_rejects_existing_target(): - request = WriterLeaseRequestV2( - task_id="task", - repository="aios/aios", - branch_mode="CREATE_NEW", - base_oid="a" * 40, - expected_branch_oid="a" * 40, - target_branch="agent/existing-target", - generation=1, - ) - try: - request.validate(Probe(True)) - except Exception as exc: - assert str(exc) == "TARGET_BRANCH_ALREADY_EXISTS" - else: - raise AssertionError("existing CREATE_NEW target must fail") - - -def test_slash_branch_uses_paginated_collection(): - credential = ServerSideCredentialProjection(Path("/tmp/key"), Path("/tmp/token"), active=True) - calls = [] - - class FakeRemote(GiteaContinuationRemote): - def _api(self, path): - calls.append(path) - if "page=1" in path: - return [{"name": f"agent/other-{i}", "commit": {"id": "b" * 40}} for i in range(50)] - return [{"name": "agent/slash/branch", "commit": {"id": "c" * 40}}] - - remote = FakeRemote(Path("/tmp"), credential) - assert remote.read_api_branch("agent/slash/branch") == "c" * 40 - assert calls[0].endswith("branches?page=1&limit=50") - assert calls[1].endswith("branches?page=2&limit=50") - - -def test_http_route_contract_is_complete(): - expected = { - "/v2/runtime", "/v2/capabilities", "/v2/governed-task", - "/v2/execution-envelope", - "/v2/writer-lease", "/v2/commit-file", "/v2/commit-files", - "/v2/job", "/v2/job/resume", "/v2/job/reconcile", - "/v2/tag/approval-request", - "/v2/tag/publish", - "/v2/tag/status", - "/v2/release/artifact", - "/v2/release/execute", +import base64 +import subprocess +from pathlib import Path + +from aios_authority_git_executor import AuthorityGitExecutor +from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig +from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection +from aios_continuation_lease import WriterLeaseRequestV2 + + +class Probe: + def __init__(self, present=False): + self.present = present + + def branch_exists(self, _branch): + return self.present + + def branch_head(self, _branch): + return "a" * 40 + + def is_ancestor(self, _older, _newer): + return True + + def commit_exists(self, _oid): + return True + + +def test_authority_create_task_branch_is_agent_scoped_and_idempotent(tmp_path): + bare = tmp_path / "repo.git" + subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) + source = tmp_path / "source" + subprocess.run(["git", "init", str(source)], check=True, capture_output=True) + (source / "README.md").write_text("fixture\n", encoding="utf-8") + subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"], check=True, capture_output=True) + base = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + executor = AuthorityGitExecutor(bare, tmp_path / "worktrees") + subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True) + assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base + assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base + assert executor.read_ref("refs/heads/agent/contract-test") == base + + +def test_create_new_lease_is_exact_base_and_absent(): + request = WriterLeaseRequestV2( + task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809", + repository="aios/aios", + branch_mode="CREATE_NEW", + base_oid="a" * 40, + expected_branch_oid="a" * 40, + target_branch="agent/p0-bridge-v21-durable-reconciliation-20260809", + generation=1, + ) + request.validate(Probe()) + + +def test_create_new_lease_rejects_existing_target(): + request = WriterLeaseRequestV2( + task_id="task", + repository="aios/aios", + branch_mode="CREATE_NEW", + base_oid="a" * 40, + expected_branch_oid="a" * 40, + target_branch="agent/existing-target", + generation=1, + ) + try: + request.validate(Probe(True)) + except Exception as exc: + assert str(exc) == "TARGET_BRANCH_ALREADY_EXISTS" + else: + raise AssertionError("existing CREATE_NEW target must fail") + + +def test_slash_branch_uses_paginated_collection(): + credential = ServerSideCredentialProjection(Path("/tmp/key"), Path("/tmp/token"), active=True) + calls = [] + + class FakeRemote(GiteaContinuationRemote): + def _api(self, path): + calls.append(path) + if "page=1" in path: + return [{"name": f"agent/other-{i}", "commit": {"id": "b" * 40}} for i in range(50)] + return [{"name": "agent/slash/branch", "commit": {"id": "c" * 40}}] + + remote = FakeRemote(Path("/tmp"), credential) + assert remote.read_api_branch("agent/slash/branch") == "c" * 40 + assert calls[0].endswith("branches?page=1&limit=50") + assert calls[1].endswith("branches?page=2&limit=50") + + +def test_http_route_contract_is_complete(): + expected = { + "/v2/runtime", "/v2/capabilities", "/v2/governed-task", + "/v2/execution-envelope", + "/v2/writer-lease", "/v2/commit-file", "/v2/commit-files", + "/v2/job", "/v2/job/resume", "/v2/job/reconcile", + "/v2/tag/approval-request", + "/v2/tag/publish", + "/v2/tag/status", + "/v2/release/artifact", + "/v2/release/execute", "/v2/release/consistency", "/v2/release/acceptance", - } - from aios_chatgpt_bridge_v2_server import ROUTES - - assert set(ROUTES) == expected + "/v2/task-admission/envelope", + "/v2/task-admission/authorize", + "/v2/task-admission/status", + } + from aios_chatgpt_bridge_v2_server import ROUTES + + assert set(ROUTES) == expected diff --git a/tests/test_phase2_introspect_and_freeze.py b/tests/test_phase2_introspect_and_freeze.py new file mode 100644 index 0000000..dfd2d68 --- /dev/null +++ b/tests/test_phase2_introspect_and_freeze.py @@ -0,0 +1,131 @@ +"""Local, non-SSH tests for the bounded Phase2 transport and status contract.""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "phase2_introspect_and_freeze.py" +SPEC = importlib.util.spec_from_file_location("phase2_introspect_and_freeze", SCRIPT) +assert SPEC and SPEC.loader +phase2 = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(phase2) + + +def test_transport_sends_large_payload_without_argv_growth(): + payload = b"x" * (256 * 1024) + observed = {} + + def runner(argv, **kwargs): + observed["argv"] = argv + observed["kwargs"] = kwargs + return SimpleNamespace(returncode=0, stdout=b'{"ok":true}') + + assert phase2.run_remote_python(payload, runner=runner) == b'{"ok":true}' + assert observed["kwargs"]["input"] == payload + argv = observed["argv"] + assert argv == [ + "ssh.exe", + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=12", + "hw", + 'test "$(hostname)" = cn-network-mkt && sudo -n python3 -', + ] + assert sum(len(part.encode("utf-8")) + 1 for part in argv) <= 512 + assert all(payload not in part.encode("utf-8") for part in argv) + assert all("base64" not in part.lower() for part in argv) + + +def test_transport_reports_timeout_without_stderr(): + def runner(argv, **kwargs): + raise subprocess.TimeoutExpired(argv, kwargs["timeout"], stderr=b"secret=do-not-leak") + + with pytest.raises(phase2.RemoteTransportError, match="timed out") as error: + phase2.run_remote_python(b"payload", runner=runner, timeout=3) + assert "secret" not in str(error.value) + + +def test_transport_reports_nonzero_without_stderr(): + def runner(argv, **kwargs): + return SimpleNamespace(returncode=23, stdout=b"", stderr=b"token=do-not-leak") + + with pytest.raises(phase2.RemoteTransportError, match="status 23") as error: + phase2.run_remote_python(b"payload", runner=runner) + assert "token" not in str(error.value) + + +def test_status_flags_are_explicit_and_missing_public_symbol_fails_closed(): + valid = phase2.validate_public_function_ast( + "def offline_migrate_task_principal_schema():\n pass\n" + "def supersede_p0_scope():\n pass\n" + ) + assert valid["ok"] is True + + missing = phase2.validate_public_function_ast( + "def offline_migrate_task_principal_schema():\n pass\n" + ) + assert missing["ok"] is False + assert missing["missing"] == ["supersede_p0_scope"] + + status = phase2.make_phase2_status( + "FAIL", errors=["PUBLIC_SYMBOL_MISSING:supersede_p0_scope"] + ) + assert status["status"] == "FAIL" + assert status["fail_closed"] is True + assert all(status[flag] is False for flag in phase2.MUTATION_FLAGS) + + +def test_dynamic_reexport_is_accepted_only_with_explicit_globals_export_mechanism(): + source = ( + "for _name, _value in _legacy.__dict__.items():\n" + " globals()[_name] = _value\n" + ) + result = phase2.validate_public_function_ast(source) + assert result["ok"] is True + assert result["dynamic_reexport"] is True + + no_export = "x = 1\n" + result = phase2.validate_public_function_ast(no_export) + assert result["ok"] is False + assert result["missing"] == list(phase2.DEFAULT_REQUIRED_ADMISSION_FUNCTIONS) + + +def test_status_validator_rejects_mutation_claims(): + status = phase2.make_phase2_status("PASS") + assert phase2.validate_phase2_status(status)["read_only"] is True + status["git_mutation"] = True + with pytest.raises(phase2.Phase2StatusError, match="git_mutation"): + phase2.validate_phase2_status(status) + + +def test_request_payload_contains_request_but_argv_does_not(): + payload = phase2.build_remote_payload( + { + "issuer_pid": 123, + "expected_admission_sha256": "a" * 64, + } + ) + assert b'"issuer_pid":123' in payload + assert b'"expected_admission_sha256":"' in payload + argv = phase2.build_ssh_argv() + assert all("123" not in part and "aaaa" not in part for part in argv) + + +def test_live_defaults_use_current_issuer_and_storage_migration_module(): + assert phase2.DEFAULT_ISSUER_UNIT == "aios-task-principal-issuer.service" + assert phase2.DEFAULT_MIGRATION_MODULE == "aios_task_principal_storage_migration" + payload = phase2.build_remote_payload() + assert b"aios-task-principal-issuer.service" in payload + assert b"aios_task_principal_storage_migration" in payload + stale_issuer = "aios-task-principal-" + "admission.service" + stale_migration = "aios_task_principal_" + "migration" + assert stale_issuer.encode() not in payload + assert stale_migration.encode() not in payload diff --git a/tests/test_task_admission_bootstrap.py b/tests/test_task_admission_bootstrap.py new file mode 100644 index 0000000..68dcb50 --- /dev/null +++ b/tests/test_task_admission_bootstrap.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +import base64 +from concurrent.futures import ThreadPoolExecutor +import hashlib +from pathlib import Path +import sys +import threading + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "active-runtime" / "app" +if str(APP) not in sys.path: + sys.path.insert(0, str(APP)) + +from aios_task_admission_bootstrap import ( # noqa: E402 + OwnerDelegationEpoch, + TaskAdmissionBootstrapService, + TaskAdmissionError, + TaskAdmissionGrant, + TaskAdmissionPolicy, + TaskAdmissionStore, + canonical_json_bytes, +) +from aios_task_admission_live_adapters import ( # noqa: E402 + AgentTaskAdmissionPort, + BoundOwnerAuthorizationBackend, +) + + +BASE_OID = "9" * 40 +EXECUTION_DIGEST = "a" * 64 +TASK_ID = "repository-discovery-p0-20260903" +BRANCH = "agent/repository-discovery-p0-20260902" +IDEMPOTENCY = "repository-discovery-p0-admission-20260903" +FILES = [ + "active-runtime/app/aios_repository_discovery.py", + "active-runtime/app/aios_chatgpt_bridge_v2.py", + "docs/gitops/openapi-chatgpt-actions.yaml", + "tests/test_repository_discovery.py", +] +CAPS = [ + "governed.task.create", + "agent-task:control", + "task.principal.issue", + "writer.lease", + "governed.write.files", + "git.read", + "testing", + "verification", +] + + +class FakeAuthorization: + def __init__(self, *, policy_digest: str, now: int = 1_800_000_000): + self.now = now + self.signature_calls = 0 + self.epoch_calls = 0 + self.signature_ok = True + self.epoch = OwnerDelegationEpoch( + epoch_id="epoch-repository-discovery-20260903", + owner_identity="owner", + trust_domain="aios.owner", + repository="aios/aios", + allowed_branch_prefixes=("agent/repository-discovery-",), + allowed_capabilities=frozenset(CAPS), + allowed_file_prefixes=("active-runtime", "docs", "tests"), + risk_ceiling="medium", + issued_at=now - 60, + not_before=now - 60, + expires_at=now + 3600, + revocation_epoch=0, + policy_digest=policy_digest, + ) + + def verify_delegation_epoch(self, epoch_id: str) -> OwnerDelegationEpoch: + self.epoch_calls += 1 + if epoch_id != self.epoch.epoch_id: + raise TaskAdmissionError("UNKNOWN_EPOCH", 403) + return self.epoch + + def verify_owner_signature( + self, + *, + key_id: str, + canonical_payload: bytes, + signature_b64: str, + ) -> str: + self.signature_calls += 1 + if key_id != "owner-key-1" or not self.signature_ok: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) + expected = base64.b64encode( + hashlib.sha256(canonical_payload + b"\0owner-key-1").digest() + ).decode("ascii") + if signature_b64 != expected: + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) + return "owner" + + +class FakeTaskPort: + def __init__(self): + self.calls = 0 + self.grants = [] + self._lock = threading.Lock() + + def admit(self, grant): + with self._lock: + self.calls += 1 + self.grants.append(grant) + return {"task_id": grant.task_id, "state": "QUEUED"} + + +@pytest.fixture +def policy(): + return TaskAdmissionPolicy( + allowed_capabilities=frozenset(CAPS), + max_file_count=16, + max_task_ttl_seconds=3600, + max_envelope_ttl_seconds=900, + max_risk="medium", + ) + + +@pytest.fixture +def service(tmp_path: Path, policy: TaskAdmissionPolicy): + now = 1_800_000_000 + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now) + port = FakeTaskPort() + svc = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), + authorization=auth, + task_port=port, + policy=policy, + clock=lambda: now, + ) + return svc, auth, port + + +def request(**overrides): + value = { + "idempotency_key": IDEMPOTENCY, + "task_id": TASK_ID, + "repository": "aios/aios", + "authority_base_oid": BASE_OID, + "target_branch": BRANCH, + "generation": 1, + "execution_digest": EXECUTION_DIGEST, + "requested_capabilities": list(CAPS), + "file_allowlist": list(FILES), + "risk": "medium", + "expires_in_seconds": 600, + } + value.update(overrides) + return value + + +def test_envelope_exact_replay_returns_same_nonce_and_payload(service): + svc, _, _ = service + first = svc.create_envelope(request()) + second = svc.create_envelope(request()) + assert first["idempotent"] is False + assert second["idempotent"] is True + assert first["envelope"] == second["envelope"] + assert first["canonical_payload_base64"] == second["canonical_payload_base64"] + + +def test_envelope_idempotency_conflict_fails_closed(service): + svc, _, _ = service + svc.create_envelope(request()) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_IDEMPOTENCY_CONFLICT"): + svc.create_envelope(request(risk="low")) + + +@pytest.mark.parametrize( + "field,value,code", + [ + ("repository", "other/repo", "TASK_ADMISSION_REPOSITORY_REJECTED"), + ("target_branch", "main", "TASK_ADMISSION_BRANCH_REJECTED"), + ("authority_base_oid", "HEAD", "TASK_ADMISSION_BASE_OID_INVALID"), + ("requested_capabilities", ["production.write"], "TASK_ADMISSION_PROHIBITED_CAPABILITY"), + ("requested_capabilities", ["git:*"], "TASK_ADMISSION_CAPABILITY_INVALID"), + ("file_allowlist", ["../secrets"], "TASK_ADMISSION_FILE_PATH_INVALID"), + ("file_allowlist", ["/etc/passwd"], "TASK_ADMISSION_FILE_PATH_INVALID"), + ("risk", "high", "TASK_ADMISSION_RISK_OUT_OF_POLICY"), + ], +) +def test_envelope_policy_rejects_scope_widening(service, field, value, code): + svc, _, _ = service + with pytest.raises(TaskAdmissionError, match=code): + svc.create_envelope(request(**{field: value})) + + +def test_valid_delegation_epoch_admits_exact_task_once(service): + svc, auth, port = service + envelope = svc.create_envelope(request())["envelope"] + result = svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert result["schema"] == "aios.task-admission-receipt.v1" + assert result["task_id"] == TASK_ID + assert result["task_state"] == "QUEUED" + assert result["parent_epoch_id"] == auth.epoch.epoch_id + assert port.calls == 1 + + replay = svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert replay["task_id"] == TASK_ID + assert replay["idempotent"] is True + assert port.calls == 1 + + +def test_post_admission_projection_runs_after_exact_task_readback_once( + tmp_path: Path, policy: TaskAdmissionPolicy +): + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest()) + port = FakeTaskPort() + observations = [] + + def project(grant, task): + observations.append((grant.task_id, task["task_id"], task["state"])) + return {"job_id": "projected-job"} + + svc = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), + authorization=auth, + task_port=port, + policy=policy, + clock=lambda: 1_800_000_000, + post_admission=project, + ) + envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"] + proof = { + "envelope_id": envelope_id, + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + + first = svc.authorize_and_admit(proof) + replay = svc.authorize_and_admit(proof) + + assert first["task_id"] == TASK_ID + assert replay["idempotent"] is True + assert observations == [(TASK_ID, TASK_ID, "QUEUED")] + + +def test_epoch_capability_widening_rejected_before_task_port(service): + svc, auth, port = service + # Envelope policy allows the capability, parent epoch deliberately does not. + auth.epoch = OwnerDelegationEpoch( + **{ + **auth.epoch.as_public_dict(), + "allowed_capabilities": frozenset( + item for item in CAPS if item != "writer.lease" + ), + "allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes, + "allowed_file_prefixes": auth.epoch.allowed_file_prefixes, + } + ) + envelope = svc.create_envelope(request())["envelope"] + with pytest.raises( + TaskAdmissionError, match="TASK_ADMISSION_EPOCH_CAPABILITY_WIDENING" + ): + svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert port.calls == 0 + + +def test_epoch_file_scope_widening_rejected(service): + svc, auth, port = service + auth.epoch = OwnerDelegationEpoch( + **{ + **auth.epoch.as_public_dict(), + "allowed_capabilities": auth.epoch.allowed_capabilities, + "allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes, + "allowed_file_prefixes": ("docs",), + } + ) + envelope = svc.create_envelope(request())["envelope"] + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_FILE_WIDENING"): + svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert port.calls == 0 + + +def test_revoked_or_expired_epoch_rejected(service): + svc, auth, port = service + auth.epoch = OwnerDelegationEpoch( + **{ + **auth.epoch.as_public_dict(), + "allowed_capabilities": auth.epoch.allowed_capabilities, + "allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes, + "allowed_file_prefixes": auth.epoch.allowed_file_prefixes, + "revocation_epoch": 1, + } + ) + envelope = svc.create_envelope(request())["envelope"] + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_REVOKED"): + svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert port.calls == 0 + + +def test_owner_signature_path_verifies_canonical_payload(service): + svc, auth, port = service + created = svc.create_envelope(request()) + payload = canonical_json_bytes(created["envelope"]) + signature = base64.b64encode( + hashlib.sha256(payload + b"\0owner-key-1").digest() + ).decode("ascii") + result = svc.authorize_and_admit( + { + "envelope_id": created["envelope"]["envelope_id"], + "authorization_kind": "owner_signature", + "owner_key_id": "owner-key-1", + "signature_base64": signature, + } + ) + assert result["authorization_kind"] == "owner_signature" + assert result["owner_identity"] == "owner" + assert auth.signature_calls == 1 + assert port.calls == 1 + + +def test_bad_owner_signature_is_rejected(service): + svc, _, port = service + envelope = svc.create_envelope(request())["envelope"] + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_OWNER_SIGNATURE_INVALID"): + svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "owner_signature", + "owner_key_id": "owner-key-1", + "signature_base64": base64.b64encode(b"bad").decode("ascii"), + } + ) + assert port.calls == 0 + + +def test_expired_envelope_cannot_admit(tmp_path: Path, policy: TaskAdmissionPolicy): + now = [1_800_000_000] + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now[0]) + port = FakeTaskPort() + svc = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), + authorization=auth, + task_port=port, + policy=policy, + clock=lambda: now[0], + ) + envelope = svc.create_envelope(request(expires_in_seconds=60))["envelope"] + now[0] += 61 + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_ENVELOPE_EXPIRED"): + svc.authorize_and_admit( + { + "envelope_id": envelope["envelope_id"], + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + assert port.calls == 0 + + +def test_concurrent_exact_authorization_calls_admit_once(service): + svc, auth, port = service + envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"] + body = { + "envelope_id": envelope_id, + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + with ThreadPoolExecutor(max_workers=16) as pool: + results = list(pool.map(lambda _: svc.authorize_and_admit(body), range(16))) + assert {item["task_id"] for item in results} == {TASK_ID} + assert port.calls == 1 + + +def test_cross_instance_envelope_winner_is_the_durable_value(tmp_path: Path, policy: TaskAdmissionPolicy): + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest()) + port = FakeTaskPort() + stores = [TaskAdmissionStore(tmp_path / "shared.sqlite3") for _ in range(16)] + services = [ + TaskAdmissionBootstrapService( + store=store, authorization=auth, task_port=port, policy=policy, + clock=lambda: 1_800_000_000, + ) + for store in stores + ] + with ThreadPoolExecutor(max_workers=16) as pool: + values = list(pool.map(lambda svc: svc.create_envelope(request()), services)) + assert len({item["envelope"]["envelope_id"] for item in values}) == 1 + assert sum(not item["idempotent"] for item in values) == 1 + + +def test_cross_instance_authorization_calls_create_one_task(tmp_path: Path, policy: TaskAdmissionPolicy): + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest()) + port = FakeTaskPort() + database = tmp_path / "shared.sqlite3" + first = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(database), authorization=auth, task_port=port, + policy=policy, clock=lambda: 1_800_000_000, + ) + envelope_id = first.create_envelope(request())["envelope"]["envelope_id"] + services = [ + TaskAdmissionBootstrapService( + store=TaskAdmissionStore(database), authorization=auth, task_port=port, + policy=policy, clock=lambda: 1_800_000_000, + ) + for _ in range(16) + ] + body = { + "envelope_id": envelope_id, + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + with ThreadPoolExecutor(max_workers=16) as pool: + values = list(pool.map(lambda svc: svc.authorize_and_admit(body), services)) + assert {item["task_id"] for item in values} == {TASK_ID} + assert port.calls == 1 + + +@pytest.mark.parametrize("file_name", ["docs//gitops/a.yaml", "docs/./gitops/a.yaml", "docs/a\x00.yaml"]) +def test_file_paths_must_already_be_canonical(service, file_name): + svc, _, _ = service + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_FILE_PATH_INVALID"): + svc.create_envelope(request(file_allowlist=[file_name])) + + +@pytest.mark.parametrize("generation", [True, "1", 1.0, 0]) +def test_generation_is_a_strict_positive_integer(service, generation): + svc, _, _ = service + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_GENERATION_INVALID"): + svc.create_envelope(request(generation=generation)) + + +def test_policy_digest_drift_and_changed_proof_cannot_replay(service): + svc, auth, _ = service + envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"] + body = { + "envelope_id": envelope_id, + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + svc.authorize_and_admit(body) + svc.policy = TaskAdmissionPolicy( + allowed_capabilities=frozenset(CAPS), policy_digest="policy-has-changed" + ) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_POLICY_DIGEST_DRIFT"): + svc.authorize_and_admit(body) + + +def test_grant_expiry_is_clipped_to_parent_epoch(tmp_path: Path, policy: TaskAdmissionPolicy): + now = 1_800_000_000 + auth = FakeAuthorization(policy_digest=policy.normalized_policy_digest(), now=now) + auth.epoch = OwnerDelegationEpoch( + **{**auth.epoch.as_public_dict(), "allowed_capabilities": auth.epoch.allowed_capabilities, + "allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes, + "allowed_file_prefixes": auth.epoch.allowed_file_prefixes, "expires_at": now + 120} + ) + port = FakeTaskPort() + svc = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), authorization=auth, + task_port=port, policy=policy, clock=lambda: now, + ) + result = svc.authorize_and_admit({ + "envelope_id": svc.create_envelope(request())["envelope"]["envelope_id"], + "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id, + }) + assert port.grants[0].expires_at == now + 120 + assert result["task_id"] == TASK_ID + + +def test_owner_backend_uses_bound_caller_and_decoded_armored_signature(policy): + captured = {} + + class Verifier: + def verify(self, **kwargs): + captured.update(kwargs) + return True + + epoch = FakeAuthorization(policy_digest=policy.normalized_policy_digest()).epoch + backend = BoundOwnerAuthorizationBackend( + authenticated_caller={"uid": "bridge-service"}, + verified_epoch_provider=lambda **kwargs: captured.update(epoch_lookup=kwargs) or epoch, + signature_verifier=Verifier(), + ) + assert backend.verify_delegation_epoch(epoch.epoch_id) == epoch + assert captured["epoch_lookup"]["caller"] == {"uid": "bridge-service"} + assert backend.verify_owner_signature( + key_id="owner-key-1", canonical_payload=b"exact", signature_b64=base64.b64encode(b"-----BEGIN SSH SIGNATURE-----").decode(), + ) == "aios-task-grant-signer" + assert captured["namespace"] == "aios-owner-approval-v1" + assert captured["payload"] == b"exact" + + +def test_official_task_port_reconciles_ambiguous_create(tmp_path: Path, policy): + grant = TaskAdmissionBootstrapService( + store=TaskAdmissionStore(tmp_path / "admission.sqlite3"), + authorization=FakeAuthorization(policy_digest=policy.normalized_policy_digest()), + task_port=FakeTaskPort(), policy=policy, clock=lambda: 1_800_000_000, + ) + # Construct via the public flow so the port sees a production-shaped grant. + created = grant.create_envelope(request())["envelope"] + envelope = grant._load_envelope(created["envelope_id"]) + task_grant = TaskAdmissionGrant( + grant_id="task-grant-adapter-test", envelope_id=envelope.envelope_id, + task_id=envelope.task_id, repository=envelope.repository, + authority_base_oid=envelope.authority_base_oid, target_branch=envelope.target_branch, + generation=envelope.generation, execution_digest=envelope.execution_digest, + capabilities=envelope.requested_capabilities, file_allowlist=envelope.file_allowlist, + risk=envelope.risk, trust_domain=envelope.trust_domain, + policy_digest=envelope.policy_digest, authorization_kind="owner_signature", + owner_identity="aios-task-grant-signer", parent_epoch_id=None, + issued_at=1_800_000_000, expires_at=1_800_000_600, + ) + + class NotFound(Exception): + status = 404 + + class Transport: + def __init__(self): self.calls = []; self.task = None + def request(self, method, path, body=None): + self.calls.append((method, path, body)) + if method == "GET" and self.task is None: raise NotFound() + if method == "POST": + self.task = {key: value for key, value in body.items() if key != "idempotency_key"} + self.task["status"] = "QUEUED" + raise TimeoutError("response lost after side effect") + return self.task + + transport = Transport() + plan = { + "external_job_id": "fixture-job-adapter-01", "repository": task_grant.repository, + "target_branch": task_grant.target_branch, "generation": task_grant.generation, + "execution_digest": task_grant.execution_digest, + } + assert AgentTaskAdmissionPort(transport, execution_plan_provider=lambda _: plan).admit(task_grant) == {"task_id": TASK_ID, "state": "QUEUED"} + assert [call[:2] for call in transport.calls] == [ + ("GET", "/v1/tasks/" + TASK_ID), ("POST", "/v1/tasks"), ("GET", "/v1/tasks/" + TASK_ID), + ] + + +def test_status_is_read_only_and_reports_admitted_task(service): + svc, auth, _ = service + envelope_id = svc.create_envelope(request())["envelope"]["envelope_id"] + before = svc.status(envelope_id) + assert before["status"] == "PENDING_AUTHORIZATION" + assert before["task"] is None + svc.authorize_and_admit( + { + "envelope_id": envelope_id, + "authorization_kind": "delegation_epoch", + "delegation_epoch_id": auth.epoch.epoch_id, + } + ) + after = svc.status(envelope_id) + assert after["status"] == "ADMITTED" + assert after["task"]["task_id"] == TASK_ID diff --git a/tests/test_task_admission_bootstrap_contract.py b/tests/test_task_admission_bootstrap_contract.py new file mode 100644 index 0000000..681bc1b --- /dev/null +++ b/tests/test_task_admission_bootstrap_contract.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "active-runtime" / "app" +if str(APP) not in sys.path: + sys.path.insert(0, str(APP)) + +from aios_task_admission_bridge_contract import ( # noqa: E402 + TASK_ADMISSION_OPERATION_REQUIRED_FIELDS, + TASK_ADMISSION_ROUTES, +) + + +def test_bootstrap_route_operation_mapping_is_exact(): + assert TASK_ADMISSION_ROUTES == { + "/v2/task-admission/envelope": "getTaskAdmissionEnvelope", + "/v2/task-admission/authorize": "submitTaskAdmissionAuthorization", + "/v2/task-admission/status": "getTaskAdmissionStatus", + } + + +def test_bootstrap_operations_do_not_replace_writer_or_principal_gates(): + operations = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS) + assert "acquireWriterLease" not in operations + assert "commitFilesGoverned" not in operations + assert "issueTaskPrincipal" not in operations + assert operations == { + "getTaskAdmissionEnvelope", + "submitTaskAdmissionAuthorization", + "getTaskAdmissionStatus", + } + + +def test_openapi_fragment_matches_runtime_contract(): + text = ( + ROOT / "docs" / "gitops" / "openapi-task-admission-bootstrap-fragment.yaml" + ).read_text(encoding="utf-8") + for route, operation in TASK_ADMISSION_ROUTES.items(): + assert f" {route}:" in text + assert f"operationId: {operation}" in text + assert "global write" not in text.lower() + assert "runShell" not in text + assert "force push" not in text.lower() diff --git a/tests/test_task_admission_bootstrap_http.py b/tests/test_task_admission_bootstrap_http.py new file mode 100644 index 0000000..867e4cf --- /dev/null +++ b/tests/test_task_admission_bootstrap_http.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import ast +from contextlib import contextmanager +import json +import os +from pathlib import Path +import sys +import threading +from http.server import ThreadingHTTPServer +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "active-runtime" / "app" +if str(APP) not in sys.path: + sys.path.insert(0, str(APP)) + +from aios_chatgpt_bridge_v2 import ( # noqa: E402 + BridgeV2Adapter, + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS, + TASK_ADMISSION_OPERATION_REQUIRED_FIELDS, + TASK_ADMISSION_OPERATIONS, +) +from aios_chatgpt_bridge_v2_server import ( # noqa: E402 + BridgeHandler, + build_bridge_adapter, +) +from aios_task_admission_bootstrap import TaskAdmissionError # noqa: E402 + + +class StubRuntime: + def __init__(self, status: dict[str, object], handlers=None) -> None: + self.status = status + self._handlers = dict(handlers or {}) + + def runtime_status(self) -> dict[str, object]: + return dict(self.status) + + def handlers(self) -> dict[str, object]: + return dict(self._handlers) + + +class FakeOwnerPort: + def __init__(self) -> None: + self.envelopes: list[dict[str, object]] = [] + self.authorizations: list[dict[str, object]] = [] + + def create_envelope(self, request): + self.envelopes.append(dict(request)) + return {"envelope_id": "env-1", "status": "PENDING", "http_status": 201} + + def authorize(self, request, task_port): + self.authorizations.append(dict(request)) + kind = request.get("authorization_kind") + if kind == "unsigned": + raise TaskAdmissionError("TASK_ADMISSION_AUTHORIZATION_REQUIRED", 400) + if kind == "bad_proof": + raise TaskAdmissionError("TASK_ADMISSION_OWNER_SIGNATURE_INVALID", 403) + return task_port.admit(request) + + +class FakeTaskPort: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def admit(self, request): + self.calls.append(dict(request)) + return {"task_id": request.get("task_id", "task-1"), "state": "QUEUED"} + + +def _status(**overrides: object) -> dict[str, object]: + value: dict[str, object] = { + "status": "active", + "health": 200, + "governed_writes_enabled": False, + "legacy_global_writes_enabled": False, + "task_principal_writes_enabled": False, + } + value.update(overrides) + return value + + +def _admission_handlers(owner: FakeOwnerPort, task: FakeTaskPort): + return { + "getTaskAdmissionEnvelope": owner.create_envelope, + "submitTaskAdmissionAuthorization": lambda request: owner.authorize( + request, task + ), + "getTaskAdmissionStatus": lambda request: { + "envelope_id": request["envelope_id"], + "status": "PENDING", + }, + } + + +def _post(base_url: str, path: str, body: dict[str, object]): + request = Request( + base_url + path, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=3) as response: + return response.status, json.loads(response.read()) + except HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +def _get(base_url: str, path: str): + try: + with urlopen(base_url + path, timeout=3) as response: + return response.status, json.loads(response.read()) + except HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +@contextmanager +def _serve(adapter: BridgeV2Adapter): + class TestBridgeHandler(BridgeHandler): + pass + + TestBridgeHandler.adapter = adapter + TestBridgeHandler.principal_verifier = None + server = ThreadingHTTPServer(("127.0.0.1", 0), TestBridgeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +def test_capabilities_expose_admission_only_for_complete_callable_handlers(): + runtime = StubRuntime(_status(), {"getRuntimeStatus": lambda request: request}) + without_backend = build_bridge_adapter(runtime) + assert not set(TASK_ADMISSION_OPERATIONS) & set( + without_backend.getGovernedWriteCapabilities()["operations"] + ) + assert not set(TASK_ADMISSION_OPERATIONS) & set( + without_backend.getGovernedWriteCapabilities()["operation_contracts"] + ) + + handlers = _admission_handlers(FakeOwnerPort(), FakeTaskPort()) + complete = build_bridge_adapter(runtime, task_admission_handlers=handlers) + capabilities = complete.getGovernedWriteCapabilities() + assert set(TASK_ADMISSION_OPERATIONS) <= set(capabilities["operations"]) + expected_routes = { + "getTaskAdmissionEnvelope": ("POST", "/v2/task-admission/envelope"), + "submitTaskAdmissionAuthorization": ("POST", "/v2/task-admission/authorize"), + "getTaskAdmissionStatus": ("GET", "/v2/task-admission/status"), + } + for operation in TASK_ADMISSION_OPERATIONS: + contract = capabilities["operation_contracts"][operation] + assert (contract["method"], contract["path"]) == expected_routes[operation] + assert contract["required_fields"] == list( + TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation] + ) + assert contract["optional_fields"] == list( + TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation] + ) + + +def test_partial_task_admission_handlers_are_rejected(): + runtime = StubRuntime(_status()) + try: + build_bridge_adapter( + runtime, + task_admission_handlers={ + "getTaskAdmissionEnvelope": lambda request: request, + }, + ) + except ValueError as exc: + assert str(exc) == "TASK_ADMISSION_HANDLERS_INCOMPLETE" + else: + raise AssertionError("partial Task Admission handlers must be rejected") + + +def test_http_admission_backend_missing_returns_404(): + adapter = build_bridge_adapter(StubRuntime(_status())) + with _serve(adapter) as base_url: + status, body = _post( + base_url, + "/v2/task-admission/envelope", + {"unexpected": True}, + ) + assert status == 404 + assert body == {"error": "NOT_FOUND"} + + +def test_http_admission_envelope_and_status_work_without_task_principal(): + owner = FakeOwnerPort() + task = FakeTaskPort() + adapter = build_bridge_adapter( + StubRuntime(_status()), + task_admission_handlers=_admission_handlers(owner, task), + ) + envelope = { + "idempotency_key": "idempotency-1", + "task_id": "task-1", + "repository": "aios/aios", + "authority_base_oid": "a" * 40, + "target_branch": "agent/task-1", + "generation": 1, + "execution_digest": "b" * 64, + "requested_capabilities": ["testing"], + "file_allowlist": ["tests/test_task.py"], + "risk": "low", + "expires_in_seconds": 60, + } + with _serve(adapter) as base_url: + status, body = _post(base_url, "/v2/task-admission/envelope", envelope) + assert status == 201 + assert body["envelope_id"] == "env-1" + status, body = _get( + base_url, "/v2/task-admission/status?envelope_id=" + quote("env-1") + ) + assert status == 200 + assert body == {"envelope_id": "env-1", "status": "PENDING"} + assert len(owner.envelopes) == 1 + assert task.calls == [] + + +def test_http_admission_rejects_bad_queries_and_caller_principal(): + owner = FakeOwnerPort() + task = FakeTaskPort() + adapter = build_bridge_adapter( + StubRuntime(_status()), + task_admission_handlers=_admission_handlers(owner, task), + ) + envelope = { + "idempotency_key": "idempotency-1", + "task_id": "task-1", + "repository": "aios/aios", + "authority_base_oid": "a" * 40, + "target_branch": "agent/task-1", + "generation": 1, + "execution_digest": "b" * 64, + "requested_capabilities": ["testing"], + "file_allowlist": ["tests/test_task.py"], + "risk": "low", + } + with _serve(adapter) as base_url: + for query in ( + "unknown=x", + "envelope_id=env-1&envelope_id=env-2", + "envelope_id=", + ): + status, body = _get(base_url, "/v2/task-admission/status?" + query) + assert status == 400 + assert body["error"] == "TASK_ADMISSION_QUERY_INVALID" + + status, body = _post( + base_url, + "/v2/task-admission/envelope", + {**envelope, "principal": {"task_id": "caller-controlled"}}, + ) + assert status == 400 + assert body == {"error": "CALLER_PRINCIPAL_FORBIDDEN"} + assert owner.envelopes == [] + assert task.calls == [] + + +def test_http_admission_proof_failures_use_task_admission_status_and_do_not_mutate(): + owner = FakeOwnerPort() + task = FakeTaskPort() + adapter = build_bridge_adapter( + StubRuntime(_status()), + task_admission_handlers=_admission_handlers(owner, task), + ) + with _serve(adapter) as base_url: + for kind, expected_status, expected_error in ( + ("unsigned", 400, "TASK_ADMISSION_AUTHORIZATION_REQUIRED"), + ("bad_proof", 403, "TASK_ADMISSION_OWNER_SIGNATURE_INVALID"), + ): + status, body = _post( + base_url, + "/v2/task-admission/authorize", + {"envelope_id": "env-1", "authorization_kind": kind}, + ) + assert status == expected_status + assert body == {"error": expected_error} + assert task.calls == [] + + +def test_existing_governed_mutation_gates_are_unchanged(): + request = { + "schema": "aios.writer-lease-request.v2", + "task_id": "task-1", + "job_id": "job-1", + "repository": "aios/aios", + "branch_mode": "CREATE_NEW", + "base_oid": "a" * 40, + "expected_branch_oid": "a" * 40, + "target_branch": "agent/task-1", + "generation": 1, + "idempotency_key": "idempotency-1", + "lease_seconds": 60, + } + runtime = StubRuntime(_status(), {"acquireWriterLease": lambda body: body}) + with _serve(build_bridge_adapter(runtime)) as base_url: + status, body = _post(base_url, "/v2/writer-lease", request) + assert status == 423 + assert body == {"error": "TASK_PRINCIPAL_WRITES_DISABLED"} + + enabled_runtime = StubRuntime( + _status( + governed_writes_enabled=True, + task_principal_writes_enabled=True, + ), + {"acquireWriterLease": lambda body: body}, + ) + with _serve(build_bridge_adapter(enabled_runtime)) as base_url: + status, body = _post(base_url, "/v2/writer-lease", request) + assert status == 401 + assert body == {"error": "TASK_PRINCIPAL_VERIFIER_UNAVAILABLE"} + + +def test_live_bridge_preservation_when_source_root_is_supplied(): + configured_source = os.environ.get("AIOS_LIVE_BRIDGE_SOURCE") + if not configured_source: + return + source = Path(configured_source) + live_server = source / "aios_chatgpt_bridge_v2_server.py" if source.is_dir() else source + candidate_server = APP / "aios_chatgpt_bridge_v2_server.py" + live_tree = ast.parse(live_server.read_text(encoding="utf-8")) + candidate_tree = ast.parse(candidate_server.read_text(encoding="utf-8")) + + def selected(tree): + return [ + node + for node in ast.walk(tree) + if (isinstance(node, ast.ClassDef) and node.name == "GovernedRuntime") + or (isinstance(node, ast.FunctionDef) and node.name == "_verified_request") + ] + + def without_attributes(nodes): + return [ast.dump(node, include_attributes=False) for node in nodes] + + assert without_attributes(selected(candidate_tree)) == without_attributes( + selected(live_tree) + ) diff --git a/tests/test_task_admission_bootstrap_recovery.py b/tests/test_task_admission_bootstrap_recovery.py new file mode 100644 index 0000000..3d7e23c --- /dev/null +++ b/tests/test_task_admission_bootstrap_recovery.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import sys +import threading +import subprocess +from dataclasses import replace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "active-runtime" / "app" +if str(APP) not in sys.path: + sys.path.insert(0, str(APP)) + +from aios_task_admission_bootstrap import ( # noqa: E402 + OwnerDelegationEpoch, + TaskAdmissionBootstrapService, + TaskAdmissionError, + TaskAdmissionPolicy, + TaskAdmissionStore, +) + + +NOW = 1_800_000_000 +CAPS = ("governed.task.create", "agent-task:control") + + +class Authorization: + def __init__(self, digest: str): + self.epoch = OwnerDelegationEpoch( + epoch_id="epoch-recovery", owner_identity="owner", trust_domain="aios.owner", + repository="aios/aios", allowed_branch_prefixes=("agent/recovery-",), + allowed_capabilities=frozenset(CAPS), allowed_file_prefixes=("docs",), + risk_ceiling="medium", issued_at=NOW - 1, not_before=NOW - 1, + expires_at=NOW + 600, revocation_epoch=0, policy_digest=digest, + ) + + def verify_delegation_epoch(self, epoch_id): + if epoch_id != self.epoch.epoch_id: + raise TaskAdmissionError("UNKNOWN_EPOCH", 403) + return self.epoch + + def verify_owner_signature(self, **_kwargs): + return "owner" + + +class RecoveringPort: + def __init__(self, *, fail_after_effect=False, fail_before_effect=False): + self.fail_after_effect = fail_after_effect + self.fail_before_effect = fail_before_effect + self.posts = 0 + self.task_ids = set() + self._lock = threading.Lock() + + def admit(self, grant): + with self._lock: + if grant.task_id in self.task_ids: + return {"task_id": grant.task_id, "state": "QUEUED"} + if self.fail_before_effect: + self.fail_before_effect = False + raise RuntimeError("before effect") + self.posts += 1 + self.task_ids.add(grant.task_id) + if self.fail_after_effect: + self.fail_after_effect = False + raise RuntimeError("after effect") + return {"task_id": grant.task_id, "state": "QUEUED"} + + +def request(): + return { + "idempotency_key": "recovery-admission-key", "task_id": "recovery-task-20260903", + "repository": "aios/aios", "authority_base_oid": "a" * 40, + "target_branch": "agent/recovery-20260903", "generation": 1, + "execution_digest": "b" * 64, "requested_capabilities": list(CAPS), + "file_allowlist": ["docs/recovery.md"], "risk": "medium", "expires_in_seconds": 300, + } + + +def make_service(path, port): + policy = TaskAdmissionPolicy(allowed_capabilities=frozenset(CAPS)) + auth = Authorization(policy.normalized_policy_digest()) + return TaskAdmissionBootstrapService( + store=TaskAdmissionStore(path), authorization=auth, task_port=port, + policy=policy, clock=lambda: NOW, + ), auth + + +@pytest.mark.parametrize("after_effect", [True, False]) +def test_replay_recovers_crash_window_with_one_effect(tmp_path, after_effect): + database = tmp_path / "admission.sqlite3" + port = RecoveringPort( + fail_after_effect=after_effect, fail_before_effect=not after_effect, + ) + first, auth = make_service(database, port) + envelope_id = first.create_envelope(request())["envelope"]["envelope_id"] + proof = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id} + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"): + first.authorize_and_admit(proof) + + second, _ = make_service(database, port) + replay = second.authorize_and_admit(proof) + assert replay["task_id"] == "recovery-task-20260903" + assert port.posts == 1 + + +def test_cross_instance_recovery_lock_allows_one_effect(tmp_path): + database = tmp_path / "admission.sqlite3" + port = RecoveringPort() + first, auth = make_service(database, port) + envelope_id = first.create_envelope(request())["envelope"]["envelope_id"] + proof = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id} + services = [make_service(database, port)[0] for _ in range(16)] + with ThreadPoolExecutor(max_workers=16) as pool: + receipts = list(pool.map(lambda svc: svc.authorize_and_admit(proof), services)) + assert {value["task_id"] for value in receipts} == {"recovery-task-20260903"} + assert port.posts == 1 + + +def test_changed_valid_epoch_proof_conflicts_after_receipt(tmp_path): + service, auth = make_service(tmp_path / "admission.sqlite3", RecoveringPort()) + envelope_id = service.create_envelope(request())["envelope"]["envelope_id"] + first = {"envelope_id": envelope_id, "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id} + service.authorize_and_admit(first) + auth.epoch = OwnerDelegationEpoch( + **{**auth.epoch.as_public_dict(), "epoch_id": "epoch-recovery-reissued", + "allowed_capabilities": auth.epoch.allowed_capabilities, + "allowed_branch_prefixes": auth.epoch.allowed_branch_prefixes, + "allowed_file_prefixes": auth.epoch.allowed_file_prefixes} + ) + changed = {**first, "delegation_epoch_id": auth.epoch.epoch_id} + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_AUTHORIZATION_CONFLICT"): + service.authorize_and_admit(changed) + + +@pytest.mark.parametrize("value", ["docs/a.md", b"docs/a.md", {"docs/a.md": True}, [], ["docs/a\x01.md"], ["docs\\a.md"]]) +def test_file_allowlist_requires_canonical_sequence(tmp_path, value): + service, _ = make_service(tmp_path / "admission.sqlite3", RecoveringPort()) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_(FILE_ALLOWLIST_REJECTED|FILE_PATH_INVALID)"): + service.create_envelope({**request(), "file_allowlist": value}) + + +@pytest.mark.parametrize("branch", ["agent/.hidden-task", "agent/recovery.lock", "agent/recovery@{x", "agent/recovery..task"]) +def test_branch_components_reject_git_ambiguous_spelling(tmp_path, branch): + service, _ = make_service(tmp_path / "admission.sqlite3", RecoveringPort()) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_BRANCH_REJECTED"): + service.create_envelope({**request(), "target_branch": branch}) + + +def test_process_death_releases_admission_lock(tmp_path): + database = tmp_path / "death.sqlite3" + script = ( + "import sys,os;from pathlib import Path;" + "from aios_task_admission_bootstrap import TaskAdmissionStore;" + "s=TaskAdmissionStore(Path(sys.argv[1]));" + "lock=s.admission_lock('death-envelope');lock.__enter__();os._exit(17)" + ) + result = subprocess.run([sys.executable, "-c", script, str(database)], check=False) + assert result.returncode == 17 + with TaskAdmissionStore(database).admission_lock("death-envelope", timeout_seconds=1): + pass + + +def test_persisted_grant_cannot_outlive_its_own_shorter_ttl(tmp_path): + port = RecoveringPort(fail_before_effect=True) + service, auth = make_service(tmp_path / "expiry.sqlite3", port) + service.policy = replace(service.policy, max_task_ttl_seconds=60) + auth.epoch = replace(auth.epoch, policy_digest=service.policy.normalized_policy_digest()) + envelope = service.create_envelope(request())["envelope"] + proof = {"envelope_id": envelope["envelope_id"], "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id} + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"): + service.authorize_and_admit(proof) + service.clock = lambda: NOW + 61 + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_GRANT_EXPIRED"): + service.authorize_and_admit(proof) + assert port.posts == 0 + + +def _adapter_fixture(tmp_path): + from aios_task_admission_live_adapters import AgentTaskAdmissionPort + service, auth = make_service(tmp_path / "adapter.sqlite3", RecoveringPort()) + envelope = service.create_envelope(request())["envelope"] + service.authorize_and_admit({"envelope_id": envelope["envelope_id"], "authorization_kind": "delegation_epoch", "delegation_epoch_id": auth.epoch.epoch_id}) + with service.store._connection() as con: + import json + from aios_task_admission_bootstrap import TaskAdmissionGrant + raw = json.loads(con.execute("SELECT grant_json FROM task_admission_grants").fetchone()[0]) + grant = TaskAdmissionGrant(**{**raw, "capabilities": tuple(raw["capabilities"]), "file_allowlist": tuple(raw["file_allowlist"])}) + plan = {"external_job_id": "fixture-exact-job", "repository": grant.repository, + "target_branch": grant.target_branch, "generation": grant.generation, + "execution_digest": grant.execution_digest, "steps": [{"operation": "fixture-only"}]} + class Transport: + posts = 0 + task = None + error = None + def request(self, method, path, body=None): + if method == "POST": self.posts += 1; raise AssertionError("unexpected create") + if self.error: raise self.error + return self.task + transport = Transport() + port = AgentTaskAdmissionPort(transport, execution_plan_provider=lambda _: plan) + transport.task = {**port._body(grant, plan), "status": "QUEUED"} + return port, transport, grant, plan + + +@pytest.mark.parametrize("field", ["metadata", "instructions", "required_capabilities", "constraints", "deadline", "priority"]) +def test_existing_task_conflict_never_becomes_absence(tmp_path, field): + port, transport, grant, _ = _adapter_fixture(tmp_path) + transport.task[field] = None + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_IDENTITY_CONFLICT"): + port.admit(grant) + assert transport.posts == 0 + + +def test_unknown_get_does_not_create_task(tmp_path): + port, transport, grant, _ = _adapter_fixture(tmp_path) + transport.error = TimeoutError("fixture readback unavailable") + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_TASK_STATE_UNKNOWN"): + port.admit(grant) + assert transport.posts == 0 + + +def test_execution_provider_keeps_full_payload_and_rejects_type_drift(tmp_path): + port, transport, grant, plan = _adapter_fixture(tmp_path) + assert port._execution_plan(grant) == plan + plan["generation"] = True + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EXECUTION_PLAN_CONFLICT"): + port.admit(grant) + assert transport.posts == 0 + + +def test_signed_epoch_host_provider_reverifies_signature_and_policy(tmp_path): + import hashlib + import json + import time + from aios_task_admission_bootstrap import canonical_json_bytes + from aios_task_admission_live_adapters import ( + SignedEpochDirectoryProvider, + task_admission_policy_from_epoch, + ) + + class Verifier: + def __init__(self): + self.calls = [] + + def verify(self, **kwargs): + self.calls.append(kwargs) + return True + + now = int(time.time()) + epoch_value = { + "schema": "aios.owner-delegation-epoch.v1", + "epoch_id": "owner-epoch-host-test-20260903", + "owner_identity": "aios-task-grant-signer", + "trust_domain": "aios.owner", + "repository": "aios/aios", + "allowed_branch_prefixes": ["agent/p0-test"], + "allowed_capabilities": ["git.read", "governed.write.file"], + "allowed_file_prefixes": ["active-runtime/app/example.py"], + "risk_ceiling": "medium", + "issued_at": now - 1, + "not_before": now - 1, + "expires_at": now + 600, + "revocation_epoch": 0, + "policy_digest": "a" * 64, + } + root = tmp_path / "epoch" + root.mkdir(mode=0o700) + (root / "owner-delegation-epoch.canonical.json").write_bytes( + canonical_json_bytes(epoch_value) + ) + (root / "owner-delegation-epoch.sig").write_text( + "-----BEGIN SSH SIGNATURE-----\nabc\n-----END SSH SIGNATURE-----\n", + encoding="utf-8", + ) + (root / "owner-delegation-epoch.key-id").write_text( + "SHA256:test-key\n", + encoding="utf-8", + ) + revocations = tmp_path / "revocations.json" + revocations.write_bytes( + canonical_json_bytes({ + "schema": "aios.owner-delegation-revocations.v1", + "revoked_epoch_ids": [], + }) + ) + for path in ( + root / "owner-delegation-epoch.canonical.json", + root / "owner-delegation-epoch.sig", + root / "owner-delegation-epoch.key-id", + revocations, + ): + path.chmod(0o600) + + verifier = Verifier() + caller = {"service": "test", "trust_domain": "aios.owner"} + provider = SignedEpochDirectoryProvider( + root=root, + verifier=verifier, + revocation_file=revocations, + expected_caller=caller, + require_root_owner=False, + clock=lambda: now, + ) + epoch = provider(epoch_id=epoch_value["epoch_id"], caller=caller) + policy = task_admission_policy_from_epoch(epoch) + assert verifier.calls[0]["namespace"] == "aios-owner-approval-v1" + assert verifier.calls[0]["payload"] == canonical_json_bytes(epoch_value) + assert policy.allowed_capabilities == frozenset( + epoch_value["allowed_capabilities"] + ) + assert policy.policy_digest == epoch_value["policy_digest"] + + +def test_signed_epoch_host_provider_rejects_revocation_and_wrong_caller(tmp_path): + import time + from aios_task_admission_bootstrap import canonical_json_bytes + from aios_task_admission_live_adapters import SignedEpochDirectoryProvider + + class Verifier: + def verify(self, **_kwargs): + return True + + now = int(time.time()) + epoch_id = "owner-epoch-host-revoked-20260903" + root = tmp_path / "epoch" + root.mkdir(mode=0o700) + value = { + "schema": "aios.owner-delegation-epoch.v1", + "epoch_id": epoch_id, + "owner_identity": "aios-task-grant-signer", + "trust_domain": "aios.owner", + "repository": "aios/aios", + "allowed_branch_prefixes": ["agent/p0-test"], + "allowed_capabilities": ["git.read"], + "allowed_file_prefixes": ["docs/test.md"], + "risk_ceiling": "medium", + "issued_at": now - 1, + "not_before": now - 1, + "expires_at": now + 600, + "revocation_epoch": 0, + "policy_digest": "b" * 64, + } + (root / "owner-delegation-epoch.canonical.json").write_bytes( + canonical_json_bytes(value) + ) + (root / "owner-delegation-epoch.sig").write_text( + "sig", encoding="utf-8" + ) + (root / "owner-delegation-epoch.key-id").write_text( + "SHA256:test-key", encoding="utf-8" + ) + revocations = tmp_path / "revocations.json" + revocations.write_bytes( + canonical_json_bytes({ + "schema": "aios.owner-delegation-revocations.v1", + "revoked_epoch_ids": [epoch_id], + }) + ) + for path in list(root.iterdir()) + [revocations]: + path.chmod(0o600) + + caller = {"service": "test", "trust_domain": "aios.owner"} + provider = SignedEpochDirectoryProvider( + root=root, + verifier=Verifier(), + revocation_file=revocations, + expected_caller=caller, + require_root_owner=False, + clock=lambda: now, + ) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_CALLER_BINDING_MISMATCH"): + provider(epoch_id=epoch_id, caller={"service": "wrong"}) + with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_REVOKED"): + provider(epoch_id=epoch_id, caller=caller) + + +def test_execution_plan_host_provider_is_content_addressed(tmp_path): + import hashlib + from types import SimpleNamespace + from aios_task_admission_bootstrap import canonical_json_bytes + from aios_task_admission_live_adapters import ExecutionPlanDirectoryProvider + + plan = { + "task_id": "p0-task-admission-bootstrap-20260903", + "repository": "aios/aios", + "target_branch": "agent/p0-task-admission-bootstrap-repair-20260903", + "generation": 1, + "external_job_id": "job-host-test", + "files": [], + } + digest = hashlib.sha256(canonical_json_bytes(plan)).hexdigest() + root = tmp_path / "plans" + root.mkdir(mode=0o700) + path = root / (digest + ".json") + path.write_bytes(canonical_json_bytes(plan)) + path.chmod(0o600) + grant = SimpleNamespace( + execution_digest=digest, + task_id=plan["task_id"], + repository=plan["repository"], + target_branch=plan["target_branch"], + generation=1, + ) + provider = ExecutionPlanDirectoryProvider( + root=root, + require_root_owner=False, + ) + value = provider(grant) + assert value["execution_digest"] == digest + assert value["external_job_id"] == "job-host-test" + + +def test_bridge_persistent_consumer_is_opt_in_and_default_fail_closed(): + source = ( + ROOT / "active-runtime" / "app" / "aios_chatgpt_bridge_v2_server.py" + ).read_text(encoding="utf-8") + assert 'os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip()' in source + assert "build_task_admission_handlers_from_environment" in source + assert "task_admission_handlers is None" in source + + +def test_bridge_persistent_host_is_opt_in_and_activation_file_is_fail_closed(): + text = (APP / "aios_chatgpt_bridge_v2_server.py").read_text(encoding="utf-8") + assert 'AIOS_TASK_ADMISSION_EPOCH_ROOT' in text + assert 'AIOS_TASK_ADMISSION_ACTIVATION_FILE' in text + assert '/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json' in text + assert 'Path(activation_raw).is_file()' in text + assert 'build_task_admission_handlers_from_activation_file' in text + + +def test_host_default_state_database_stays_inside_existing_bridge_state_root(): + text = (APP / "aios_task_admission_live_adapters.py").read_text(encoding="utf-8") + assert '/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3' in text + assert '/var/lib/aios-task-admission/state.sqlite3' not in text + +def test_host_posix_permission_gate_is_skipped_when_posix_metadata_is_not_authoritative( + tmp_path, + monkeypatch, +): + import aios_task_admission_live_adapters as live + + root = tmp_path / "non-posix-mode" + root.mkdir() + target = root / "value.json" + target.write_text("{}", encoding="utf-8") + + # Deliberately make the synthesized mode permissive. On Windows this is + # normal and must not be interpreted as a POSIX trust failure. + root.chmod(0o777) + target.chmod(0o666) + + monkeypatch.setattr(live, "_host_posix_metadata_enforced", lambda: False) + live._host_require_directory(root, root_owned=True) + live._host_require_regular(target, root_owned=True) + + +@pytest.mark.skipif( + __import__("os").name != "posix", + reason="POSIX mode-bit enforcement is a Linux/Unix trust boundary", +) +def test_host_posix_permission_gate_rejects_group_or_other_writable(tmp_path): + import aios_task_admission_live_adapters as live + + root = tmp_path / "posix-mode" + root.mkdir(mode=0o700) + root.chmod(0o777) + with pytest.raises( + live.TaskAdmissionHostError, + match="SECURE_DIRECTORY_WRITABLE_BY_GROUP_OR_OTHER", + ): + live._host_require_directory(root, root_owned=False)