1024 lines
41 KiB
Python
1024 lines
41 KiB
Python
"""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 get_grant_by_id(self, grant_id: str) -> TaskAdmissionGrant | None:
|
|
"""Return one immutable admitted grant by its stable grant identity.
|
|
|
|
This is a read-only lookup used by the server-side TaskPrincipal
|
|
recovery path. It never trusts caller-supplied grant content.
|
|
"""
|
|
if not isinstance(grant_id, str) or not grant_id:
|
|
raise TaskAdmissionError("TASK_ADMISSION_GRANT_ID_INVALID", 400)
|
|
with self._connection() as con:
|
|
row = con.execute(
|
|
"SELECT grant_json FROM task_admission_grants WHERE grant_id=?",
|
|
(grant_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
try:
|
|
raw = json.loads(row["grant_json"])
|
|
return TaskAdmissionGrant(
|
|
**{
|
|
**raw,
|
|
"capabilities": tuple(raw["capabilities"]),
|
|
"file_allowlist": tuple(raw["file_allowlist"]),
|
|
}
|
|
)
|
|
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
raise TaskAdmissionError(
|
|
"TASK_ADMISSION_DURABLE_GRANT_INVALID", 503
|
|
) from exc
|
|
|
|
def get_grant_by_task_id(self, task_id: str) -> TaskAdmissionGrant | None:
|
|
"""Return the immutable admitted grant bound to an authoritative Task."""
|
|
if not isinstance(task_id, str) or not task_id:
|
|
raise TaskAdmissionError("TASK_ADMISSION_TASK_ID_INVALID", 400)
|
|
with self._connection() as con:
|
|
row = con.execute(
|
|
"""
|
|
SELECT g.grant_json
|
|
FROM task_admission_grants AS g
|
|
JOIN task_admission_receipts AS r
|
|
ON r.envelope_id = g.envelope_id
|
|
WHERE r.task_id = ?
|
|
""",
|
|
(task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
try:
|
|
raw = json.loads(row["grant_json"])
|
|
grant = TaskAdmissionGrant(
|
|
**{
|
|
**raw,
|
|
"capabilities": tuple(raw["capabilities"]),
|
|
"file_allowlist": tuple(raw["file_allowlist"]),
|
|
}
|
|
)
|
|
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
raise TaskAdmissionError(
|
|
"TASK_ADMISSION_DURABLE_GRANT_INVALID", 503
|
|
) from exc
|
|
if grant.task_id != task_id:
|
|
raise TaskAdmissionError(
|
|
"TASK_ADMISSION_DURABLE_GRANT_TASK_MISMATCH", 503
|
|
)
|
|
return grant
|
|
|
|
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,
|
|
# V2R2C2 TTL contract: the admission envelope is a *signing
|
|
# window* only. Once authorization succeeds (epoch verified or
|
|
# Owner signature verified) the envelope is consumed, and the
|
|
# derived grant's lifecycle is bounded by the task/grant policy
|
|
# ceiling (max_task_ttl_seconds) and, for a delegation epoch,
|
|
# by the parent epoch's own expiry. It must NOT be truncated by
|
|
# the already-consumed envelope's remaining window, otherwise a
|
|
# nominal 3600s policy collapses to the ~900s envelope window
|
|
# (dual-semantic TTL defect). The envelope's temporal validity
|
|
# still gates authorization (now < envelope.expires_at above).
|
|
expires_at=(
|
|
now + self.policy.max_task_ttl_seconds
|
|
if parent_epoch is None
|
|
else min(
|
|
now + self.policy.max_task_ttl_seconds,
|
|
parent_epoch.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:
|
|
projection_result: Mapping[str, Any] = result
|
|
projection_reader = getattr(self.task_port, "projection_result", None)
|
|
if callable(projection_reader):
|
|
try:
|
|
candidate = projection_reader(persisted_grant, result)
|
|
except TaskAdmissionError:
|
|
raise
|
|
except Exception as exc:
|
|
raise TaskAdmissionError(
|
|
"TASK_ADMISSION_EXECUTION_PROJECTION_FAILED", 503
|
|
) from exc
|
|
if not isinstance(candidate, Mapping):
|
|
raise TaskAdmissionError(
|
|
"TASK_ADMISSION_EXECUTION_PROJECTION_INVALID", 503
|
|
)
|
|
projection_result = candidate
|
|
try:
|
|
projection = self.post_admission(
|
|
persisted_grant, projection_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)
|