feat(governed): close P0 task admission bootstrap
This commit is contained in:
@@ -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 {})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user