Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbccf48a58 | ||
|
|
540e28a724 | ||
|
|
e33664f033 | ||
|
|
83b77a53d2 | ||
|
|
30fff31145 | ||
|
|
765f337b82 | ||
|
|
034a07a05e | ||
|
|
5f65e45895 | ||
|
|
d841948c21 | ||
|
|
5d821096ce | ||
|
|
e87c70778a | ||
|
|
396ea98111 | ||
|
|
a6851c0a7e | ||
|
|
7b0d387221 | ||
|
|
faf6eb2289 | ||
|
|
d54f832668 | ||
|
|
b915386ccb | ||
|
|
2af1dfbc40 | ||
|
|
3b36619487 | ||
|
|
3e432fe42c | ||
|
|
7dbe51de39 | ||
|
|
b707f8a05f | ||
|
|
83c41c8f2e | ||
|
|
a96901ce52 | ||
|
|
084fbc9930 | ||
|
|
581e2d3c0c | ||
|
|
7ffe99a0b5 | ||
|
|
6582e8a5e1 | ||
|
|
8fd457f8f5 | ||
|
|
4e66d483e4 | ||
|
|
a887646377 | ||
|
|
1227714ce3 | ||
|
|
757de6b8ed | ||
|
|
eff070bc37 | ||
|
|
3b1c3a1b60 | ||
|
|
e55392184d | ||
|
|
7e0acaae4c | ||
|
|
3bd736db7f | ||
|
|
7022801a2d | ||
|
|
f9253dd28b | ||
|
|
c24ed5ee8e | ||
|
|
db18ca306d | ||
|
|
8f3bd0d11f | ||
|
|
7a64e326c2 | ||
|
|
3609f45d69 | ||
|
|
41df96a46a | ||
|
|
4c17ce3c98 | ||
|
|
5d0f9970f6 | ||
|
|
7837f67119 | ||
|
|
4a5e3cbfec | ||
|
|
64eed3b119 | ||
|
|
cd0a90bc37 | ||
|
|
6c1dcb829a | ||
|
|
fb8a0f3e47 | ||
|
|
d2ad00139d | ||
|
|
b5c27fec1d | ||
|
|
e43209ebdd | ||
|
|
ab149d0a57 | ||
|
|
dbefdcda07 | ||
|
|
536a2af0f0 | ||
|
|
7cc1730025 | ||
|
|
30f501c77e | ||
|
|
58987681fe | ||
|
|
440f1aedc9 | ||
|
|
9e7024ba63 |
@@ -1,40 +1,79 @@
|
||||
"""API adapter for agent context routing and visibility."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
APIRouter = HTTPException = None
|
||||
|
||||
from .agent_context_dashboard import AgentContextDashboard
|
||||
from .agent_context_rbac import AgentContextRBAC
|
||||
from .agent_context_resolver import AgentContextResolver
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
resolver = AgentContextResolver()
|
||||
rbac = AgentContextRBAC()
|
||||
dashboard = AgentContextDashboard()
|
||||
_AUTHORITY_KEYS = frozenset({"user_id", "group_id", "role", "owner", "owner_policy", "group_policy", "agent_default"})
|
||||
|
||||
|
||||
def resolve_agent_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
|
||||
principal = authenticated_principal()
|
||||
user_id = principal["user_id"]
|
||||
group_id = principal.get("group_id")
|
||||
role = principal["role"]
|
||||
if role == "group_admin" and (not isinstance(group_id, str) or not group_id):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
route = rbac.route(user_id, group_id, role)
|
||||
|
||||
def server_policy(name: str) -> dict[str, Any] | None:
|
||||
value = principal.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_POLICY")
|
||||
return dict(value)
|
||||
|
||||
def client_overlay(name: str) -> dict[str, Any] | None:
|
||||
value = payload.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_CONTEXT_OVERLAY")
|
||||
return {key: item for key, item in value.items() if key not in _AUTHORITY_KEYS}
|
||||
|
||||
defaults = server_policy("agent_default") or {}
|
||||
defaults.update(user_id=user_id, group_id=group_id or "", role=role)
|
||||
|
||||
context = resolver.resolve(
|
||||
user_id=payload["user_id"],
|
||||
group_id=payload.get("group_id", ""),
|
||||
session=payload.get("session"),
|
||||
user_profile=payload.get("user_profile"),
|
||||
group_policy=payload.get("group_policy"),
|
||||
agent_default=payload.get("agent_default"),
|
||||
owner_policy=payload.get("owner_policy"),
|
||||
owner=payload.get("role") == "owner",
|
||||
user_id=user_id,
|
||||
group_id=group_id or "",
|
||||
session=client_overlay("session"),
|
||||
user_profile=client_overlay("user_profile"),
|
||||
group_policy=server_policy("group_policy"),
|
||||
agent_default=defaults,
|
||||
owner_policy=server_policy("owner_policy") if role == "owner" else None,
|
||||
owner=role == "owner",
|
||||
)
|
||||
return {"routing": route.to_dict(), "context": context.to_dict()}
|
||||
|
||||
|
||||
def get_context_dashboard() -> dict[str, Any]:
|
||||
authenticated_principal()
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def _http(call):
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -42,15 +81,15 @@ def build_router():
|
||||
|
||||
@router.post("/resolve", operation_id="resolveAgentContext")
|
||||
def resolve(payload: dict[str, Any]):
|
||||
return resolve_agent_context(payload)
|
||||
return _http(lambda: resolve_agent_context(payload))
|
||||
|
||||
@router.get("/overview", operation_id="getAgentContextDashboard")
|
||||
def overview():
|
||||
return get_context_dashboard()
|
||||
return _http(get_context_dashboard)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
|
||||
__all__ = ["router", "set_authenticator", "resolve_agent_context", "get_context_dashboard"]
|
||||
|
||||
@@ -1,47 +1,129 @@
|
||||
"""Actions API for unified Agent Event Bus."""
|
||||
|
||||
"""Authenticated loopback API for the unified Agent Event Bus."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = Request = None
|
||||
|
||||
from .agent_event_bus import AgentEventBus
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
bus = AgentEventBus()
|
||||
_CREATE_FIELDS = frozenset({"kind", "source", "target", "summary", "payload", "correlation_id"})
|
||||
|
||||
|
||||
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.publish(**payload)}
|
||||
def _authorize(client_host: str | None) -> dict[str, Any]:
|
||||
try:
|
||||
address = ipaddress.ip_address(client_host or "")
|
||||
except ValueError as exc:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED") from exc
|
||||
if not address.is_loopback:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED")
|
||||
return authenticated_principal()
|
||||
|
||||
|
||||
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
|
||||
return {"status": "ok", "events": bus.list_events(kind, status)}
|
||||
def _event_scope(principal: Mapping[str, Any]) -> tuple[str, frozenset[str]]:
|
||||
tenant_id = principal.get("tenant_id")
|
||||
targets = principal.get("allowed_event_targets")
|
||||
if not isinstance(tenant_id, str) or not tenant_id:
|
||||
raise ApiSecurityError(403, "EVENT_TENANT_SCOPE_REQUIRED")
|
||||
if (not isinstance(targets, (list, tuple, set, frozenset)) or not targets
|
||||
or any(not isinstance(target, str) or not target for target in targets)):
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_REQUIRED")
|
||||
return tenant_id, frozenset(targets)
|
||||
|
||||
|
||||
def get_agent_event(event_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.get(event_id)}
|
||||
def _authorized_event(
|
||||
event: Mapping[str, Any] | None, tenant_id: str, targets: frozenset[str],
|
||||
) -> Mapping[str, Any] | None:
|
||||
if event is None:
|
||||
return None
|
||||
if event.get("tenant_id") != tenant_id or event.get("target") not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_SCOPE_DENIED")
|
||||
return event
|
||||
|
||||
|
||||
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
|
||||
def create_agent_event(payload: Mapping[str, Any], *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
if set(payload) - _CREATE_FIELDS:
|
||||
raise ApiSecurityError(403, "EVENT_FIELDS_FORBIDDEN")
|
||||
required = ("kind", "target", "summary", "payload")
|
||||
if any(name not in payload for name in required):
|
||||
raise ApiSecurityError(403, "INVALID_EVENT")
|
||||
normalized = {name: payload[name] for name in required}
|
||||
if not isinstance(normalized["target"], str) or normalized["target"] not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_DENIED")
|
||||
normalized["source"] = principal["user_id"]
|
||||
normalized["correlation_id"] = payload.get("correlation_id")
|
||||
normalized["tenant_id"] = tenant_id
|
||||
return {"status": "ok", "event": bus.publish(**normalized)}
|
||||
|
||||
|
||||
def agent_event_stats() -> dict[str, Any]:
|
||||
return {"status": "ok", "stats": bus.stats()}
|
||||
def list_agent_events(
|
||||
kind: str | None = None, status: str = "pending", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "events": bus.list_events(
|
||||
kind, status, tenant_id=tenant_id, targets=targets,
|
||||
)}
|
||||
|
||||
|
||||
# Compatibility adapters
|
||||
|
||||
def list_gpt_inbox_messages():
|
||||
return list_agent_events(kind="gpt_inbox")
|
||||
def get_agent_event(event_id: str, *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "event": _authorized_event(bus.get(event_id), tenant_id, targets)}
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str):
|
||||
return transition_agent_event(event_id, "completed", "custom-gpt")
|
||||
def transition_agent_event(
|
||||
event_id: str, status: str, actor: str = "system", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del actor
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
_authorized_event(bus.get(event_id), tenant_id, targets)
|
||||
return {"status": "ok", "event": bus.transition(
|
||||
event_id, status, principal["user_id"], expected_tenant_id=tenant_id,
|
||||
expected_targets=targets,
|
||||
)}
|
||||
|
||||
|
||||
def agent_event_stats(*, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "stats": bus.stats(tenant_id=tenant_id, targets=targets)}
|
||||
|
||||
|
||||
def list_gpt_inbox_messages(*, client_host: str | None = None):
|
||||
return list_agent_events(kind="gpt_inbox", client_host=client_host)
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str, *, client_host: str | None = None):
|
||||
return transition_agent_event(event_id, "completed", client_host=client_host)
|
||||
|
||||
|
||||
def _host(request: Any) -> str | None:
|
||||
client = getattr(request, "client", None)
|
||||
return getattr(client, "host", None)
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -50,28 +132,32 @@ def build_router():
|
||||
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
|
||||
|
||||
@router.post("", operation_id="createAgentEvent")
|
||||
def create(payload: dict[str, Any]):
|
||||
return create_agent_event(payload)
|
||||
def create(payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: create_agent_event(payload, client_host=_host(request)))
|
||||
|
||||
@router.get("", operation_id="listAgentEvents")
|
||||
def listing(kind: str | None = None, status: str = "pending"):
|
||||
return list_agent_events(kind, status)
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str):
|
||||
return get_agent_event(event_id)
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any]):
|
||||
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
|
||||
def listing(request: Request, kind: str | None = None, status: str = "pending"):
|
||||
return _http(lambda: list_agent_events(kind, status, client_host=_host(request)))
|
||||
|
||||
@router.get("/stats/overview", operation_id="getAgentEventStats")
|
||||
def stats():
|
||||
return agent_event_stats()
|
||||
def stats(request: Request):
|
||||
return _http(lambda: agent_event_stats(client_host=_host(request)))
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str, request: Request):
|
||||
return _http(lambda: get_agent_event(event_id, client_host=_host(request)))
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: transition_agent_event(
|
||||
event_id, payload.get("status", ""), payload.get("actor", "system"),
|
||||
client_host=_host(request),
|
||||
))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
|
||||
__all__ = [
|
||||
"router", "set_authenticator", "create_agent_event", "list_agent_events",
|
||||
"get_agent_event", "transition_agent_event", "agent_event_stats",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Unified Agent Event Bus.
|
||||
|
||||
Unifies commands, GPT messages, replies, approvals, audit and reports into one
|
||||
state machine. Compatibility layers can filter events by kind instead of keeping
|
||||
separate queues.
|
||||
"""
|
||||
|
||||
"""Filesystem-backed Agent Event Bus with strict path contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
@@ -12,8 +6,12 @@ from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from .security_path_guards import contained_path, safe_segment, write_text_no_follow
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -27,6 +25,7 @@ class AgentEvent:
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
status: str = "pending"
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
@@ -39,87 +38,223 @@ class AgentEvent:
|
||||
|
||||
class AgentEventBus:
|
||||
STATES = ("pending", "running", "completed", "failed")
|
||||
_DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
|
||||
def __init__(self, root: str = "agents/events"):
|
||||
self.root = Path(root)
|
||||
for state in self.STATES:
|
||||
(self.root / state).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "audit").mkdir(parents=True, exist_ok=True)
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("symlink event root forbidden")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
self, kind: str, source: str, target: str, summary: str,
|
||||
payload: dict[str, Any], correlation_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
if not all(isinstance(value, str) and value for value in (kind, source, target, summary)):
|
||||
raise ValueError("event text fields must be nonempty strings")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("event payload must be an object")
|
||||
if tenant_id is not None and (not isinstance(tenant_id, str) or not tenant_id):
|
||||
raise ValueError("invalid event tenant")
|
||||
identity = json.dumps(
|
||||
{"kind": kind, "source": source, "target": target, "summary": summary,
|
||||
"payload": payload, "correlation_id": correlation_id, "tenant_id": tenant_id},
|
||||
sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":"),
|
||||
)
|
||||
event_id = "evt_" + sha256(identity.encode("utf-8")).hexdigest()[:16]
|
||||
safe_segment(event_id)
|
||||
event = AgentEvent(
|
||||
event_id, kind, source, target, summary, payload,
|
||||
tenant_id=tenant_id, correlation_id=correlation_id,
|
||||
)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
return event.to_dict()
|
||||
|
||||
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted((self.root / status).glob("*.json")):
|
||||
event = self._read(path)
|
||||
def list_events(
|
||||
self, kind: str | None = None, status: str = "pending", limit: int = 100,
|
||||
*, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._state(status)
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0 or limit > 1000:
|
||||
raise ValueError("invalid event limit")
|
||||
if limit == 0:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for name in sorted(self._names(status)):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
event_id = safe_segment(name[:-5])
|
||||
event = self._read(status, event_id)
|
||||
if kind and event.get("kind") != kind:
|
||||
continue
|
||||
if tenant_id is not None and event.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
if targets is not None and event.get("target") not in targets:
|
||||
continue
|
||||
result.append(event)
|
||||
return result[:limit]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def get(self, event_id: str) -> dict[str, Any] | None:
|
||||
safe_segment(event_id)
|
||||
for state in self.STATES:
|
||||
path = self.root / state / f"{event_id}.json"
|
||||
if path.exists():
|
||||
return self._read(path)
|
||||
if f"{event_id}.json" in self._names(state):
|
||||
return self._read(state, event_id)
|
||||
return None
|
||||
|
||||
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
|
||||
def transition(
|
||||
self, event_id: str, status: str, actor: str | None = None,
|
||||
*, expected_tenant_id: str | None = None,
|
||||
expected_targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
self._state(status) # validate before any lookup or mutation
|
||||
event = self.get(event_id)
|
||||
if not event:
|
||||
return {"status": "not_found", "id": event_id}
|
||||
if status not in self.STATES:
|
||||
return {"status": "invalid_state"}
|
||||
old = event.get("status", "pending")
|
||||
old_path = self.root / old / f"{event_id}.json"
|
||||
event["status"] = status
|
||||
event["actor"] = actor
|
||||
event["updated_at"] = utc_now()
|
||||
if expected_tenant_id is not None and event.get("tenant_id") != expected_tenant_id:
|
||||
raise ValueError("event tenant mismatch")
|
||||
if expected_targets is not None and event.get("target") not in expected_targets:
|
||||
raise ValueError("event target mismatch")
|
||||
old = event.get("status")
|
||||
self._state(old)
|
||||
event.update(status=status, actor=actor, updated_at=utc_now())
|
||||
self._write(status, event)
|
||||
if old_path.exists() and old != status:
|
||||
old_path.unlink()
|
||||
if old != status:
|
||||
self._unlink(old, event_id)
|
||||
self._audit("transition", event, {"from": old, "to": status})
|
||||
return event
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
def stats(
|
||||
self, *, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if tenant_id is not None:
|
||||
events = {
|
||||
state: len(self.list_events(status=state, limit=1000, tenant_id=tenant_id, targets=targets))
|
||||
for state in self.STATES
|
||||
}
|
||||
audit_events = 0
|
||||
for name in self._names("audit"):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
audit = self._read("audit", safe_segment(name[:-5]))
|
||||
event = audit.get("event") if isinstance(audit, dict) else None
|
||||
if (isinstance(event, dict) and event.get("tenant_id") == tenant_id
|
||||
and (targets is None or event.get("target") in targets)):
|
||||
audit_events += 1
|
||||
return {"events": events, "audit_events": audit_events, "status": "healthy"}
|
||||
return {
|
||||
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
|
||||
"audit_events": len(list((self.root / "audit").glob("*.json"))),
|
||||
"events": {state: sum(name.endswith(".json") for name in self._names(state)) for state in self.STATES},
|
||||
"audit_events": sum(name.endswith(".json") for name in self._names("audit")),
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]):
|
||||
(self.root / state / f"{data['id']}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
def _state(self, state: Any) -> str:
|
||||
if state not in self.STATES:
|
||||
raise ValueError("invalid event state")
|
||||
return str(state)
|
||||
|
||||
def _ensure_root(self) -> None:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("invalid event root")
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]) -> None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not isinstance(data.get("id"), str):
|
||||
raise ValueError("event id must be a string")
|
||||
event_id = safe_segment(data["id"])
|
||||
self._ensure_root()
|
||||
write_text_no_follow(
|
||||
self.root, f"{state}/{event_id}.json",
|
||||
json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
|
||||
)
|
||||
|
||||
def _read(self, path: Path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
def _open_state(self, state: str, *, create: bool = False) -> tuple[int, int] | None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not self.root.exists():
|
||||
if not create:
|
||||
return None
|
||||
self._ensure_root()
|
||||
contained_path(self.root, state)
|
||||
root_fd = os.open(self.root, self._DIR_FLAGS)
|
||||
try:
|
||||
try:
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
os.close(root_fd)
|
||||
return None
|
||||
os.mkdir(state, mode=0o750, dir_fd=root_fd)
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
return root_fd, state_fd
|
||||
except Exception:
|
||||
try:
|
||||
os.close(root_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
|
||||
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
|
||||
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
def _names(self, state: str) -> set[str]:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
if not self.root.exists():
|
||||
return set()
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return set()
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
return set(os.listdir(state_fd))
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _read(self, state: str, event_id: str) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
raise FileNotFoundError(event_id)
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
fd = os.open(f"{event_id}.json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=state_fd)
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
os.close(fd)
|
||||
raise ValueError("unsafe event file")
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
||||
value = json.load(stream)
|
||||
if (not isinstance(value, dict) or value.get("id") != event_id
|
||||
or (state != "audit" and value.get("status") != state)):
|
||||
raise ValueError("invalid event record")
|
||||
return value
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _unlink(self, state: str, event_id: str) -> None:
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
info = os.stat(f"{event_id}.json", dir_fd=state_fd, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("unsafe event file")
|
||||
os.unlink(f"{event_id}.json", dir_fd=state_fd)
|
||||
os.fsync(state_fd)
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None) -> None:
|
||||
safe_segment(action)
|
||||
audit = {"id": f"{safe_segment(event['id'])}-{action}", "action": action,
|
||||
"event": event, "extra": extra or {}, "time": utc_now()}
|
||||
self._write("audit", audit)
|
||||
|
||||
|
||||
__all__ = ["AgentEvent", "AgentEventBus"]
|
||||
|
||||
@@ -1,77 +1,178 @@
|
||||
"""API for agent orchestration observability."""
|
||||
|
||||
"""Authenticated API for agent orchestration observability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
||||
store: AgentObservabilityStore | None = None
|
||||
dashboard = AgentObservabilityDashboard()
|
||||
_ROLE_WHITELIST = frozenset({"user", "group_admin", "owner"})
|
||||
_RECORD_FIELDS = frozenset(AgentExecutionRecord.__dataclass_fields__)
|
||||
_authenticator: Callable[[], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the querying tenant.
|
||||
class ApiSecurityError(PermissionError):
|
||||
def __init__(self, status_code: int, code: str):
|
||||
super().__init__(code)
|
||||
self.status_code, self.code = status_code, code
|
||||
|
||||
The callable returns the authenticated principal (dict with "user_id" and
|
||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
||||
user/group ids are NOT trusted to escalate scope.
|
||||
"""
|
||||
|
||||
def _storage() -> AgentObservabilityStore:
|
||||
global store
|
||||
if store is None:
|
||||
store = AgentObservabilityStore()
|
||||
return store
|
||||
|
||||
|
||||
def set_authenticator(fn: Callable[[], Mapping[str, Any] | None] | None) -> None:
|
||||
"""Install the host authenticator; its result is fetched for every call."""
|
||||
if fn is not None and not callable(fn):
|
||||
raise TypeError("authenticator must be callable or None")
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def _principal():
|
||||
if _authenticator is not None:
|
||||
p = _authenticator()
|
||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
||||
return p
|
||||
return None
|
||||
def _group_id(principal: Mapping[str, Any]) -> str | None:
|
||||
value = principal.get("group_id")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
def authenticated_principal() -> dict[str, Any]:
|
||||
"""Return a request-scoped copy of the server principal, or fail closed."""
|
||||
if _authenticator is None:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
try:
|
||||
raw = _authenticator()
|
||||
except ApiSecurityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_FAILED") from exc
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
principal = dict(raw)
|
||||
user_id, role = principal.get("user_id"), principal.get("role")
|
||||
if not isinstance(user_id, str) or not user_id.strip() or role not in _ROLE_WHITELIST:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
principal["user_id"] = user_id.strip()
|
||||
if principal.get("group_id") is not None and _group_id(principal) is None:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
if role == "group_admin" and not _group_id(principal):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
return principal
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
# The authenticated principal (tenant) is authoritative; the request params
|
||||
# role/user_id/group_id can never choose owner or another tenant.
|
||||
principal = _principal()
|
||||
pr_user = principal.get("user_id") if principal else None
|
||||
pr_role = principal.get("role", "user") if principal else "user"
|
||||
def _scope_set(principal: Mapping[str, Any], name: str) -> frozenset[str]:
|
||||
raw = principal.get(name, ())
|
||||
if not isinstance(raw, (list, tuple, set, frozenset)) or any(
|
||||
not isinstance(item, str) or not item for item in raw
|
||||
):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_SCOPE")
|
||||
return frozenset(raw)
|
||||
|
||||
if pr_role == "owner":
|
||||
# Server-verified owner may browse a requested scope.
|
||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
||||
summary_scope = None
|
||||
elif pr_user:
|
||||
# Authenticated non-owner: strictly scoped to own tenant.
|
||||
q_user, q_group, q_role = pr_user, None, "user"
|
||||
summary_scope = pr_user
|
||||
else:
|
||||
# Anonymous: nothing scoped to them.
|
||||
q_user, q_group, q_role = None, None, "user"
|
||||
summary_scope = None
|
||||
|
||||
records = store.query(q_user, q_group, q_role)
|
||||
def _owner_user(principal: Mapping[str, Any], requested: str | None) -> str:
|
||||
own, target = str(principal["user_id"]), requested or str(principal["user_id"])
|
||||
if target != own and target not in _scope_set(principal, "allowed_user_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_USER_SCOPE_DENIED")
|
||||
return target
|
||||
|
||||
|
||||
def _owner_group(principal: Mapping[str, Any], requested: str | None) -> str | None:
|
||||
if requested is None:
|
||||
return None
|
||||
if requested != _group_id(principal) and requested not in _scope_set(principal, "allowed_group_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_GROUP_SCOPE_DENIED")
|
||||
return requested
|
||||
|
||||
|
||||
def _summary(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
count = len(records)
|
||||
hermes = sum(1 for row in records if row.get("used_hermes"))
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(summary_scope),
|
||||
"total_executions": count,
|
||||
"openclaw_independent_loops": count - hermes,
|
||||
"hermes_orchestrated_loops": hermes,
|
||||
"average_duration_ms": (
|
||||
sum(int(row.get("duration_ms") or 0) for row in records) / count if count else 0
|
||||
),
|
||||
"token_usage": {
|
||||
"input": sum(int(row.get("input_tokens") or 0) for row in records),
|
||||
"output": sum(int(row.get("output_tokens") or 0) for row in records),
|
||||
"total": sum(int(row.get("total_tokens") or 0) for row in records),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def record_agent_execution(payload: Mapping[str, Any]):
|
||||
principal = authenticated_principal()
|
||||
if set(payload) - _RECORD_FIELDS:
|
||||
raise ApiSecurityError(403, "EXECUTION_FIELDS_FORBIDDEN")
|
||||
normalized, role = dict(payload), principal["role"]
|
||||
if role == "owner":
|
||||
requested_user = payload.get("user_id")
|
||||
if requested_user is not None and not isinstance(requested_user, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
target_user = _owner_user(principal, requested_user)
|
||||
requested_scope = payload.get("visible_scope")
|
||||
if requested_scope is not None and not isinstance(requested_scope, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
allowed_group = _owner_group(principal, requested_scope) if requested_scope and requested_scope != target_user else None
|
||||
normalized["user_id"], normalized["visible_scope"] = target_user, allowed_group or target_user
|
||||
else:
|
||||
normalized["user_id"] = principal["user_id"]
|
||||
normalized["visible_scope"] = _group_id(principal) or principal["user_id"]
|
||||
try:
|
||||
record = AgentExecutionRecord(**normalized)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_RECORD") from exc
|
||||
return {"status": "ok", "record": _storage().append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(
|
||||
user_id: str | None = None, group_id: str | None = None,
|
||||
role: str = "user",
|
||||
):
|
||||
del role # wire compatibility only; server principal is authoritative
|
||||
principal, pr_role = authenticated_principal(), None
|
||||
pr_role = principal["role"]
|
||||
if pr_role == "user":
|
||||
if user_id not in (None, principal["user_id"]) or group_id is not None:
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif pr_role == "group_admin":
|
||||
own_group = _group_id(principal)
|
||||
if group_id not in (None, own_group) or user_id not in (None, principal["user_id"]):
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], own_group, "group_admin"
|
||||
elif user_id is None and group_id is None:
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif group_id is not None:
|
||||
q_user = _owner_user(principal, user_id) if user_id is not None else principal["user_id"]
|
||||
q_group, q_role = _owner_group(principal, group_id), "group_admin"
|
||||
else:
|
||||
q_user, q_group, q_role = _owner_user(principal, user_id), None, "user"
|
||||
records = _storage().query(q_user, q_group, q_role)
|
||||
return {"status": "ok", "records": records, "summary": _summary(records)}
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -79,15 +180,13 @@ def build_router():
|
||||
|
||||
@router.post("/execution", operation_id="recordAgentExecution")
|
||||
def record(payload: dict[str, Any]):
|
||||
return record_agent_execution(payload)
|
||||
return _http(lambda: record_agent_execution(payload))
|
||||
|
||||
@router.get("/overview", operation_id="getAgentObservabilityOverview")
|
||||
def overview(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
return get_agent_observability(user_id, group_id, role)
|
||||
|
||||
return _http(lambda: get_agent_observability(user_id, group_id, role))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
|
||||
__all__ = ["ApiSecurityError", "authenticated_principal", "set_authenticator", "router", "record_agent_execution", "get_agent_observability"]
|
||||
|
||||
@@ -21,7 +21,11 @@ import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import has_capabilities
|
||||
from aios_capability_contract import (
|
||||
CAPABILITY_VOCABULARY_DIGEST,
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
)
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
@@ -148,6 +152,7 @@ class TaskStore:
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
capability_vocabulary_digest TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
@@ -161,6 +166,7 @@ class TaskStore:
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
capability_vocabulary_digest TEXT,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
@@ -209,6 +215,59 @@ class TaskStore:
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "agents", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "tasks", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_additive_column(
|
||||
connection: sqlite3.Connection, table: str, column: str, definition: str
|
||||
) -> None:
|
||||
"""Add C6 handshake storage without rewriting historical task rows."""
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
}
|
||||
if column not in columns:
|
||||
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_capability_vocabulary_digest(value: object | None) -> str | None:
|
||||
"""Validate an opt-in formal vocabulary handshake.
|
||||
|
||||
An omitted digest is the explicit C5-compatible legacy mode. A
|
||||
supplied digest has no downgrade path: it must name this exact formal
|
||||
vocabulary before any durable state is changed.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or value.strip().lower() != CAPABILITY_VOCABULARY_DIGEST:
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
return CAPABILITY_VOCABULARY_DIGEST
|
||||
|
||||
@staticmethod
|
||||
def _negotiated_capability_vocabulary_matches(
|
||||
*,
|
||||
agent_digest: str | None,
|
||||
task_digest: str | None,
|
||||
requested_digest: str | None,
|
||||
) -> bool:
|
||||
"""Match only an explicitly negotiated formal task vocabulary.
|
||||
|
||||
Historical agents and tasks carry ``NULL`` and keep their C5 behavior.
|
||||
A task admitted with a digest is never offered to or claimed by an
|
||||
agent that did not register the same current vocabulary.
|
||||
"""
|
||||
if task_digest is None:
|
||||
return True
|
||||
return (
|
||||
task_digest == CAPABILITY_VOCABULARY_DIGEST
|
||||
and agent_digest == task_digest
|
||||
and (requested_digest is None or requested_digest == task_digest)
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
@@ -221,6 +280,7 @@ class TaskStore:
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
@@ -264,6 +324,7 @@ class TaskStore:
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
@@ -272,6 +333,9 @@ class TaskStore:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
@@ -279,13 +343,14 @@ class TaskStore:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
"capability_vocabulary_digest,metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
vocabulary_digest,
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
@@ -307,6 +372,7 @@ class TaskStore:
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
@@ -331,6 +397,7 @@ class TaskStore:
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
@@ -375,6 +442,7 @@ class TaskStore:
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
@@ -399,6 +467,12 @@ class TaskStore:
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
normalized_required = normalize_required_capabilities(required)
|
||||
if any(value.startswith("exact:") for value in normalized_required):
|
||||
raise TaskProtocolError("TASK_CAPABILITY_UNKNOWN", 403)
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
@@ -407,6 +481,7 @@ class TaskStore:
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
@@ -429,15 +504,16 @@ class TaskStore:
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,constraints_json,metadata_json,deadline,priority,"
|
||||
"required_capabilities_json,capability_vocabulary_digest,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
vocabulary_digest,
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
@@ -517,18 +593,28 @@ class TaskStore:
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(self, *, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
def poll_tasks(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
self._expire_locked(connection)
|
||||
capabilities = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
@@ -536,7 +622,14 @@ class TaskStore:
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if has_capabilities(capabilities, required):
|
||||
if (
|
||||
self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
)
|
||||
and has_capabilities(capabilities, required)
|
||||
):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
@@ -558,19 +651,37 @@ class TaskStore:
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
if not self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
):
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
capabilities = set(json.loads(agent["capabilities_json"]))
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
|
||||
@@ -6,7 +6,7 @@ The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget,EvidenceEnvelope
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,6 +69,7 @@ class BaselineService:
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
self.store.evidence_attestation_verifier=self._verify_task_evidence
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
@@ -379,11 +380,39 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
with self.store._transaction():
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
evidence=EvidenceEnvelope.create(task_id=task.task_id,claim_id=claim.claim_id,
|
||||
kind='diagnostic',worker_identity=claim.worker_identity,payload=envelope,
|
||||
previous_evidence_hash=self.store._last_evidence_hash(task.task_id),
|
||||
attestation_ref='baseline-machine:'+self.signer.key_id)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':self.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(self.signer.private_key.sign(proof)).decode())
|
||||
self.store.complete_claim_with_evidence_and_event(task.task_id,claim.claim_id,
|
||||
'COMPLETED',envelope,[evidence])
|
||||
|
||||
def _verify_task_evidence(self,evidence):
|
||||
"""Verify the TaskPlane wrapper and its separately signed operation receipt."""
|
||||
try:
|
||||
if not isinstance(evidence,EvidenceEnvelope) or evidence.kind!='diagnostic': return False
|
||||
prefix='baseline-machine:'
|
||||
if not evidence.attestation_ref or not evidence.attestation_ref.startswith(prefix): return False
|
||||
key_id=evidence.attestation_ref[len(prefix):]
|
||||
key=self.signer.trusted_public_keys.get(key_id)
|
||||
if key is None or not evidence.signature: return False
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':key_id,'evidence':evidence.digest_payload()})
|
||||
key.verify(base64.b64decode(evidence.signature,validate=True),proof)
|
||||
claim=self.store.get_claim(evidence.claim_id)
|
||||
task=self.store.get_task(evidence.task_id)
|
||||
if claim.task_id!=task.task_id or claim.worker_identity!=evidence.worker_identity: return False
|
||||
if claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
return self._receipt_valid(task,evidence.payload)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Canonical fail-closed capability semantics for formal AgentTask eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
# These aliases are the verified C4 contract captured in
|
||||
# ``qa/capability-contract.txt``. Unknown task names become exact requirements;
|
||||
# unknown worker names are never granted.
|
||||
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"repository_read": frozenset({"repo.read"}),
|
||||
"git.read": frozenset({"repo.read"}),
|
||||
"repo.read": frozenset({"repo.read"}),
|
||||
"test_execution": frozenset({"test.execute"}),
|
||||
"git.test": frozenset({"test.execute"}),
|
||||
"test.execute": frozenset({"test.execute"}),
|
||||
"governed_git_write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.git.write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||
"shell": frozenset({"runtime.shell"}),
|
||||
"git": frozenset({"runtime.git"}),
|
||||
"python": frozenset({"runtime.python"}),
|
||||
"code.read": frozenset({"legacy.code.read"}),
|
||||
"code.review": frozenset({"legacy.code.review"}),
|
||||
"code.write": frozenset({"legacy.code.write"}),
|
||||
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||
"test.python": frozenset({"legacy.test.python"}),
|
||||
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||
"git.commit": frozenset({"legacy.git.commit"}),
|
||||
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||
"review.security": frozenset({"legacy.review.security"}),
|
||||
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
}
|
||||
|
||||
_WORKER_ALIASES = {
|
||||
key: value for key, value in _TASK_ALIASES.items()
|
||||
if key not in {"release:candidate", "governed_git_write", "governed.git.write"}
|
||||
}
|
||||
_WORKER_ALIASES.update({
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"governed.git.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.git.write.file": frozenset({"governed.git.write.file"}),
|
||||
})
|
||||
|
||||
_EXACT_LIFECYCLE_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"production.plan.apply",
|
||||
"production.promote",
|
||||
"production.rollback",
|
||||
"task.principal.issue",
|
||||
"testing",
|
||||
"verification",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
for _capability in _EXACT_LIFECYCLE_CAPABILITIES:
|
||||
_TASK_ALIASES[_capability] = frozenset({_capability})
|
||||
_WORKER_ALIASES[_capability] = frozenset({_capability})
|
||||
_UNKNOWN_PREFIX = "exact:"
|
||||
CAPABILITY_VOCABULARY_SCHEMA = "aios.agent-task.capability-vocabulary.v1"
|
||||
|
||||
|
||||
def capability_vocabulary_descriptor() -> dict[str, object]:
|
||||
"""Return the immutable inputs to the formal capability vocabulary digest.
|
||||
|
||||
The generic Task Plane has deliberately different wildcard semantics. This
|
||||
descriptor names only the formal AgentTask vocabulary used in this module.
|
||||
"""
|
||||
return {
|
||||
"schema": CAPABILITY_VOCABULARY_SCHEMA,
|
||||
"task_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_TASK_ALIASES.items())
|
||||
},
|
||||
"worker_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_WORKER_ALIASES.items())
|
||||
},
|
||||
"unknown_required_prefix": _UNKNOWN_PREFIX,
|
||||
"unknown_required": "exact-and-deny",
|
||||
"unknown_worker": "never-granted",
|
||||
"wildcard": "formal-deny",
|
||||
}
|
||||
|
||||
|
||||
def capability_vocabulary_digest() -> str:
|
||||
"""Return a deterministic digest for the formal capability vocabulary."""
|
||||
encoded = json.dumps(
|
||||
capability_vocabulary_descriptor(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
# A constant lets registration, admission, poll, and claim compare one exact
|
||||
# formal vocabulary without importing or interpreting the generic Task Plane.
|
||||
CAPABILITY_VOCABULARY_DIGEST = capability_vocabulary_digest()
|
||||
|
||||
|
||||
def _clean(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("capability must be a string")
|
||||
text = value.strip().lower()
|
||||
if not text:
|
||||
raise ValueError("capability must not be empty")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
continue
|
||||
mapped = _WORKER_ALIASES.get(text)
|
||||
if mapped is not None:
|
||||
result.update(mapped)
|
||||
if {"governed.git.write.branch", "governed.git.write.file"}.issubset(result):
|
||||
result.add("governed.git.write")
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def has_capabilities(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> bool:
|
||||
required = normalize_required_capabilities(required_capabilities)
|
||||
effective = normalize_worker_capabilities(worker_capabilities)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in required):
|
||||
return False
|
||||
return required.issubset(effective)
|
||||
|
||||
|
||||
def required_capabilities_subset(granted_requirements: Iterable[object], required_requirements: Iterable[object]) -> bool:
|
||||
granted = normalize_required_capabilities(granted_requirements)
|
||||
required = normalize_required_capabilities(required_requirements)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in granted | required):
|
||||
return False
|
||||
return required.issubset(granted)
|
||||
|
||||
|
||||
def normalized_capability_matrix(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> dict[str, object]:
|
||||
worker_values = tuple(worker_capabilities)
|
||||
required_values = tuple(required_capabilities)
|
||||
worker = normalize_worker_capabilities(worker_values)
|
||||
required = normalize_required_capabilities(required_values)
|
||||
return {
|
||||
"worker_effective": sorted(worker),
|
||||
"task_required": sorted(required),
|
||||
"eligible": has_capabilities(worker_values, required_values),
|
||||
}
|
||||
@@ -158,7 +158,10 @@ def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
||||
from security_path_guards import safe_segment, relative_parts, write_text_no_follow
|
||||
|
||||
def safe(s: str, n=160) -> str:
|
||||
return safe_segment(s, max_length=n)
|
||||
def sh(args, check=False):
|
||||
p = subprocess.run(['git'] + args, cwd=str(WS), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git failed')
|
||||
@@ -206,7 +209,10 @@ def relpath(path: str) -> str:
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
return str(Path(x))
|
||||
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
||||
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
b = name or f'{prefix}-{uid("br")}'
|
||||
if len(b) > 180: raise ValueError('branch name too long')
|
||||
relative_parts(b)
|
||||
if '..' in b or '@{' in b or b.endswith('.lock'): raise ValueError('invalid branch name')
|
||||
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
||||
return b
|
||||
def _json_has_plain_secret(obj: Any) -> bool:
|
||||
@@ -254,8 +260,12 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -284,7 +294,7 @@ def checkout(branch, base='main', create=False):
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
r=relpath(path); write_text_no_follow(WS, str(r).replace('\\','/'), content); return r
|
||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
@@ -835,7 +845,7 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
|
||||
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
r=write_json(f'patches/{safe(b.replace("/","_"))}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
@@ -942,7 +952,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if not name or not base: return self.sendj({'error':'provider_name/base_url/model_name required'},400)
|
||||
owner=safe(data.get('username') or a['account']); secret_ref=data.get('api_key_secret_ref') or data.get('secret_ref')
|
||||
if data.get('api_key'):
|
||||
d=SECRETS/'llm'/owner; d.mkdir(parents=True, exist_ok=True); sid=uid('llmkey'); p=d/f'{sid}.secret'; p.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(p,0o600); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
sid=uid('llmkey'); write_text_no_follow(SECRETS, f'llm/{owner}/{sid}.secret', str(data['api_key'])); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
if not secret_ref: return self.sendj({'error':'api_key_secret_ref or api_key required'},400)
|
||||
mid=data.get('model_id') or f'custom.{owner}.{hashlib.sha1((prov+base+name).encode()).hexdigest()[:10]}'; b=checkout(data.get('branch') or f'patch/model-{safe(mid)}','main',True)
|
||||
doc=sanitize({k:v for k,v in data.items() if k!='api_key'}); doc.update({'model_id':mid,'provider_name':prov,'base_url':base,'model_name':name,'api_key_secret_ref':secret_ref,'created_by':a['account'],'created_at':now()})
|
||||
@@ -1080,6 +1090,7 @@ def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
|
||||
@@ -216,6 +216,7 @@ class ControlPlaneReleaseManifest:
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
trusted_runtime_identity: Mapping[str, object] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -229,6 +230,35 @@ class ControlPlaneReleaseManifest:
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def runtime_identity(self) -> dict[str, object]:
|
||||
"""Return only a host-injected, already trusted comparison result."""
|
||||
identity = self.trusted_runtime_identity
|
||||
required = {
|
||||
"status", "declared_identity", "observed_identity", "manifest_digest",
|
||||
"config_digest", "observed_at", "expires_at", "evidence_refs", "reason",
|
||||
"mismatch_fields",
|
||||
}
|
||||
if (
|
||||
not isinstance(identity, Mapping)
|
||||
or identity.get("status") != "PASS"
|
||||
or not required.issubset(identity)
|
||||
or not isinstance(identity.get("declared_identity"), Mapping)
|
||||
or not isinstance(identity.get("observed_identity"), Mapping)
|
||||
or not isinstance(identity.get("evidence_refs"), list)
|
||||
or not identity["evidence_refs"]
|
||||
):
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"declared_identity": None,
|
||||
"observed_identity": None,
|
||||
"observed_at": None,
|
||||
"expires_at": None,
|
||||
"evidence_refs": [],
|
||||
"reason": "TRUSTED_OBSERVATION_UNAVAILABLE",
|
||||
"mismatch_fields": [],
|
||||
}
|
||||
return dict(identity)
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
@@ -254,7 +284,12 @@ class ControlPlaneReleaseManifest:
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
# Artifact and path validation proves only a local declaration. The
|
||||
# composition root may inject the output of an already trusted runtime
|
||||
# comparison, but this manifest must never promote its own checks to
|
||||
# runtime-observation PASS.
|
||||
identity = self.runtime_identity()
|
||||
return {**self.as_dict(), "drift_status": identity["status"], "component_runtime_identity": identity}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
|
||||
@@ -39,6 +39,7 @@ from aios_governed_task_bootstrap import (
|
||||
)
|
||||
from governed_production_release import build_executor_config
|
||||
from aios_baseline.runtime_contract import evaluate_deployment
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
TaskPrincipalVerifier = Callable[
|
||||
@@ -104,6 +105,10 @@ class RuntimeConfig:
|
||||
health_url: str = "http://127.0.0.1:18927/health"
|
||||
release_plan_path: Path | None = None
|
||||
repository_discovery_cursor_secret: str | bytes | None = None
|
||||
component_manifest_path: Path | None = None
|
||||
component_receipt_path: Path | None = None
|
||||
component_id: str | None = None
|
||||
component_identity_comparison: Callable[[Mapping[str, Any]], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
@@ -236,6 +241,12 @@ class GovernedRuntime:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=self.config.component_id,
|
||||
manifest_path=self.config.component_manifest_path,
|
||||
receipt_path=self.config.component_receipt_path,
|
||||
trusted_comparison=self.config.component_identity_comparison,
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
@@ -246,7 +257,8 @@ class GovernedRuntime:
|
||||
"runtime_path": str(self.config.state_root),
|
||||
"artifact_sha256": os.environ.get("AIOS_CONTROL_PLANE_ARTIFACT_SHA256"),
|
||||
"deployment_state": "STAGING",
|
||||
"drift_status": "PASS",
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
"repository": self.config.repository,
|
||||
"repository_identity": self.config.repository,
|
||||
"authoritative_git_dir": str(self.config.authoritative_git_dir),
|
||||
@@ -1332,6 +1344,17 @@ def main(
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
)),
|
||||
repository_discovery_cursor_secret=discovery_cursor_secret,
|
||||
component_manifest_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_MANIFEST_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_MANIFEST_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_receipt_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_id=os.environ.get("AIOS_COMPONENT_ID", "").strip() or None,
|
||||
)
|
||||
runtime = GovernedRuntime(config)
|
||||
# Prefer a server-side TaskPrincipal provider recovered from the same
|
||||
@@ -1342,21 +1365,39 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
admission_runtime = expired_admission_runtime()
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, build_source_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
if admission_runtime is None or task_principal_verifier is not None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw),
|
||||
)
|
||||
admission_runtime = AdmissionRuntimeRouter(admission_runtime, source_runtime).bundle()
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
|
||||
@@ -11,10 +11,32 @@ class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
required_facts: Mapping[str, frozenset[str]] | None = None
|
||||
|
||||
def _readiness_reason(self) -> str:
|
||||
if self.required_facts is None:
|
||||
return "REQUIRED_FACT_INVENTORY_MISSING"
|
||||
expected_groups = {"READ_PLANE", "WRITE_PLANE", "RELEASE_PLANE"}
|
||||
if set(self.required_facts) != expected_groups:
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
planes = {
|
||||
"READ_PLANE": self.read_plane,
|
||||
"WRITE_PLANE": self.write_plane,
|
||||
"RELEASE_PLANE": self.release_plane,
|
||||
}
|
||||
for group, facts in self.required_facts.items():
|
||||
if not isinstance(facts, frozenset) or not facts or any(not isinstance(name, str) or not name for name in facts):
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
plane = planes[group]
|
||||
if set(plane) != set(facts):
|
||||
return "REQUIRED_FACT_INVENTORY_MISMATCH"
|
||||
if any(type(value) is not bool or value is not True for value in plane.values()):
|
||||
return "REQUIRED_FACT_NOT_TRUE"
|
||||
return "READY"
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
return self._readiness_reason() == "READY"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -22,4 +44,5 @@ class RuntimeStatus:
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
"readiness_reason": self._readiness_reason(),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,6 +16,25 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
||||
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
||||
|
||||
def _loopback(value):
|
||||
try: return ipaddress.ip_address(value).is_loopback
|
||||
except (ValueError, TypeError): return False
|
||||
|
||||
def validate_dev_profile():
|
||||
if DEV_ALLOW and not (os.getenv('AIOS_RUNTIME_PROFILE') == 'isolated-staging' and _loopback(HOST)):
|
||||
raise RuntimeError('DEV_ALLOW requires isolated-staging and loopback')
|
||||
|
||||
def runtime_selection(principal, data):
|
||||
if not principal.get('token_ok'): raise PermissionError('authenticated principal required')
|
||||
if any(key in data for key in ('username', 'user_id', 'role', 'tier')):
|
||||
raise ValueError('runtime identity must come from authenticated principal')
|
||||
username=principal['username']; role=principal['role']
|
||||
if role not in _ROLE_WHITELIST or re.fullmatch(r'[A-Za-z0-9_@.-]{1,120}', username) is None or username in ('.','..'):
|
||||
raise ValueError('invalid authenticated principal')
|
||||
tier='ultra' if role=='owner' else 'regular'
|
||||
return username, role, tier
|
||||
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
||||
@@ -68,6 +87,7 @@ def token_identity(token: str):
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
@@ -85,7 +105,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -163,7 +185,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'not_found','path':path},404)
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
def do_POST(self):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == '/api/runtime/select':
|
||||
if not (_loopback(HOST) and _loopback(self.client_address[0])):
|
||||
self.send_json({'error':'loopback ingress required'},403); return
|
||||
a = self.require_write()
|
||||
if not a: return
|
||||
data = parse_body(self)
|
||||
try: runtime_selection(a, data)
|
||||
except (ValueError, PermissionError):
|
||||
self.send_json({'error':'invalid runtime selection identity'},403); return
|
||||
else:
|
||||
data = parse_body(self)
|
||||
init_db()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -217,7 +251,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not m: self.send_json({'error':'model not found'},404); return
|
||||
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
||||
if path == '/api/runtime/select':
|
||||
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
||||
username,role,tier=runtime_selection(a, data); risk=data.get('risk','normal')
|
||||
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
||||
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
||||
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
||||
@@ -292,5 +326,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
|
||||
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
||||
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -486,8 +486,12 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -1303,6 +1307,7 @@ def api_spec():
|
||||
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -51,15 +51,30 @@ AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
||||
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
||||
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
||||
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA = "aios.source-publication-policy.v1"
|
||||
SOURCE_PUBLICATION_POLICY_ID = "C5_SOURCE_PUBLICATION_ONLY"
|
||||
SOURCE_PUBLICATION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
||||
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
# ``engineering`` is deliberately between medium and high. Adding this rank
|
||||
# does not raise any configured ceiling; existing policy files keep their
|
||||
# existing value (the normal local-owner policy is ``medium``).
|
||||
_RISK = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -691,6 +706,30 @@ class OwnerAuthorizationBundle:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePublicationPolicyRequest:
|
||||
"""One exact, source-only unattended publication authorization request."""
|
||||
|
||||
policy_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
source_oid: str
|
||||
source_tree_oid: str
|
||||
target_agent: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
exact_files: tuple[str, ...]
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
risk: str = "engineering"
|
||||
production_impact: str = "none"
|
||||
renewal: str = "none"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "exact_files", tuple(self.exact_files))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCapabilityRequest:
|
||||
capability_id: str
|
||||
@@ -755,20 +794,28 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
key_path: Path | None,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path,
|
||||
key_path or Path("/dev/null"),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.policy = policy
|
||||
self.clock = clock
|
||||
self.registered_signer = registered_signer
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
if self.registered_signer is not None:
|
||||
return str(self.registered_signer.fingerprint)
|
||||
return super().fingerprint
|
||||
|
||||
@staticmethod
|
||||
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
||||
@@ -831,6 +878,111 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
||||
if self.registered_signer is None:
|
||||
return super()._sign_canonical(value)
|
||||
payload = canonical_json_bytes(dict(value))
|
||||
signature = self.registered_signer._sign_canonical_payload(
|
||||
self.namespace, payload
|
||||
)
|
||||
return SignedArtifact(
|
||||
payload=dict(value),
|
||||
canonical_payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.fingerprint,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def issue_source_publication_policy(
|
||||
self, request: SourcePublicationPolicyRequest
|
||||
) -> SignedArtifact:
|
||||
"""Sign one exact C5 source-publication policy.
|
||||
|
||||
This is an issuance operation only. It cannot change the configured
|
||||
owner policy, its ceiling, branch/file allowlists, or trust domain.
|
||||
"""
|
||||
now = int(self.clock())
|
||||
files = tuple(request.exact_files)
|
||||
def source_scope_narrows(value: str, allowed: Sequence[str]) -> bool:
|
||||
base = value.rstrip("/")
|
||||
return any(
|
||||
base == prefix.rstrip("/")
|
||||
or base.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in allowed
|
||||
)
|
||||
if (
|
||||
request.policy_id != SOURCE_PUBLICATION_POLICY_ID
|
||||
or request.repository != self.policy.repository
|
||||
or not _HEX40.fullmatch(request.authority_base_oid)
|
||||
or not _HEX40.fullmatch(request.source_oid)
|
||||
or not _HEX40.fullmatch(request.source_tree_oid)
|
||||
or not _ID.fullmatch(request.target_agent)
|
||||
or not request.target_agent.startswith("agent/")
|
||||
or not source_scope_narrows(
|
||||
request.target_branch, self.policy.allowed_branch_prefixes
|
||||
)
|
||||
or not request.target_branch.startswith("agent/")
|
||||
or "\\" in request.target_branch
|
||||
or ".." in PurePosixPath(request.target_branch).parts
|
||||
or request.target_branch.endswith(("/", "."))
|
||||
or type(request.generation) is not int
|
||||
or request.generation < 1
|
||||
or not _HEX64.fullmatch(request.execution_digest)
|
||||
or not files
|
||||
or any(
|
||||
not item
|
||||
or PurePosixPath(item).is_absolute()
|
||||
or "\\" in item
|
||||
or ".." in PurePosixPath(item).parts
|
||||
or item.startswith(".git")
|
||||
or not source_scope_narrows(item, self.policy.allowed_file_prefixes)
|
||||
for item in files
|
||||
)
|
||||
or not SOURCE_PUBLICATION_CAPABILITIES.issubset(self.policy.allowed_capabilities)
|
||||
or len(set(files)) != len(files)
|
||||
or not _ID.fullmatch(request.nonce)
|
||||
or request.risk != "engineering"
|
||||
or request.risk not in _RISK
|
||||
or _RISK[request.risk] > _RISK[self.policy.risk_ceiling]
|
||||
or request.production_impact != "none"
|
||||
or request.renewal != "none"
|
||||
or type(request.ttl_seconds) is not int
|
||||
or not 1 <= request.ttl_seconds <= 3600
|
||||
or request.ttl_seconds > self.policy.maximum_epoch_ttl_seconds
|
||||
):
|
||||
_fail("SOURCE_PUBLICATION_REQUEST_WIDENS_POLICY")
|
||||
value: dict[str, Any] = {
|
||||
"schema": SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
"policy_id": SOURCE_PUBLICATION_POLICY_ID,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": request.repository,
|
||||
"trust_domain": self.policy.trust_domain,
|
||||
"policy_digest": self.policy.digest,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"source_oid": request.source_oid,
|
||||
"source_tree_oid": request.source_tree_oid,
|
||||
"target_agent": request.target_agent,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"exact_files": list(files),
|
||||
"capabilities": sorted(SOURCE_PUBLICATION_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": request.nonce,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 30),
|
||||
"expires_at": now + request.ttl_seconds,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.fingerprint,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
issue_source_only_policy = issue_source_publication_policy
|
||||
|
||||
def issue_authorization_bundle(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,8 @@ 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_EPOCH_V2 = "aios.owner-delegation-epoch.v2"
|
||||
SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -142,9 +144,30 @@ class OwnerDelegationEpoch:
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
production_scope: str | None = None
|
||||
destructive_policy: str | None = None
|
||||
# Provenance retained by the consumer. These fields are populated by the
|
||||
# signed-directory provider for v2 (and left at their v1-compatible
|
||||
# defaults when reading legacy epochs); they are never used to translate or
|
||||
# re-sign the original artifact.
|
||||
status: str = ""
|
||||
signature_namespace: str = ""
|
||||
signature_key_id: str = ""
|
||||
canonical_payload_digest: str = ""
|
||||
canonical_payload: bytes = b""
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
"""The signed epoch issuer, retained under the legacy identity name."""
|
||||
return self.owner_identity
|
||||
|
||||
@property
|
||||
def canonical_payload_bytes(self) -> bytes:
|
||||
return self.canonical_payload
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value.pop("canonical_payload", None)
|
||||
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)
|
||||
@@ -309,7 +332,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -838,7 +861,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
|
||||
@@ -26,6 +26,8 @@ import urllib.request
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
OwnerDelegationEpoch,
|
||||
SCHEMA_EPOCH,
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
canonical_json_bytes,
|
||||
@@ -44,6 +46,19 @@ from aios_gitops_job_identity import (
|
||||
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"
|
||||
_EPOCH_V1_FIELDS = frozenset({
|
||||
"epoch_id", "owner_identity", "trust_domain", "repository",
|
||||
"allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",
|
||||
"risk_ceiling", "issued_at", "not_before", "expires_at",
|
||||
"revocation_epoch", "policy_digest", "schema",
|
||||
})
|
||||
_EPOCH_V2_FIELDS = frozenset({
|
||||
*_EPOCH_V1_FIELDS,
|
||||
"business_goal", "production_scope", "destructive_policy",
|
||||
"signature_namespace", "signature_key_id", "canonical_payload_digest",
|
||||
"status",
|
||||
})
|
||||
_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
||||
|
||||
|
||||
class OfficialTaskTransport(Protocol):
|
||||
@@ -463,6 +478,7 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -622,11 +638,15 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_SCHEMA_INVALID",
|
||||
403,
|
||||
)
|
||||
expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS
|
||||
if set(value) != expected_fields:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)
|
||||
if value.get("epoch_id") != epoch_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_ID_MISMATCH",
|
||||
@@ -642,7 +662,34 @@ class SignedEpochDirectoryProvider:
|
||||
if epoch_id in self._revoked_ids():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
|
||||
# v2 is a producer-authored envelope. Validate its self-digest and
|
||||
# signature binding before the cryptographic verifier sees the exact
|
||||
# bytes from disk. The digest is over the payload with only the digest
|
||||
# member removed; no schema translation is permitted.
|
||||
if schema == SCHEMA_EPOCH_V2:
|
||||
if (
|
||||
value.get("status") != "ACTIVE"
|
||||
or not isinstance(value.get("business_goal"), str)
|
||||
or not value["business_goal"]
|
||||
or not isinstance(value.get("production_scope"), str)
|
||||
or not value["production_scope"]
|
||||
or not isinstance(value.get("destructive_policy"), str)
|
||||
or value["destructive_policy"] not in {"forbidden", "rollback-only"}
|
||||
or value.get("signature_namespace") != OWNER_NAMESPACE
|
||||
or not isinstance(value.get("signature_key_id"), str)
|
||||
or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])
|
||||
or not isinstance(value.get("canonical_payload_digest"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)
|
||||
digest_value = dict(value)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if sha256_json(digest_value) != supplied_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)
|
||||
|
||||
key_id = key_id_file.read_text(encoding="utf-8").strip()
|
||||
if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)
|
||||
armored = signature.read_text(encoding="utf-8")
|
||||
if self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
@@ -659,6 +706,23 @@ class SignedEpochDirectoryProvider:
|
||||
not_before = value.get("not_before")
|
||||
expires_at = value.get("expires_at")
|
||||
if (
|
||||
not isinstance(value.get("epoch_id"), str)
|
||||
or not value["epoch_id"]
|
||||
or not isinstance(value.get("owner_identity"), str)
|
||||
or not value["owner_identity"]
|
||||
or not isinstance(value.get("trust_domain"), str)
|
||||
or not value["trust_domain"]
|
||||
or not isinstance(value.get("repository"), str)
|
||||
or not value["repository"]
|
||||
or not isinstance(value.get("risk_ceiling"), str)
|
||||
or value["risk_ceiling"] not in {"low", "medium", "engineering", "high"}
|
||||
or not isinstance(value.get("issued_at"), int)
|
||||
or isinstance(value.get("issued_at"), bool)
|
||||
or not isinstance(value.get("revocation_epoch"), int)
|
||||
or isinstance(value.get("revocation_epoch"), bool)
|
||||
or not isinstance(value.get("policy_digest"), str)
|
||||
or not value["policy_digest"]
|
||||
or
|
||||
not isinstance(not_before, int)
|
||||
or isinstance(not_before, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
@@ -672,13 +736,16 @@ class SignedEpochDirectoryProvider:
|
||||
files = value.get("allowed_file_prefixes")
|
||||
if (
|
||||
not isinstance(branches, list)
|
||||
or not branches
|
||||
or not all(isinstance(item, str) and item for item in branches)
|
||||
or not isinstance(capabilities, list)
|
||||
or not capabilities
|
||||
or not all(
|
||||
isinstance(item, str) and item
|
||||
for item in capabilities
|
||||
)
|
||||
or not isinstance(files, list)
|
||||
or not files
|
||||
or not all(isinstance(item, str) and item for item in files)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
@@ -701,6 +768,19 @@ class SignedEpochDirectoryProvider:
|
||||
revocation_epoch=int(value["revocation_epoch"]),
|
||||
policy_digest=str(value["policy_digest"]),
|
||||
schema=str(value["schema"]),
|
||||
production_scope=(
|
||||
str(value["production_scope"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
destructive_policy=(
|
||||
str(value["destructive_policy"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
status=str(value.get("status", "")),
|
||||
signature_namespace=str(value.get("signature_namespace", "")),
|
||||
signature_key_id=str(value.get("signature_key_id", "")),
|
||||
canonical_payload_digest=str(value.get("canonical_payload_digest", "")),
|
||||
canonical_payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -915,6 +995,7 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
PARAMETERIZED_RECOVERY_NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
@@ -199,6 +200,31 @@ def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Call
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
def _parameterized_recovery_nonce_reserved(
|
||||
connection: sqlite3.Connection, nonce: str
|
||||
) -> bool:
|
||||
"""Share the existing authority nonce domain when C6 NOOP support exists.
|
||||
|
||||
Historical lineage databases can predate TaskStore's idempotency table;
|
||||
their established lineage-only operation remains readable. The C6 NOOP
|
||||
adapter refuses to activate on those schemas, so an absent table cannot
|
||||
create a cross-operation uniqueness gap.
|
||||
"""
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if "idempotency" not in tables:
|
||||
return False
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM idempotency WHERE scope=? AND key=?",
|
||||
(PARAMETERIZED_RECOVERY_NONCE_SCOPE, nonce),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
@@ -251,6 +277,8 @@ class TaskLineageSupersessionController:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
if _parameterized_recovery_nonce_reserved(connection, decision["nonce"]):
|
||||
_fail("PARAMETERIZED_RECOVERY_NONCE_CONSUMED")
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Narrow, terminal-only parameterized recovery over the formal Task store.
|
||||
|
||||
This adapter deliberately does *not* create a generic recovery authority. It
|
||||
can only record an Owner-signed, exact-CAS terminal NOOP. A non-terminal
|
||||
recovery remains the existing formal lineage/supersession operation.
|
||||
|
||||
The receipt uses the existing TaskStore ``idempotency`` uniqueness constraint
|
||||
in the same authoritative database. Two fixed owner-authority scopes bind a
|
||||
decision id and a nonce atomically; this is not a new nonce database or table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_agent_task_protocol import TaskStore, canonical_json
|
||||
from aios_task_lineage_supersession import (
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
REPOSITORY,
|
||||
)
|
||||
|
||||
|
||||
DECISION_SCHEMA = "aios.task-parameterized-recovery-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-parameterized-recovery-receipt.v1"
|
||||
ACTION_TERMINAL_NOOP = "TERMINAL_NOOP"
|
||||
TERMINAL_STATUSES = frozenset({"COMPLETED", "CANCELLED", "FAILED_FINAL"})
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
DECISION_SCOPE = "aios.task-parameterized-recovery.v1:owner-decision"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
_BRANCH = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "decision_id", "nonce", "action", "task_id",
|
||||
"task_identity_sha256", "expected_status", "expected_attempt",
|
||||
"expected_generation", "expected_revision", "expected_claimed_by",
|
||||
"expected_lease_token_hash", "expected_lease_expires_at", "repository",
|
||||
"target_branch", "owner_identity", "signature_namespace",
|
||||
"signature_principal", "key_id", "issued_at", "not_before",
|
||||
"expires_at", "one_time_use", "signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ParameterizedRecoveryError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ParameterizedRecoveryError(code, status)
|
||||
|
||||
|
||||
def _utc(value: object, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ParameterizedRecoveryError(f"{field.upper()}_INVALID", 400) from exc
|
||||
if parsed.tzinfo is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: object, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def canonical_decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
def _identity_digest(row: sqlite3.Row) -> str:
|
||||
value = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
}
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != _FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"action": ACTION_TERMINAL_NOOP,
|
||||
"repository": REPOSITORY,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(f"{field.upper()}_MISMATCH", 403)
|
||||
for field in ("task_id", "decision_id", "nonce"):
|
||||
if _SAFE_ID.fullmatch(_text(raw[field], field, maximum=512)) is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
if not isinstance(raw["task_identity_sha256"], str) or not _HEX64.fullmatch(raw["task_identity_sha256"]):
|
||||
_fail("TASK_IDENTITY_SHA256_INVALID", 400)
|
||||
if not isinstance(raw["target_branch"], str) or not _BRANCH.fullmatch(raw["target_branch"]):
|
||||
_fail("TARGET_BRANCH_INVALID", 400)
|
||||
for field, minimum in (("expected_attempt", 0), ("expected_generation", 1), ("expected_revision", 1)):
|
||||
if isinstance(raw[field], bool) or not isinstance(raw[field], int) or raw[field] < minimum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
for field in ("expected_claimed_by", "expected_lease_token_hash", "expected_lease_expires_at"):
|
||||
if raw[field] is not None:
|
||||
_text(raw[field], field, maximum=512)
|
||||
if raw["expected_status"] not in TERMINAL_STATUSES:
|
||||
_fail("EXPECTED_STATUS_NOT_TERMINAL", 409)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > not_before or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at <= issued_at or expires_at - issued_at > MAX_TTL:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
if now < not_before or now >= expires_at or issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
return dict(raw)
|
||||
|
||||
|
||||
class ParameterizedRecoveryController:
|
||||
"""Consumes a validated terminal-NOOP decision once, in the TaskStore DB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskStore,
|
||||
verifier: SignatureVerifier,
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
identity_digest: Callable[[sqlite3.Row], str] = _identity_digest,
|
||||
) -> None:
|
||||
if not isinstance(store, TaskStore) or verifier is None:
|
||||
_fail("FORMAL_TASK_AUTHORITY_REQUIRED", 503)
|
||||
self.store = store
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
tables = {str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not {"tasks", "events", "idempotency", "task_lineage_supersession_receipts"}.issubset(tables):
|
||||
_fail("FORMAL_LINEAGE_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
|
||||
def _verify_task(self, row: sqlite3.Row, decision: Mapping[str, Any]) -> None:
|
||||
if self.identity_digest(row) != decision["task_identity_sha256"]:
|
||||
_fail("TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
if (
|
||||
row["task_id"] != decision["task_id"]
|
||||
or row["status"] != decision["expected_status"]
|
||||
or int(row["revision"]) != decision["expected_revision"]
|
||||
or int(row["attempt"]) != decision["expected_attempt"]
|
||||
or row["claimed_by"] != decision["expected_claimed_by"]
|
||||
or row["lease_token_hash"] != decision["expected_lease_token_hash"]
|
||||
or row["lease_expires_at"] != decision["expected_lease_expires_at"]
|
||||
):
|
||||
_fail("TASK_EXACT_CAS_MISMATCH")
|
||||
try:
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
constraints = json.loads(row["constraints_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("TASK_BINDING_JSON_INVALID") from exc
|
||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(execution, Mapping) or constraints.get("generation") != decision["expected_generation"]:
|
||||
_fail("TASK_EXECUTION_BINDING_INVALID")
|
||||
if (
|
||||
execution.get("task_id") != decision["task_id"]
|
||||
or execution.get("repository") != decision["repository"]
|
||||
or execution.get("target_branch") != decision["target_branch"]
|
||||
):
|
||||
_fail("TASK_EXECUTION_BINDING_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def _receipt(decision: Mapping[str, Any], decision_sha: str, row: sqlite3.Row, now: datetime) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"state": "NOOP",
|
||||
"noop_reason": "TERMINAL_STATUS",
|
||||
"decision_id": decision["decision_id"],
|
||||
"nonce": decision["nonce"],
|
||||
"task_id": decision["task_id"],
|
||||
"status": row["status"],
|
||||
"revision": int(row["revision"]),
|
||||
"owner_decision_sha256": decision_sha,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": hashlib.sha256(decision["signature"].encode()).hexdigest(),
|
||||
"exact_identity_cas": True,
|
||||
"one_time_use": True,
|
||||
"observed_at": now.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_exact_replay(
|
||||
nonce_row: sqlite3.Row | None, decision_row: sqlite3.Row | None, decision: Mapping[str, Any], decision_sha: str
|
||||
) -> dict[str, Any] | None:
|
||||
if nonce_row is None and decision_row is None:
|
||||
return None
|
||||
if nonce_row is None or decision_row is None:
|
||||
present = nonce_row if nonce_row is not None else decision_row
|
||||
if present["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
_fail("RECOVERY_RECEIPT_INCOMPLETE", 500)
|
||||
if nonce_row["payload_digest"] != decision_sha or decision_row["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
try:
|
||||
nonce_receipt = json.loads(nonce_row["response_json"])
|
||||
decision_receipt = json.loads(decision_row["response_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("RECOVERY_RECEIPT_INVALID", 500) from exc
|
||||
if nonce_receipt != decision_receipt or (
|
||||
nonce_receipt.get("schema") != RECEIPT_SCHEMA
|
||||
or nonce_receipt.get("decision_id") != decision["decision_id"]
|
||||
or nonce_receipt.get("nonce") != decision["nonce"]
|
||||
or nonce_receipt.get("task_id") != decision["task_id"]
|
||||
or nonce_receipt.get("owner_decision_sha256") != decision_sha
|
||||
):
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
return dict(nonce_receipt)
|
||||
|
||||
def recover_terminal_noop(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload = canonical_decision_payload(raw)
|
||||
payload_bytes = canonical_json(payload).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
payload=payload_bytes,
|
||||
signature=decision["signature"],
|
||||
key_id=OWNER_KEY_ID,
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (decision["task_id"],)).fetchone()
|
||||
if row is None:
|
||||
_fail("TASK_NOT_FOUND", 404)
|
||||
self._verify_task(row, decision)
|
||||
nonce_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(NONCE_SCOPE, decision["nonce"]),
|
||||
).fetchone()
|
||||
decision_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(DECISION_SCOPE, decision["decision_id"]),
|
||||
).fetchone()
|
||||
lineage_row = connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if lineage_row is not None:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
replay = self._read_exact_replay(nonce_row, decision_row, decision, decision_sha)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
receipt = self._receipt(decision, decision_sha, row, now)
|
||||
receipt_json = canonical_json(receipt)
|
||||
for scope, key in ((NONCE_SCOPE, decision["nonce"]), (DECISION_SCOPE, decision["decision_id"])):
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(scope, key, decision_sha, receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(decision["task_id"], None, "TASK_PARAMETERIZED_RECOVERY_NOOP", receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
# There is intentionally no generic resume/reclaim path. The existing
|
||||
# lineage controller remains the only predecessor/successor authority.
|
||||
recover = recover_terminal_noop
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Read-only V3 issuer scope over the admitted producer's durable receipt.
|
||||
|
||||
The V1/V2 provider remains authoritative for those schemas. V3 validation
|
||||
and Job readback are delegated to the existing admission producer contract;
|
||||
this module never reserves a Job or changes the canonical plan digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_live_adapters import (
|
||||
ProducerReceiptError,
|
||||
_FLProducerReceiptReserver,
|
||||
load_v3_plan,
|
||||
validate_v3_plan,
|
||||
)
|
||||
from governed_runtime_scope import (
|
||||
GovernedScopeError,
|
||||
RuntimeScope,
|
||||
_branch,
|
||||
_generation,
|
||||
_hex64,
|
||||
_job_id,
|
||||
_normalize_caps,
|
||||
_normalize_files,
|
||||
_oid40,
|
||||
_task_id,
|
||||
_trust_digest,
|
||||
)
|
||||
|
||||
|
||||
def _deny_mutation(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise GovernedScopeError("ISSUER_PRODUCER_MUTATION_FORBIDDEN")
|
||||
|
||||
|
||||
def _read_only_bootstrap(root: Path) -> GovernedTaskBootstrap:
|
||||
"""Construct only the read-only binding view; __init__ creates directories."""
|
||||
root = Path(root)
|
||||
for path in (root, root / "task-bindings", root / "jobs"):
|
||||
metadata = path.lstat()
|
||||
if (stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode)
|
||||
or stat.S_IMODE(metadata.st_mode) & 0o022):
|
||||
raise GovernedScopeError("PRODUCER_STATE_ROOT_INVALID")
|
||||
view = object.__new__(GovernedTaskBootstrap)
|
||||
view.root = root
|
||||
view.task_meta_root = root / "task-bindings"
|
||||
view.job_root = root / "jobs"
|
||||
view.create = _deny_mutation
|
||||
view._store = _deny_mutation
|
||||
view._write = _deny_mutation
|
||||
return view
|
||||
|
||||
|
||||
class IssuerV3ReceiptScopeProvider:
|
||||
"""Delegate legacy plans, and resolve V3 with an existing Job only."""
|
||||
|
||||
def __init__(
|
||||
self, *, legacy: Any, producer_state_root: Path, plan_root: Path,
|
||||
grant_reader: Callable[..., Mapping[str, Any]],
|
||||
plan_loader: Callable[[Path, Mapping[str, Any]], Mapping[str, Any]] = load_v3_plan,
|
||||
) -> None:
|
||||
self.legacy = legacy
|
||||
self.producer_state_root = Path(producer_state_root)
|
||||
self.plan_root = Path(plan_root)
|
||||
self.grant_reader = grant_reader
|
||||
self.plan_loader = plan_loader
|
||||
|
||||
def inspect_v3_binding(
|
||||
self, *, grant_raw: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
) -> tuple[str, Mapping[str, Any]]:
|
||||
"""Validate original bytes and durable receipt without Task mutation.
|
||||
|
||||
This method can inspect an expired historical fixture. ``resolve``
|
||||
separately enforces live Task and grant state before issuing scope.
|
||||
"""
|
||||
try:
|
||||
original = self.plan_loader(self.plan_root, grant_raw)
|
||||
if dict(original) != dict(plan):
|
||||
raise GovernedScopeError("EXECUTION_PLAN_READBACK_CONFLICT")
|
||||
# Keep the main V3 validator in the issuer contract even when a
|
||||
# test supplies an isolated loader. It binds the original plan,
|
||||
# including risk, hashes, capabilities and execution digest.
|
||||
from aios_task_admission_live_adapters import canonical_json_bytes
|
||||
validate_v3_plan(grant=grant_raw, raw=canonical_json_bytes(original))
|
||||
bootstrap = _read_only_bootstrap(self.producer_state_root)
|
||||
runtime = type("ReadOnlyIssuerRuntime", (), {
|
||||
"bootstrap": bootstrap, "bindings": {},
|
||||
})()
|
||||
reserver = _FLProducerReceiptReserver(runtime)
|
||||
job_id = reserver.existing_v3_job(grant_raw, original)
|
||||
if job_id is None:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_MISSING")
|
||||
readback = reserver.readbacks.get(str(grant_raw["task_id"]))
|
||||
proof = readback.get("reservation_readback") if isinstance(readback, Mapping) else None
|
||||
if not isinstance(proof, Mapping) or proof.get("job_id") != job_id:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_BINDING_INVALID")
|
||||
return job_id, dict(proof)
|
||||
except (TaskAdmissionError, ProducerReceiptError, OSError, KeyError, TypeError, ValueError) as exc:
|
||||
raise GovernedScopeError(str(exc) or "PRODUCER_RECEIPT_INVALID") from exc
|
||||
|
||||
def resolve(
|
||||
self, *, task: Any, execution: Any, grant: Any,
|
||||
plan: Mapping[str, Any], trust: Any, now: int,
|
||||
) -> RuntimeScope:
|
||||
if plan.get("schema") != "aios.task-admission-execution-plan.v3":
|
||||
return self.legacy.resolve(
|
||||
task=task, execution=execution, grant=grant,
|
||||
plan=plan, trust=trust, now=now,
|
||||
)
|
||||
|
||||
task_id = _task_id(task.task_id)
|
||||
exec_job = _job_id(execution.external_job_id)
|
||||
exec_digest = _hex64(execution.execution_digest, "execution_digest")
|
||||
exec_branch = _branch(execution.target_branch)
|
||||
exec_generation = _generation(execution.generation)
|
||||
grant_raw = self.grant_reader(
|
||||
task_id=task_id, execution_digest=exec_digest,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, now=int(now),
|
||||
)
|
||||
producer_job, _proof = self.inspect_v3_binding(
|
||||
grant_raw=grant_raw, plan=plan,
|
||||
)
|
||||
if exec_job != producer_job:
|
||||
raise GovernedScopeError("EXECUTION_PRODUCER_JOB_MISMATCH")
|
||||
if str(task.status).upper() not in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedScopeError("TASK_NOT_CLAIMED")
|
||||
if grant.status != "ADMITTED" or int(grant.expires_at) <= int(now):
|
||||
raise GovernedScopeError("GRANT_EXPIRED_OR_NOT_ADMITTED")
|
||||
expected = {
|
||||
"task_id": task_id,
|
||||
"repository": execution.repository,
|
||||
"target_branch": exec_branch,
|
||||
"generation": exec_generation,
|
||||
"execution_digest": exec_digest,
|
||||
"authority_base_oid": _oid40(grant.authority_base_oid, "authority_base_oid"),
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if (execution.repository != "aios/aios"
|
||||
or execution.task_id != task_id
|
||||
or any(grant_raw.get(key) != value for key, value in expected.items())):
|
||||
raise GovernedScopeError("V3_GRANT_EXECUTION_BINDING_MISMATCH")
|
||||
caps = _normalize_caps(grant.requested_capabilities)
|
||||
files = _normalize_files(grant.file_allowlist)
|
||||
if (caps != _normalize_caps(grant_raw["capabilities"])
|
||||
or files != _normalize_files(grant_raw["file_allowlist"])):
|
||||
raise GovernedScopeError("V3_GRANT_SCOPE_MISMATCH")
|
||||
if (not trust.issuer_id or not trust.key_id or not trust.public_key
|
||||
or "TASK_PRINCIPAL" not in str(trust.purpose).upper().replace("-", "_")
|
||||
or not str(trust.bridge_id).strip()):
|
||||
raise GovernedScopeError("TRUST_IDENTITY_INVALID")
|
||||
trust_digest = _trust_digest(trust)
|
||||
if (trust.scope_manifest_sha256 is not None
|
||||
and _hex64(trust.scope_manifest_sha256, "scope_manifest_sha256") != trust_digest):
|
||||
raise GovernedScopeError("TRUST_MANIFEST_DIGEST_MISMATCH")
|
||||
return RuntimeScope(
|
||||
scope_id=f"governed-runtime:{task_id}:g{exec_generation}:{exec_digest[:16]}",
|
||||
task_id=task_id, external_job_id=producer_job,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, execution_digest=exec_digest,
|
||||
capabilities=caps, trust_manifest_sha256=trust_digest,
|
||||
grant_id=grant.grant_id, grant_expires_at=int(grant.expires_at),
|
||||
)
|
||||
@@ -24,6 +24,19 @@ class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
C5_INTEGRATION_CONTRACT_FIELDS = frozenset({
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"publication_plan_sha256", "repair_source_commit", "repair_tree_oid",
|
||||
})
|
||||
C5_INTEGRATION_FILES = {
|
||||
"active-runtime/app/aios_capability_contract.py": ("publication_source_commit", "67d8a3a296110e20a4ea59863cd7982b6bdada71abd457c0f7cec480b06d278a"),
|
||||
"tests/test_v2r2c5_capability_contract.py": ("publication_source_commit", "f91af641528ea9a625ca45a46b6c26f5dba568e6b69cba819ca33ab5cba28de3"),
|
||||
"active-runtime/app/aios_baseline/service.py": ("repair_source_commit", "3c3918aec5503947b48ef4c73e34cac8f0d33fdfb99cfcd5ca8af699b663da72"),
|
||||
"tests/baseline_v1_1/test_loop.py": ("repair_source_commit", "f867e92b9ec9b4dff248e6ca38280848458a535c610dc6d9fea2b270c29b664a"),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
@@ -135,6 +148,7 @@ class TrustedCandidateBuilderConfig:
|
||||
policy_sha256: str
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
c5_contract: Mapping[str, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
@@ -145,16 +159,52 @@ class TrustedCandidateBuilderConfig:
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
is_integration = policy.get("schema") == "aios.trusted-candidate-builder-policy.c5-integration.v1"
|
||||
is_c5 = is_integration or policy.get("schema") == "aios.trusted-candidate-builder-policy.c5.v1"
|
||||
if set(policy) != (required | {"c5_contract"} if is_c5 else required) or policy.get("schema") not in {"aios.trusted-candidate-builder-policy.v1", "aios.trusted-candidate-builder-policy.c5.v1", "aios.trusted-candidate-builder-policy.c5-integration.v1"}:
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
expected_recipes = {"V2R2C2", "V2R2C3", "V2R2C5"} if is_c5 else {"V2R2C2", "V2R2C3"}
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != expected_recipes:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
c5_contract = None
|
||||
if is_c5:
|
||||
c5 = recipes["V2R2C5"]
|
||||
expected_mode = "governed-source-integration-only" if is_integration else "governed-source-admission-only"
|
||||
if not isinstance(c5, Mapping) or set(c5) != {"mode", "version", "purpose"} or c5.get("mode") != expected_mode or c5.get("version") != "V2R2C5" or c5.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("POLICY_V2R2C5_INVALID")
|
||||
required_c5 = {"task_id", "authority_base_oid", "accepted_base_oid", "rollback_version", "rollback_target_oid", "rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
required_c5 |= C5_INTEGRATION_CONTRACT_FIELDS
|
||||
value = policy.get("c5_contract")
|
||||
if not isinstance(value, Mapping) or set(value) != required_c5:
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if any(not isinstance(v, str) for v in value.values()):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
c5_contract = dict(value)
|
||||
oid_fields = {"authority_base_oid", "accepted_base_oid", "rollback_target_oid"}
|
||||
if is_integration:
|
||||
oid_fields |= {
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"repair_source_commit", "repair_tree_oid",
|
||||
}
|
||||
sha_fields = {"rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
sha_fields.add("publication_plan_sha256")
|
||||
if (
|
||||
not c5_contract["task_id"]
|
||||
or c5_contract["rollback_version"] != "V2R2C4"
|
||||
or c5_contract["accepted_base_oid"] != c5_contract["rollback_target_oid"]
|
||||
or any(len(c5_contract[name]) != 40 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in oid_fields)
|
||||
or any(len(c5_contract[name]) != 64 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in sha_fields)
|
||||
):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
@@ -210,6 +260,7 @@ class TrustedCandidateBuilderConfig:
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
c5_contract=c5_contract,
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
@@ -231,6 +282,36 @@ class TrustedCandidateBuilderConfig:
|
||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(version)
|
||||
if recipe is not None:
|
||||
if recipe.get("mode") in {"governed-source-admission-only", "governed-source-integration-only"}:
|
||||
if version != "V2R2C5" or self.c5_contract is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "tree_oid"}
|
||||
if set(admitted) != required or admitted.get("mode") != "governed-source-admission" or admitted.get("version") != version or admitted.get("source_commit") != source_commit or admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if (not isinstance(admitted.get("candidate_id"), str) or not admitted.get("candidate_id")
|
||||
or admitted.get("task_id") != self.c5_contract["task_id"]
|
||||
or admitted.get("base_oid") != self.c5_contract["authority_base_oid"]):
|
||||
raise BuilderError("SOURCE_ADMISSION_BINDING_INVALID")
|
||||
effective = str(candidate_id or admitted["candidate_id"])
|
||||
if effective != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit", "tree_oid"):
|
||||
value = admitted.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if recipe.get("mode") == "governed-source-integration-only" and (
|
||||
source_commit != self.c5_contract["integration_source_commit"]
|
||||
or admitted["tree_oid"] != self.c5_contract["integration_tree_oid"]
|
||||
):
|
||||
raise BuilderError("SOURCE_INTEGRATION_BINDING_INVALID")
|
||||
return {**admitted, **self.c5_contract}
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
@@ -247,6 +328,8 @@ class TrustedCandidateBuilderConfig:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
if self.c5_contract is not None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
@@ -263,8 +346,9 @@ class TrustedCandidateBuilderConfig:
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
allowed_fields = (required, required | {"tree_oid"}) if version == "V2R2C5" else (required,)
|
||||
if (
|
||||
set(admitted) != required
|
||||
set(admitted) not in allowed_fields
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
@@ -275,7 +359,7 @@ class TrustedCandidateBuilderConfig:
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit"):
|
||||
for name in ("base_oid", "source_commit", *(["tree_oid"] if "tree_oid" in admitted else [])):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
@@ -395,7 +479,44 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def _validate_integration_source(self, recipe: Mapping[str, str]) -> None:
|
||||
if "integration_source_commit" not in recipe:
|
||||
return
|
||||
|
||||
def read(*arguments: str, binary: bool = False):
|
||||
try:
|
||||
result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", *arguments],
|
||||
check=True, capture_output=True, text=not binary, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return result.stdout if binary else result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_UNAVAILABLE") from exc
|
||||
|
||||
source = recipe["source_commit"]
|
||||
if source != recipe["integration_source_commit"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
for commit_field, tree_field in (
|
||||
("integration_source_commit", "integration_tree_oid"),
|
||||
("publication_source_commit", "publication_tree_oid"),
|
||||
("repair_source_commit", "repair_tree_oid"),
|
||||
):
|
||||
if read("rev-parse", f"{recipe[commit_field]}^{{tree}}") != recipe[tree_field]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
if read("show", "-s", "--format=%P", source) != recipe["authority_base_oid"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
changed = read("diff", "--name-only", recipe["authority_base_oid"], source).splitlines()
|
||||
if sorted(changed) != sorted(C5_INTEGRATION_FILES):
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_FILE_SET_MISMATCH")
|
||||
for path, (component, digest) in C5_INTEGRATION_FILES.items():
|
||||
integrated = read("show", f"{source}:{path}", binary=True)
|
||||
original = read("show", f"{recipe[component]}:{path}", binary=True)
|
||||
if integrated != original or hashlib.sha256(integrated).hexdigest() != digest:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_BYTES_MISMATCH")
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self._validate_integration_source(recipe)
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
@@ -412,6 +533,16 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("tree_oid") is not None and recipe.get("tree_oid") != tree_oid:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("accepted_base_oid") is not None:
|
||||
accepted_ancestry = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["accepted_base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if getattr(accepted_ancestry, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
@@ -488,6 +619,13 @@ class ServerOwnedEvidenceSource:
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if recipe.get("rollback_version") is not None:
|
||||
if deployment.get("rollback_target") != {
|
||||
"version": recipe["rollback_version"],
|
||||
"target_oid": recipe["rollback_target_oid"],
|
||||
"artifact_sha256": recipe["rollback_artifact_sha256"],
|
||||
}:
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
|
||||
@@ -18,7 +18,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import fcntl
|
||||
try:
|
||||
import fcntl
|
||||
except ModuleNotFoundError: # pragma: no cover - production host is POSIX
|
||||
fcntl = None
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -376,6 +379,8 @@ class FileLock:
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "FileLock":
|
||||
if fcntl is None:
|
||||
raise ReleaseBlocked("production executor file locking requires POSIX")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
@@ -388,7 +393,7 @@ class FileLock:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
if self.handle is not None and fcntl is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Fixed registered composition of the existing shadow API and renewal store."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
ORIGINAL_SHA = 'f6393fe614239036a1d8235c98c1f455dc32b700107d88f48190c4f61dae617e'
|
||||
|
||||
|
||||
def service_type(types, renewal, public_key):
|
||||
class RegisteredService(types.SilentReleaseActionService):
|
||||
def __init__(self, root, **kwargs):
|
||||
if 'completion_approvals' in kwargs:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_DEPENDENCY_REQUIRED')
|
||||
original = types.SignedApprovalStore(Path(root) / 'approvals')
|
||||
completion = renewal.RegisteredCompletionApprovalStore(
|
||||
original, public_key, types.SignedReleasePlanApproval,
|
||||
)
|
||||
super().__init__(root, completion_approvals=completion, **kwargs)
|
||||
return RegisteredService
|
||||
|
||||
|
||||
def main():
|
||||
# Original Round3 startup and verifier remain loaded before the new root
|
||||
# is added. The C5 types are a separate registered module as in R2.
|
||||
import silent_release_hardening as original
|
||||
import hashlib
|
||||
if hashlib.sha256(Path(original.__file__).read_bytes()).hexdigest() != ORIGINAL_SHA:
|
||||
raise RuntimeError('ROUND3_HARDENING_DRIFT')
|
||||
if not getattr(original.BuilderAttestationVerifier.verify,
|
||||
'__aios_round3_rollback_contract_patch__', False):
|
||||
raise RuntimeError('ROUND3_VERIFIER_NOT_ACTIVE')
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
if Path(__file__) != root / 'c5_completion_api.py':
|
||||
raise renewal.RenewalRejected('REGISTERED_API_WRAPPER_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
keyring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if keyring.get('algorithm') != 'Ed25519' or parent['key_id'] in keyring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('REGISTERED_APPROVAL_KEY_REQUIRED')
|
||||
public_key = base64.b64decode(keyring['keys'][parent['key_id']], validate=True)
|
||||
renewal.verify_signature(parent, public_key)
|
||||
spec = importlib.util.spec_from_file_location('_registered_c5_completion_types',
|
||||
root / 'silent_release_hardening.py')
|
||||
types = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = types
|
||||
spec.loader.exec_module(types)
|
||||
loaded = runpy.run_path(str(root / 'original_api.py'), run_name='_registered_original_api')
|
||||
g = loaded['main'].__globals__
|
||||
if g['SILENT_RELEASE_EXECUTION_ENABLED'] is not False or Path(g['SILENT_RELEASE_STATE_ROOT']) != renewal.STATE_ROOT:
|
||||
raise renewal.RenewalRejected('EXISTING_SHADOW_PLANE_REQUIRED')
|
||||
g['ReleasePlan'] = types.ReleasePlan
|
||||
g['TrustedCandidateBundle'] = types.TrustedCandidateBundle
|
||||
g['SilentReleaseActionService'] = service_type(types, renewal, public_key)
|
||||
g['TRUSTED_RELEASE_POLICY'] = renewal.TRUSTED_RELEASE_POLICY_PATH
|
||||
service = g['_build_silent_release_service']()
|
||||
if service.execution_enabled or service.policy_sha256 != renewal.TRUSTED_RELEASE_POLICY_SHA256:
|
||||
raise renewal.RenewalRejected('EXACT_API_POLICY_REQUIRED')
|
||||
# Preserve the exact candidate boundary from the existing registered R2.
|
||||
def reject_candidate_creation(payload):
|
||||
raise original.CapabilityAuthorizationError('completion mode cannot create or replace candidates')
|
||||
service.create_release_candidate = reject_candidate_creation
|
||||
g['_SILENT_RELEASE_SERVICE'] = service
|
||||
g['main']()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Completion-only fixed tag and metadata adapter launcher, no runtime effects."""
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
UNIT = 'aios-c5-exact-production-host-r1.service'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('adapter')
|
||||
parser.add_argument('mode', choices=('tag', 'registry', 'baseline'))
|
||||
args = parser.parse_args()
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
from silent_release_hardening import SignedApprovalStore, SignedReleasePlanApproval
|
||||
if Path(__file__) != root / 'c5_completion_phase.py' or os.geteuid() != 0:
|
||||
raise renewal.RenewalRejected('REGISTERED_COMPLETION_PHASE_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if credentials != Path('/run/credentials') / UNIT:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_PURPOSE_UNIT_REQUIRED')
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/' + UNIT)
|
||||
for line in Path('/proc/self/cgroup').read_text().splitlines()):
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_CGROUP_REQUIRED')
|
||||
module = 'governed_tag_adapter.py' if args.mode == 'tag' else 'release_metadata_adapter.py'
|
||||
if Path(args.adapter) != ROOT / module:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_ADAPTER_REQUIRED')
|
||||
value = json.load(sys.stdin)
|
||||
fixed = {'schema': 'aios.production-executor-request.v1', 'version': 'V2R2C5',
|
||||
'target_oid': renewal.SOURCE, 'evidence_closure_oid': renewal.SOURCE,
|
||||
'artifact_sha256': renewal.ARTIFACT,
|
||||
'approval_plan_id': 'sha256:d8f64b8b92f9fdb17f5c6053b76a68ee5bcbc2f266d91438c4db7893463079f8',
|
||||
'repository_identity': 'aios/aios', 'production_service': 'aios-v2r2c2-production.service',
|
||||
'production_port': 18928}
|
||||
if args.mode != 'tag':
|
||||
fixed['tag_oid'] = renewal.TAG_OID
|
||||
if (set(value) != set(fixed) | {'operation'} or value['operation'] not in {'probe', 'apply'}
|
||||
or any(value[k] != v for k, v in fixed.items())):
|
||||
raise renewal.RenewalRejected('EXACT_COMPLETION_REQUEST_REQUIRED')
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
ring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if parent['key_id'] in ring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('APPROVAL_KEY_REVOKED')
|
||||
public_key = base64.b64decode(ring['keys'][parent['key_id']], validate=True)
|
||||
approval = renewal.RegisteredCompletionApprovalStore(
|
||||
SignedApprovalStore(renewal.STATE_ROOT / 'approvals'), public_key, SignedReleasePlanApproval,
|
||||
).load(renewal.CID)
|
||||
if approval.fingerprint == renewal.OLD_FP:
|
||||
raise renewal.RenewalRejected('FRESH_COMPLETION_SUCCESSOR_REQUIRED')
|
||||
# Loading the store verifies both actual signatures, registered fixed nonce,
|
||||
# append-only chain and current successor lifetime. The historical proof
|
||||
# intentionally remains bound to the original approval, never rewritten.
|
||||
runtime = json.load(urllib.request.urlopen('http://127.0.0.1:18928/health', timeout=10))
|
||||
if any(runtime.get(k) != v for k, v in {
|
||||
'version': 'V2R2C5', 'commit_oid': renewal.SOURCE, 'artifact_sha256': renewal.ARTIFACT,
|
||||
'deployment_state': 'ACTIVE', 'production_deployed': True}.items()):
|
||||
raise renewal.RenewalRejected('CURRENT_C5_RUNTIME_REQUIRED')
|
||||
# Exact signed copies of these historical helpers are read-only here.
|
||||
# Never call their activation, run(), or verification-phase effect methods.
|
||||
import production_verification_phase_r1 as business
|
||||
import public_business_acceptance_probe_r1 as public
|
||||
business.RegisteredVerificationPhase.business_proof(None, True)
|
||||
public.reconcile()
|
||||
os.environ.clear()
|
||||
os.environ.update(PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
PYTHONDONTWRITEBYTECODE='1', AIOS_GITOPS_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_LEGACY_GLOBAL_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_CONTROLLER_WORKSPACE=str(renewal.CONTROLLER_WORKSPACE),
|
||||
AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=str(renewal.AUTHORITATIVE_GIT_DIR),
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY='aios/aios',
|
||||
AIOS_RUNTIME_REGISTRY_ROOT=str(renewal.RUNTIME_REGISTRY_ROOT),
|
||||
AIOS_RUNTIME_BASELINE_PATH=str(renewal.RUNTIME_BASELINE_PATH))
|
||||
if args.mode == 'tag':
|
||||
os.environ.update(GIT_COMMITTER_NAME='owner', GIT_COMMITTER_EMAIL='owner@cnnetmkt.top',
|
||||
GIT_COMMITTER_DATE='1790826063 +0000', GIT_TERMINAL_PROMPT='0',
|
||||
GIT_ASKPASS=str(root / 'publisher-askpass.py'))
|
||||
sys.argv = [module, '--workspace', str(renewal.CONTROLLER_WORKSPACE),
|
||||
'--repository', str(renewal.AUTHORITATIVE_GIT_DIR), '--remote', 'gitea-http',
|
||||
'--gitea-api', 'http://127.0.0.1:3000/api/v1', '--gitea-repo', 'aios/aios',
|
||||
'--gitea-token-file', str(credentials / 'gitea-release-publisher'),
|
||||
'--state-root', str(renewal.HISTORICAL_ROOT / 'tag-publication')]
|
||||
else:
|
||||
sys.argv = [module, '--mode', args.mode, '--expected-previous-baseline', 'V2R2C4',
|
||||
'--expected-previous-baseline-sha256', 'de0c7008facc44c5061239ee490135b58e521989952918f84a57cecb3358483c',
|
||||
'--health-url', 'http://127.0.0.1:18928/health']
|
||||
sys.stdin = io.StringIO(json.dumps(value))
|
||||
runpy.run_path(str(root / module), run_name='__main__')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Append-only C5 completion renewal boundary; activation requires registration.
|
||||
|
||||
The exact Owner-registered API, signer and completion host must call this
|
||||
boundary. This module does not grant authority or perform deployment effects.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
CID = 'rc-v2r2c5-5f65e458957d'
|
||||
# Immutable release content remains compiled into the validator. Renewable
|
||||
# authority identities and filesystem locations are installed only from the
|
||||
# digest-bound, root-owned registration file (see configure_registered_bindings).
|
||||
OLD_FP = '0' * 64
|
||||
SOURCE = '5f65e458957dd51c9973cfa956a624f3cbf54990'
|
||||
TREE = 'ddd239d43d63a7fbd3cff46119c71466c9443737'
|
||||
ARTIFACT = '7f9d8409f07a5e0d36f5db7c55eb82ea70935fa731e057998f29cd1a5ca0a9ad'
|
||||
PLAN = '370521afcf9b2288f6ad3803ca3554a46ee05d2042d53b19b4f4cb80e37a9684'
|
||||
MUTABLE = {'issued_at', 'not_before', 'expires_at', 'nonce', 'fingerprint', 'signature'}
|
||||
CAPABILITIES = ['baseline_registry_finalize', 'runtime_switch', 'tag_publish']
|
||||
AUTHORITY_ROOT = Path('/unconfigured/c5-completion-authority')
|
||||
STATE_ROOT = Path('/unconfigured/c5-completion-state')
|
||||
OWNER_KEY = 'UNCONFIGURED'
|
||||
TAG_OID = '6d1745ff496103db7e721a2b2bc624fda52c5dc8'
|
||||
SAGA_HEAD = '0' * 64
|
||||
HISTORICAL_ROOT = Path('/unconfigured/c5-completion-history')
|
||||
CONTROLLER_WORKSPACE = Path('/unconfigured/c5-controller-workspace')
|
||||
AUTHORITATIVE_GIT_DIR = Path('/unconfigured/c5-authoritative-git')
|
||||
RUNTIME_REGISTRY_ROOT = Path('/unconfigured/c5-runtime-registry')
|
||||
RUNTIME_BASELINE_PATH = Path('/unconfigured/c5-runtime-baseline.json')
|
||||
TRUSTED_RELEASE_POLICY_PATH = Path('/unconfigured/c5-trusted-release-policy.json')
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = '0' * 64
|
||||
BINDINGS_PATH_ENV = 'AIOS_C5_COMPLETION_BINDINGS_PATH'
|
||||
BINDINGS_SHA256_ENV = 'AIOS_C5_COMPLETION_BINDINGS_SHA256'
|
||||
_BINDINGS_DIGEST = None
|
||||
RENEWAL_SOURCE_FILES = ('c5_completion_renewal.py', 'c5_release_execution_host.py',
|
||||
'silent_release_hardening.py', 'c5_completion_signer.py',
|
||||
'c5_completion_api.py', 'c5_completion_phase.py') # gitleaks:allow -- filenames only
|
||||
PHASE_DEPENDENCIES = ('governed_tag_adapter.py', 'release_metadata_adapter.py',
|
||||
'production_verification_phase_r1.py', 'public_business_acceptance_probe_r1.py',
|
||||
'publisher-askpass.py', 'c5_staging_installation.py',
|
||||
'aios_single_host_authority.py', 'registered_owner_signer_adapter.py')
|
||||
|
||||
|
||||
class RenewalRejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_completion_lineage(gate):
|
||||
"""Bind a current completion-only journal suffix to the frozen Saga15 head.
|
||||
|
||||
The suffix is authenticated by the enclosing registered Owner contract;
|
||||
these checks preserve its origin and exclude deployment/source transitions.
|
||||
"""
|
||||
revision = gate.get('saga_revision')
|
||||
suffix = gate.get('completion_journal_suffix', [])
|
||||
observed = gate.get('observed_at')
|
||||
if (type(revision) is not int or revision < 15 or not isinstance(suffix, list)
|
||||
or type(observed) not in {int, float} or not math.isfinite(observed)):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if revision == 15:
|
||||
if gate.get('saga_head') != SAGA_HEAD or suffix:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
return
|
||||
if not 0 < len(suffix) <= 64 or len(suffix) != revision - 15:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
allowed = {
|
||||
'owner_registered_completion_successor_tag_reconciliation': ('TAG_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'owner_registered_tag_capability_successor_verified': ('TAG_VERIFIED', {'RECONCILIATION_REQUIRED', 'TAG_PENDING'}),
|
||||
'baseline_registry_finalization_pending': ('BASELINE_REGISTRY_PENDING', {'TAG_VERIFIED'}),
|
||||
'owner_registered_completion_successor_metadata_reconciliation': ('BASELINE_REGISTRY_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'release_reconciliation_required': ('RECONCILIATION_REQUIRED', {'TAG_PENDING', 'TAG_VERIFIED', 'BASELINE_REGISTRY_PENDING'}),
|
||||
}
|
||||
previous, state = SAGA_HEAD, 'RECONCILIATION_REQUIRED'
|
||||
for offset, event in enumerate(suffix, 16):
|
||||
if (not isinstance(event, dict)
|
||||
or set(event) != {'schema_version', 'sequence', 'previous_hash', 'payload', 'event_hash'}
|
||||
or type(event['schema_version']) is not int or event['schema_version'] != 1
|
||||
or type(event['sequence']) is not int or event['sequence'] != offset + 1
|
||||
or event['previous_hash'] != previous):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
payload = event['payload']
|
||||
if (not isinstance(payload, dict)
|
||||
or set(payload) != {'candidate_id', 'release_plan_id', 'revision', 'state', 'event', 'timestamp', 'details'}
|
||||
or payload['candidate_id'] != CID
|
||||
or payload['release_plan_id'] != 'pending-' + CID
|
||||
or type(payload['revision']) is not int or payload['revision'] != offset
|
||||
or not isinstance(payload['details'], dict)
|
||||
or not isinstance(payload['event'], str) or payload['event'] not in allowed):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
target, origins = allowed[payload['event']]
|
||||
if payload['state'] != target or state not in origins:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if ('release_plan_sha256' in payload['details']
|
||||
and payload['details']['release_plan_sha256'] != PLAN):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
try:
|
||||
if timestamp(payload['timestamp']) > gate['observed_at']:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
actual = digest(canonical({key: event[key] for key in ('sequence', 'previous_hash', 'payload')}))
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as exc:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID') from exc
|
||||
if event['event_hash'] != actual:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
previous, state = actual, target
|
||||
if gate.get('saga_head') != previous:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise RenewalRejected('UTC_REQUIRED')
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def verify_signature(approval, public_key):
|
||||
payload = {k: v for k, v in approval.items() if k not in {'signature', 'fingerprint'}}
|
||||
raw = canonical(payload)
|
||||
if approval.get('fingerprint') != digest(raw):
|
||||
raise RenewalRejected('APPROVAL_DIGEST_INVALID')
|
||||
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
||||
base64.b64decode(approval['signature'], validate=True), raw
|
||||
)
|
||||
|
||||
|
||||
def validate_successor(old, new, public_key, *, now):
|
||||
"""Validate historical predecessor and fresh identical-binding successor.
|
||||
|
||||
Historical signature verification does not authorize an expired effect.
|
||||
The caller must separately verify its fresh registered Owner capability.
|
||||
"""
|
||||
verify_signature(old, public_key)
|
||||
verify_signature(new, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES}
|
||||
if any(old.get(k) != v for k, v in exact.items()):
|
||||
raise RenewalRejected('EXACT_PREDECESSOR_REQUIRED')
|
||||
if set(old) != set(new) or any(new[k] != old[k] for k in set(old) - MUTABLE):
|
||||
raise RenewalRejected('IMMUTABLE_APPROVAL_BINDING_DRIFT')
|
||||
if timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('PREDECESSOR_NOT_EXPIRED')
|
||||
issued, not_before, expires = (timestamp(new[k]) for k in ['issued_at', 'not_before', 'expires_at'])
|
||||
if not issued <= not_before <= now < expires or not 0 < expires - issued <= 900:
|
||||
raise RenewalRejected('FRESH_BOUNDED_APPROVAL_REQUIRED')
|
||||
if new['nonce'] == old['nonce'] or new['fingerprint'] == old['fingerprint']:
|
||||
raise RenewalRejected('FRESH_APPROVAL_IDENTITY_REQUIRED')
|
||||
return new['fingerprint']
|
||||
|
||||
|
||||
def secure_bytes(path, owner_uid=0):
|
||||
path = Path(path)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
with os.fdopen(descriptor, 'rb') as stream:
|
||||
metadata = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != owner_uid or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_RENEWAL_FILE')
|
||||
return stream.read()
|
||||
|
||||
|
||||
def _safe_parents(path):
|
||||
"""Reject writable or symlinked ancestors before opening authority files."""
|
||||
path = Path(path)
|
||||
if not path.is_absolute() or '..' in path.parts:
|
||||
raise RenewalRejected('ABSOLUTE_CANONICAL_PATH_REQUIRED')
|
||||
for parent in path.parents:
|
||||
info = parent.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_AUTHORITY_ANCESTOR')
|
||||
|
||||
|
||||
def _sha(value):
|
||||
return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def _oid(value):
|
||||
return isinstance(value, str) and len(value) == 40 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def configure_registered_bindings(path=None, expected_sha256=None, *, owner_uid=0):
|
||||
"""Install renewable completion bindings from one authenticated file.
|
||||
|
||||
No historical approval fingerprint, Saga head, Owner key, or authority/state
|
||||
path is a runtime default. The service registration must provide both the
|
||||
absolute file path and its SHA-256 digest.
|
||||
"""
|
||||
global _BINDINGS_DIGEST
|
||||
path = Path(path or os.environ.get(BINDINGS_PATH_ENV, ''))
|
||||
expected_sha256 = expected_sha256 or os.environ.get(BINDINGS_SHA256_ENV, '')
|
||||
if not path.is_absolute() or not _sha(expected_sha256):
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_BINDINGS_REQUIRED')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path, owner_uid=owner_uid)
|
||||
if digest(raw) != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_DIGEST_MISMATCH')
|
||||
if _BINDINGS_DIGEST is not None and _BINDINGS_DIGEST != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_REBIND_FORBIDDEN')
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_INVALID') from exc
|
||||
fields = {
|
||||
'schema', 'authority_root', 'state_root', 'owner_key_id',
|
||||
'predecessor_fingerprint', 'saga_head', 'historical_root',
|
||||
'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path',
|
||||
'trusted_release_policy_path', 'trusted_release_policy_sha256',
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != fields or value.get('schema') != 'aios.c5.completion-bindings.v1':
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_SCHEMA_INVALID')
|
||||
authority_root = Path(value['authority_root'])
|
||||
state_root = Path(value['state_root'])
|
||||
governed_paths = {
|
||||
name: Path(value[name]) for name in (
|
||||
'historical_root', 'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path', 'trusted_release_policy_path',
|
||||
)
|
||||
}
|
||||
owner_key_id = value['owner_key_id']
|
||||
try:
|
||||
owner_key_valid = (isinstance(owner_key_id, str)
|
||||
and owner_key_id.startswith('SHA256:')
|
||||
and len(base64.b64decode(owner_key_id[7:], validate=True)) == 32)
|
||||
except (ValueError, TypeError):
|
||||
owner_key_valid = False
|
||||
all_paths = (authority_root, state_root, *governed_paths.values())
|
||||
if (any(not item.is_absolute() or '..' in item.parts for item in all_paths)
|
||||
or not _sha(value['predecessor_fingerprint']) or not _sha(value['saga_head'])
|
||||
or not _sha(value['trusted_release_policy_sha256'])
|
||||
or not owner_key_valid):
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_VALUE_INVALID')
|
||||
if path.parent != authority_root:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_ROOT_MISMATCH')
|
||||
global AUTHORITY_ROOT, STATE_ROOT, OWNER_KEY, OLD_FP, SAGA_HEAD
|
||||
global HISTORICAL_ROOT, CONTROLLER_WORKSPACE, AUTHORITATIVE_GIT_DIR
|
||||
global RUNTIME_REGISTRY_ROOT, RUNTIME_BASELINE_PATH
|
||||
global TRUSTED_RELEASE_POLICY_PATH, TRUSTED_RELEASE_POLICY_SHA256
|
||||
AUTHORITY_ROOT = authority_root
|
||||
STATE_ROOT = state_root
|
||||
OWNER_KEY = value['owner_key_id']
|
||||
OLD_FP = value['predecessor_fingerprint']
|
||||
SAGA_HEAD = value['saga_head']
|
||||
HISTORICAL_ROOT = governed_paths['historical_root']
|
||||
CONTROLLER_WORKSPACE = governed_paths['controller_workspace']
|
||||
AUTHORITATIVE_GIT_DIR = governed_paths['authoritative_git_dir']
|
||||
RUNTIME_REGISTRY_ROOT = governed_paths['runtime_registry_root']
|
||||
RUNTIME_BASELINE_PATH = governed_paths['runtime_baseline_path']
|
||||
TRUSTED_RELEASE_POLICY_PATH = governed_paths['trusted_release_policy_path']
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = value['trusted_release_policy_sha256']
|
||||
_BINDINGS_DIGEST = expected_sha256
|
||||
return dict(value)
|
||||
|
||||
|
||||
class RegisteredCompletionAuthority:
|
||||
"""Fixed root registration, Owner signature and immutable evidence verifier.
|
||||
|
||||
The completed production proofs are historical facts. This fresh authority
|
||||
permits only approval renewal and remaining metadata effects; it cannot
|
||||
authorize another runtime switch or public acceptance Task.
|
||||
"""
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return AUTHORITY_ROOT
|
||||
|
||||
@property
|
||||
def state_root(self):
|
||||
return STATE_ROOT
|
||||
|
||||
def __call__(self):
|
||||
from c5_staging_installation import RegistryOwnerVerifier
|
||||
root = AUTHORITY_ROOT
|
||||
_safe_parents(root / 'contract.json')
|
||||
envelope = json.loads(secure_bytes(root / 'contract.json'))
|
||||
if set(envelope) != {'payload', 'signature'}:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_ENVELOPE_REQUIRED')
|
||||
p = envelope['payload']
|
||||
fields = {'schema', 'host', 'repository', 'candidate_id', 'source_commit', 'tree_oid',
|
||||
'artifact_sha256', 'release_plan_sha256', 'state_root', 'predecessor_fingerprint',
|
||||
'predecessor_sha256', 'predecessor_uid', 'predecessor_snapshot', 'approval_nonce',
|
||||
'issued_at', 'expires_at', 'owner_key_id', 'owner_namespace', 'allowed_effects',
|
||||
'runtime_effects_allowed', 'files', 'gate_path', 'gate_observed_at',
|
||||
'governed_source_commit', 'governed_source_tree'}
|
||||
if not isinstance(p, dict) or set(p) != fields:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_SCHEMA_REQUIRED')
|
||||
exact = {'schema': 'aios.c5.registered-completion-authority.v1', 'host': 'cn-network-mkt',
|
||||
'repository': 'aios/aios', 'candidate_id': CID, 'source_commit': SOURCE,
|
||||
'tree_oid': TREE, 'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'state_root': str(STATE_ROOT), 'predecessor_fingerprint': OLD_FP,
|
||||
'owner_key_id': OWNER_KEY, 'owner_namespace': 'aios-owner-approval-v1',
|
||||
'allowed_effects': ['approval_successor_append', 'baseline_finalize', 'registry_finalize', 'tag_publish'],
|
||||
'runtime_effects_allowed': False}
|
||||
if any(p[k] != v for k, v in exact.items()) or type(p['runtime_effects_allowed']) is not bool:
|
||||
raise RenewalRejected('EXACT_COMPLETION_AUTHORITY_REQUIRED')
|
||||
now = time.time()
|
||||
if (type(p['issued_at']) is not int or type(p['expires_at']) is not int
|
||||
or not p['issued_at'] <= now < p['expires_at']
|
||||
or not 0 < p['expires_at'] - p['issued_at'] <= 1800
|
||||
or type(p['gate_observed_at']) not in {int, float}
|
||||
or not math.isfinite(p['gate_observed_at'])
|
||||
or not p['issued_at'] - 60 <= p['gate_observed_at'] <= p['issued_at']):
|
||||
raise RenewalRejected('FRESH_OWNER_AUTHORITY_REQUIRED')
|
||||
if (not _sha(p['approval_nonce']) or not _sha(p['predecessor_sha256'])
|
||||
or type(p['predecessor_uid']) is not int or p['predecessor_uid'] not in {0, 1000}
|
||||
or any(not isinstance(p[k], str) or len(p[k]) != 40
|
||||
or any(c not in '0123456789abcdef' for c in p[k])
|
||||
for k in ('governed_source_commit', 'governed_source_tree'))):
|
||||
raise RenewalRejected('AUTHORITY_IDENTITIES_INVALID')
|
||||
verifier = RegistryOwnerVerifier(Path('/etc/aios/single-host-signer-registry.json'),
|
||||
owner_signer_id='local-owner-authority')
|
||||
if not verifier.verify(namespace=p['owner_namespace'], payload=canonical(p),
|
||||
signature=envelope['signature'], key_id=p['owner_key_id']):
|
||||
raise RenewalRejected('OWNER_SIGNATURE_REJECTED')
|
||||
payload_sha = digest(canonical(p))
|
||||
registered = json.loads(secure_bytes(root / 'registration.json'))
|
||||
if (set(registered) != {'schema', 'status', 'payload_sha256', 'approval_nonce'}
|
||||
or registered != {'schema': 'aios.c5.completion-registration.v1', 'status': 'REGISTERED',
|
||||
'payload_sha256': payload_sha, 'approval_nonce': p['approval_nonce']}):
|
||||
raise RenewalRejected('ROOT_REGISTRATION_REQUIRED')
|
||||
if (root / 'rollback.json').exists():
|
||||
raise RenewalRejected('REGISTRATION_ROLLED_BACK')
|
||||
pins = p['files']
|
||||
if not isinstance(pins, list) or not pins:
|
||||
raise RenewalRejected('SIGNED_FILE_CLOSURE_REQUIRED')
|
||||
checked = {}
|
||||
for item in pins:
|
||||
if (not isinstance(item, dict) or set(item) != {'path', 'sha256', 'uid'}
|
||||
or not _sha(item['sha256']) or type(item['uid']) is not int or item['uid'] != 0):
|
||||
raise RenewalRejected('SIGNED_FILE_PIN_INVALID')
|
||||
path = Path(item['path'])
|
||||
if path.parent != root or str(path) in checked:
|
||||
raise RenewalRejected('SIGNED_FILE_PATH_INVALID')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path)
|
||||
if digest(raw) != item['sha256']:
|
||||
raise RenewalRejected('SIGNED_FILE_BYTES_DRIFT')
|
||||
checked[str(path)] = raw
|
||||
required = {str(root / name) for name in (*RENEWAL_SOURCE_FILES, *PHASE_DEPENDENCIES, 'source-material.json',
|
||||
'historical-production-proof.json', 'predecessor-approval.json', 'current-gate.json',
|
||||
'approval-public-keyring.json', 'original_api.py')}
|
||||
if not required <= set(checked):
|
||||
raise RenewalRejected('INCOMPLETE_REGISTERED_CLOSURE')
|
||||
if Path(__file__) != root / 'c5_completion_renewal.py':
|
||||
raise RenewalRejected('UNREGISTERED_AUTHORITY_MODULE')
|
||||
if (p['predecessor_snapshot'] != str(root / 'predecessor-approval.json')
|
||||
or p['gate_path'] != str(root / 'current-gate.json')
|
||||
or digest(checked[p['predecessor_snapshot']]) != p['predecessor_sha256']):
|
||||
raise RenewalRejected('REGISTERED_SNAPSHOT_DRIFT')
|
||||
material = json.loads(checked[str(root / 'source-material.json')])
|
||||
if (material.get('status') != 'MATERIALIZED' or material.get('task_status') != 'SUCCEEDED'
|
||||
or material.get('remote_verified') is not True
|
||||
or material.get('source_commit') != p['governed_source_commit']
|
||||
or material.get('source_tree') != p['governed_source_tree']
|
||||
or material.get('file_sha256') != {
|
||||
name: digest(checked[str(root / name)])
|
||||
for name in RENEWAL_SOURCE_FILES}):
|
||||
raise RenewalRejected('GOVERNED_CODE_PROVENANCE_REQUIRED')
|
||||
historical = json.loads(checked[str(root / 'historical-production-proof.json')])
|
||||
if (historical.get('candidate_id') != CID or historical.get('approval_fingerprint') != OLD_FP
|
||||
or historical.get('release_plan_sha256') != PLAN
|
||||
or historical.get('status') != 'COMPLETED'
|
||||
or any(historical.get(k) is not True for k in (
|
||||
'public_business_verified', 'real_c4_rollback_verified', 'c5_restore_verified'))):
|
||||
raise RenewalRejected('COMPLETED_PRODUCTION_PROOF_REQUIRED')
|
||||
gate = json.loads(checked[p['gate_path']])
|
||||
expected_gate = {'candidate_id': CID, 'source_commit': SOURCE, 'artifact_sha256': ARTIFACT,
|
||||
'release_plan_sha256': PLAN,
|
||||
'local_tag_oid': TAG_OID, 'runtime_verified': True, 'business_verified': True,
|
||||
'global_writes_enabled': False, 'observed_at': p['gate_observed_at']}
|
||||
if (any(gate.get(k) != v for k, v in expected_gate.items())
|
||||
or any(type(gate.get(k)) is not bool for k in (
|
||||
'runtime_verified', 'business_verified', 'global_writes_enabled'))
|
||||
or gate.get('remote_tag_oid') not in {None, TAG_OID}):
|
||||
raise RenewalRejected('EXACT_POSTVERIFICATION_GATE_REQUIRED')
|
||||
validate_completion_lineage(gate)
|
||||
return {**p, 'payload_sha256': payload_sha}
|
||||
|
||||
def validate_host(self, *, state_root, candidate_id, source_commit, tree_oid,
|
||||
artifact_sha256, release_plan_sha256):
|
||||
self()
|
||||
if (Path(state_root), candidate_id, source_commit, tree_oid, artifact_sha256, release_plan_sha256) != (
|
||||
STATE_ROOT, CID, SOURCE, TREE, ARTIFACT, PLAN):
|
||||
raise RenewalRejected('COMPLETION_HOST_BINDING_DRIFT')
|
||||
|
||||
def deny_runtime_effect(self, **kwargs):
|
||||
self()
|
||||
raise RenewalRejected('COMPLETION_RUNTIME_EFFECT_FORBIDDEN')
|
||||
|
||||
|
||||
class CompletionRuntimeGuard:
|
||||
def __init__(self, authority):
|
||||
if type(authority) is not RegisteredCompletionAuthority:
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_AUTHORITY_REQUIRED')
|
||||
self.authority = authority
|
||||
|
||||
def execute(self, **kwargs):
|
||||
return self.authority.deny_runtime_effect(**kwargs)
|
||||
|
||||
|
||||
def append_file(path, raw):
|
||||
"""An existing record must be identical; it is never replaced."""
|
||||
path = Path(path)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
except FileExistsError:
|
||||
if secure_bytes(path, owner_uid=os.geteuid()) != raw:
|
||||
raise RenewalRejected('APPEND_ONLY_RECORD_CONFLICT')
|
||||
return False
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
|
||||
|
||||
def publish_successor(directory, old, new, public_key, *, now, registration_digest, predecessor_bytes):
|
||||
"""Append records inside the SAME plane under a verified caller lock.
|
||||
|
||||
Registration validation, current Saga CAS, production/business readbacks,
|
||||
and permission to call this function remain mandatory caller gates.
|
||||
"""
|
||||
fp = validate_successor(old, new, public_key, now=now)
|
||||
if json.loads(predecessor_bytes) != old:
|
||||
raise RenewalRejected('PREDECESSOR_BYTES_DRIFT')
|
||||
predecessor_bytes_digest = digest(predecessor_bytes)
|
||||
if len(registration_digest) != 64 or any(c not in '0123456789abcdef' for c in registration_digest):
|
||||
raise RenewalRejected('REGISTERED_DIGEST_REQUIRED')
|
||||
directory = Path(directory)
|
||||
metadata = directory.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_APPEND_DIRECTORY')
|
||||
receipt = {'schema': 'aios.c5.completion-approval-successor.v1', 'candidate_id': CID,
|
||||
'predecessor_fingerprint': OLD_FP, 'predecessor_bytes_sha256': predecessor_bytes_digest,
|
||||
'successor_fingerprint': fp, 'successor_sha256': digest(canonical(new)),
|
||||
'owner_registration_payload_sha256': registration_digest,
|
||||
'completion_only': True, 'runtime_effects_allowed': False}
|
||||
# A crash after the first append is unresolved until its exact receipt is
|
||||
# reconciled. A loader must require BOTH files; no mutable current pointer.
|
||||
append_file(directory / (fp + '.approval.json'), canonical(new))
|
||||
append_file(directory / (fp + '.receipt.json'), canonical(receipt))
|
||||
return receipt
|
||||
|
||||
|
||||
class CompletionApprovalStore:
|
||||
"""Explicit store dependency for a registered completion host/API.
|
||||
|
||||
The original store and original candidate approval remain untouched.
|
||||
No global class replacement or expired approval verification is used.
|
||||
An authenticated registration fixes ONE successor nonce and immutable
|
||||
receipt path before signing. The host must separately enforce TAG_PENDING
|
||||
and forbid runtime effects; this store does not execute a Saga.
|
||||
"""
|
||||
|
||||
def __init__(self, original, directory, public_key, authority, approval_type):
|
||||
self.original = original
|
||||
self.directory = Path(directory)
|
||||
self.public_key = public_key
|
||||
self.authority = authority
|
||||
self.approval_type = approval_type
|
||||
|
||||
def _policy(self):
|
||||
# authority must be the fixed registered verifier, not a caller-supplied
|
||||
# HTTP callback. Wiring it is outside this unregistered draft.
|
||||
policy = self.authority()
|
||||
if policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP:
|
||||
raise RenewalRejected('REGISTERED_EXACT_POLICY_REQUIRED')
|
||||
nonce = policy.get('approval_nonce', '')
|
||||
if not isinstance(nonce, str) or len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce):
|
||||
raise RenewalRejected('REGISTERED_NONCE_REQUIRED')
|
||||
return policy, self.directory / (nonce + '.activation.json')
|
||||
|
||||
def _parent(self):
|
||||
old = self.original.load(CID)
|
||||
policy, _ = self._policy()
|
||||
owner = policy.get('predecessor_uid')
|
||||
if type(owner) is not int or owner not in {0, 1000}:
|
||||
raise RenewalRejected('REGISTERED_PREDECESSOR_OWNER_REQUIRED')
|
||||
raw = secure_bytes(self.original.root / (CID + '.json'), owner_uid=owner)
|
||||
if json.loads(raw) != old.to_dict() or digest(raw) != policy.get('predecessor_sha256'):
|
||||
raise RenewalRejected('PREDECESSOR_FILE_DRIFT')
|
||||
return old.to_dict(), raw
|
||||
|
||||
def load(self, candidate_id):
|
||||
if candidate_id != CID:
|
||||
return self.original.load(candidate_id)
|
||||
policy, activation = self._policy()
|
||||
if not activation.exists():
|
||||
return self.original.load(candidate_id)
|
||||
receipt = json.loads(secure_bytes(activation, owner_uid=policy['predecessor_uid']))
|
||||
expected = {'schema', 'candidate_id', 'predecessor_fingerprint', 'predecessor_bytes_sha256',
|
||||
'successor_fingerprint', 'successor_sha256', 'owner_registration_payload_sha256',
|
||||
'completion_only', 'runtime_effects_allowed'}
|
||||
if set(receipt) != expected or receipt.get('owner_registration_payload_sha256') != policy['payload_sha256']:
|
||||
raise RenewalRejected('ACTIVATION_RECEIPT_INVALID')
|
||||
fp = receipt.get('successor_fingerprint', '')
|
||||
if len(fp) != 64 or any(c not in '0123456789abcdef' for c in fp):
|
||||
raise RenewalRejected('SUCCESSOR_FINGERPRINT_INVALID')
|
||||
raw = secure_bytes(self.directory / (fp + '.approval.json'), owner_uid=policy['predecessor_uid'])
|
||||
new = json.loads(raw)
|
||||
parent, parent_raw = self._parent()
|
||||
valid = validate_successor(parent, new, self.public_key, now=datetime.now(timezone.utc).timestamp())
|
||||
exact = {'candidate_id': CID, 'predecessor_fingerprint': OLD_FP,
|
||||
'predecessor_bytes_sha256': digest(parent_raw), 'successor_fingerprint': valid,
|
||||
'successor_sha256': digest(raw), 'completion_only': True, 'runtime_effects_allowed': False}
|
||||
if any(receipt.get(k) != v for k, v in exact.items()) or new['nonce'] != policy['approval_nonce']:
|
||||
raise RenewalRejected('SUCCESSOR_ACTIVATION_DRIFT')
|
||||
durable = json.loads(secure_bytes(self.directory / (fp + '.receipt.json'), owner_uid=policy['predecessor_uid']))
|
||||
if durable != receipt:
|
||||
raise RenewalRejected('DURABLE_RECEIPT_DRIFT')
|
||||
return self.approval_type.from_dict(new)
|
||||
|
||||
def save(self, candidate_id, approval):
|
||||
if candidate_id != CID:
|
||||
return self.original.save(candidate_id, approval)
|
||||
policy, activation = self._policy()
|
||||
parent, raw = self._parent()
|
||||
if approval.fingerprint == parent['fingerprint']:
|
||||
return self.original.save(candidate_id, approval)
|
||||
new = approval.to_dict()
|
||||
if new.get('nonce') != policy['approval_nonce']:
|
||||
raise RenewalRejected('REGISTERED_NONCE_MISMATCH')
|
||||
# The caller supplies its authenticated registered effect lock and
|
||||
# invokes the existing ApprovalVerifier before this store boundary.
|
||||
receipt = publish_successor(self.directory, parent, new, self.public_key,
|
||||
now=datetime.now(timezone.utc).timestamp(),
|
||||
registration_digest=policy['payload_sha256'], predecessor_bytes=raw)
|
||||
created = append_file(activation, canonical(receipt))
|
||||
if self.load(candidate_id).fingerprint != approval.fingerprint:
|
||||
raise RenewalRejected('SUCCESSOR_READBACK_FAILED')
|
||||
return activation, created
|
||||
|
||||
|
||||
class RegisteredCompletionApprovalStore(CompletionApprovalStore):
|
||||
"""Production dependency; no request-selected root or authority callback."""
|
||||
|
||||
def __init__(self, original, public_key, approval_type):
|
||||
if Path(original.root) != STATE_ROOT / 'approvals':
|
||||
raise RenewalRejected('SAME_APPROVAL_PLANE_REQUIRED')
|
||||
super().__init__(original, STATE_ROOT / 'approvals' / 'c5-completion-successors',
|
||||
public_key, RegisteredCompletionAuthority(), approval_type)
|
||||
|
||||
|
||||
class CompletionSignerMixin:
|
||||
"""Narrow additional method of the EXISTING credential-bound signer.
|
||||
|
||||
Activation must register this exact mixin, verifier and namespace snapshot
|
||||
pins. The encrypted private key stays in the existing signer's unit. This
|
||||
draft supplies no activation, credential access or HTTP route.
|
||||
"""
|
||||
|
||||
def sign_exact_completion_successor(self, request):
|
||||
expected = {'schema', 'operation', 'candidate_id', 'predecessor_fingerprint'}
|
||||
if (set(request) != expected or request.get('operation') != 'renew_exact_completion_approval'
|
||||
or request.get('candidate_id') != CID or request.get('predecessor_fingerprint') != OLD_FP
|
||||
or request.get('schema') != 'aios.exact-release-signer.request.v1'):
|
||||
raise RenewalRejected('EXACT_SIGNER_RENEWAL_REQUEST_REQUIRED')
|
||||
policy = self.completion_authority()
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
if (policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP
|
||||
or not policy['issued_at'] <= now < policy['expires_at']
|
||||
or not 0 <= now - policy['gate_observed_at'] <= 60):
|
||||
raise RenewalRejected('FRESH_REGISTERED_COMPLETION_GATE_REQUIRED')
|
||||
old_raw = secure_bytes(policy['predecessor_snapshot'], owner_uid=0)
|
||||
if digest(old_raw) != policy['predecessor_sha256']:
|
||||
raise RenewalRejected('REGISTERED_SIGNER_PREIMAGE_DRIFT')
|
||||
old = json.loads(old_raw)
|
||||
public_key = self.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
verify_signature(old, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES,
|
||||
'key_id': self.policy.key_id, 'owner_identity': self.policy.owner_identity,
|
||||
'trust_domain': self.policy.trust_domain,
|
||||
'owner_approval_record_oid': self.authorization_oid}
|
||||
if any(old.get(k) != v for k, v in exact.items()) or timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('EXACT_EXPIRED_SIGNER_PREDECESSOR_REQUIRED')
|
||||
nonce = policy['approval_nonce']
|
||||
if len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce) or nonce == old['nonce']:
|
||||
raise RenewalRejected('FRESH_REGISTERED_SIGNER_NONCE_REQUIRED')
|
||||
output = self.state_root / 'completion-renewals' / (nonce + '.json')
|
||||
if output.exists():
|
||||
result = json.loads(secure_bytes(output, owner_uid=0))
|
||||
if (result.get('registration_payload_sha256') != policy['payload_sha256']
|
||||
or result['approval']['nonce'] != nonce):
|
||||
raise RenewalRejected('SIGNER_REPLAY_CONFLICT')
|
||||
validate_successor(old, result['approval'], public_key, now=now)
|
||||
return {**result, 'status': 'SIGNED_IDEMPOTENT'}
|
||||
expiry = min(now + 600, policy['expires_at'] - 5)
|
||||
if expiry - now < 120:
|
||||
raise RenewalRejected('INSUFFICIENT_SIGNER_WINDOW')
|
||||
def utc(value):
|
||||
return datetime.fromtimestamp(value, timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
payload = {k:v for k,v in old.items() if k not in {'fingerprint', 'signature'}}
|
||||
payload.update(issued_at=utc(now), not_before=utc(now), expires_at=utc(expiry), nonce=nonce)
|
||||
raw = canonical(payload)
|
||||
# All candidate, plan, historical signature and registered freshness
|
||||
# checks precede this only signing operation.
|
||||
approval = {**payload, 'fingerprint': digest(raw),
|
||||
'signature': base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
validate_successor(old, approval, public_key, now=now)
|
||||
result = {'schema': 'aios.exact-release-signer.response.v1', 'status': 'SIGNED',
|
||||
'approval': approval, 'registration_payload_sha256': policy['payload_sha256'],
|
||||
'predecessor_fingerprint': OLD_FP, 'completion_only': True, 'arbitrary_signing': False}
|
||||
# This registered directory must be created by the activation consumer
|
||||
# inside the existing signer state root, never a request path.
|
||||
append_file(output, canonical(result))
|
||||
return result
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Registered fixed extension of the existing credential-bound signer unit."""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from c5_completion_renewal import (
|
||||
CompletionSignerMixin, RegisteredCompletionAuthority,
|
||||
RenewalRejected, secure_bytes, digest,
|
||||
)
|
||||
|
||||
ORIGINAL = Path('/usr/local/libexec/aios_exact_release_signer.py')
|
||||
ORIGINAL_SHA = '6e2a5a9df0edfe566175e5b556d15901b2c8fe4484388ad74af2ac64f810c930'
|
||||
|
||||
|
||||
def signer_class(original):
|
||||
class CompletionSigner(CompletionSignerMixin, original.ExactReleaseSigner):
|
||||
def completion_authority(self):
|
||||
return RegisteredCompletionAuthority()()
|
||||
|
||||
def handle(self, request):
|
||||
if request.get('operation') == 'renew_exact_completion_approval':
|
||||
return self.sign_exact_completion_successor(request)
|
||||
return super().handle(request)
|
||||
return CompletionSigner
|
||||
|
||||
|
||||
def main():
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
if Path(__file__) != renewal.AUTHORITY_ROOT / 'c5_completion_signer.py':
|
||||
raise RenewalRejected('REGISTERED_SIGNER_WRAPPER_REQUIRED')
|
||||
RegisteredCompletionAuthority()()
|
||||
if digest(secure_bytes(ORIGINAL)) != ORIGINAL_SHA:
|
||||
raise RenewalRejected('ORIGINAL_SIGNER_PREIMAGE_DRIFT')
|
||||
spec = importlib.util.spec_from_file_location('c5_original_exact_release_signer', ORIGINAL)
|
||||
original = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = original
|
||||
spec.loader.exec_module(original)
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if not credentials.is_absolute() or credentials.parent != Path('/run/credentials'):
|
||||
raise RenewalRejected('EXISTING_SIGNER_PURPOSE_CREDENTIAL_REQUIRED')
|
||||
if credentials.name != 'aios-exact-release-signer.service':
|
||||
raise RenewalRejected('EXISTING_SIGNER_UNIT_REQUIRED')
|
||||
cgroup = Path('/proc/self/cgroup').read_text()
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/aios-exact-release-signer.service')
|
||||
for line in cgroup.splitlines()):
|
||||
raise RenewalRejected('EXISTING_SIGNER_CGROUP_REQUIRED')
|
||||
signer = signer_class(original)(
|
||||
Path('/etc/aios-gitops/exact-release-signer-policy.json'),
|
||||
credentials / 'owner_release_private_key',
|
||||
)
|
||||
original.serve(Path('/run/aios-exact-release-signer/signer.sock'), signer)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Exact C5 runtime-switch adapter for the silent-release Saga.
|
||||
|
||||
The adapter owns only the production deployment phase. Tag publication and
|
||||
Registry/Baseline finalization remain separate ``ReleaseOrchestrator`` Saga
|
||||
steps. A host must register immutable executable bytes and provide the real
|
||||
HMAC-authenticated source-admission store plus the signed Owner approval
|
||||
verifier before this adapter can be constructed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_source_admission import ReleaseSourceAdmissionStore
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import (
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
ReleasePlan,
|
||||
RuntimeSwitchController,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
_canonical_json,
|
||||
)
|
||||
|
||||
|
||||
C5_VERSION = "V2R2C5"
|
||||
C5_PREPUBLICATION_SOURCE_COMMIT = "396ea9811124736aa140d2810ab53eaefb6b5c1c"
|
||||
C4_ROLLBACK_TARGET = {
|
||||
"version": "V2R2C4",
|
||||
"target_oid": "e5f8209f57b43fc39e55c4555776e6362ce326eb",
|
||||
"artifact_sha256": "0dfc0461f7f9aa25cfbfc37008dcbd2d48d5341383574163b7bba102852062c4",
|
||||
}
|
||||
C4_ROLLBACK_ARTIFACT_PATH = Path(
|
||||
"/var/lib/aios-gitops/release-only/"
|
||||
"v2r2c4-e5f8209f57b4/artifact.tar.gz"
|
||||
)
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE = Path("/etc/aios/v2r2c2.env")
|
||||
C5_PRODUCTION_SERVICE = "aios-v2r2c2-production.service"
|
||||
C5_PRODUCTION_PORT = 18928
|
||||
REQUEST_SCHEMA = "aios.c5.exact-production-deployment-request.v1"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class C5DeploymentBindingError(CapabilityAuthorizationError):
|
||||
"""C5 source, plan, approval, executor, or receipt binding is invalid."""
|
||||
|
||||
|
||||
class C5DeploymentOutcomeUnknown(RuntimeError):
|
||||
"""The deployment effect must be reconciled before any retry."""
|
||||
|
||||
|
||||
class C5DeploymentConflict(RuntimeError):
|
||||
"""The observed runtime is bound to a different release."""
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
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 _run_registered_adapter(
|
||||
command: tuple[str, ...], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Invoke only the code-pinned process and accept one JSON object."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
input=_canonical_json(dict(request)),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_UNAVAILABLE"
|
||||
) from exc
|
||||
if completed.returncode != 0:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_FAILED"
|
||||
)
|
||||
try:
|
||||
lines = [
|
||||
line
|
||||
for line in completed.stdout.decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
value = json.loads(lines[-1]) if lines else None
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _secure_registered_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
require_root_owner: bool,
|
||||
) -> None:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID") from exc
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
if os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_WRITABLE")
|
||||
if require_root_owner:
|
||||
if not hasattr(os, "geteuid") or info.st_uid != 0:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_OWNER_INVALID")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != 0
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5DeploymentBindingError(
|
||||
"C5_EXECUTOR_PARENT_SECURITY_INVALID"
|
||||
)
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if _sha256_file(path) != expected_sha256:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_CODE_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredC5DeploymentExecutor:
|
||||
"""Pinned host configuration for one concrete process adapter.
|
||||
|
||||
``adapter_path`` is optional for a native executable. When present the
|
||||
command is exactly ``executable adapter_path``; arbitrary argv and Python
|
||||
callables are intentionally unsupported.
|
||||
"""
|
||||
|
||||
executor_id: str
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
adapter_path: Path | None = None
|
||||
adapter_sha256: str | None = None
|
||||
require_root_owner: bool = True
|
||||
execution_scope: str = "PRODUCTION"
|
||||
isolation_root: Path | None = None
|
||||
rollback_target: Mapping[str, str] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.executor_id or len(self.executor_id) > 255:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ID_INVALID")
|
||||
if HEX64.fullmatch(self.executable_sha256) is None:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if (self.adapter_path is None) != (self.adapter_sha256 is None):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ADAPTER_BINDING_INVALID")
|
||||
if (
|
||||
self.adapter_sha256 is not None
|
||||
and HEX64.fullmatch(self.adapter_sha256) is None
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if not Path(self.artifact_path).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_PATH_INVALID")
|
||||
if self.execution_scope not in {"PRODUCTION", "ISOLATED_REHEARSAL"}:
|
||||
raise C5DeploymentBindingError("C5_EXECUTION_SCOPE_INVALID")
|
||||
target = dict(self.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if target.keys() != C4_ROLLBACK_TARGET.keys() or target["version"] != C4_ROLLBACK_TARGET["version"] or target["target_oid"] != C4_ROLLBACK_TARGET["target_oid"] or HEX64.fullmatch(str(target["artifact_sha256"])) is None:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if self.execution_scope == "PRODUCTION":
|
||||
if target != C4_ROLLBACK_TARGET:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if Path(self.rollback_artifact_path) != C4_ROLLBACK_ARTIFACT_PATH:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_ARTIFACT_PATH_INVALID")
|
||||
if Path(self.production_environment_file) != C5_PRODUCTION_ENVIRONMENT_FILE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_ENVIRONMENT_FILE_INVALID")
|
||||
if self.isolation_root is not None:
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
else:
|
||||
if self.isolation_root is None or not Path(self.isolation_root).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
root = Path(self.isolation_root).resolve(strict=False)
|
||||
for bound_path in (self.rollback_artifact_path, self.production_environment_file):
|
||||
path = Path(bound_path)
|
||||
if not path.is_absolute() or path.resolve(strict=False) == root:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID")
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID") from exc
|
||||
_secure_registered_file(
|
||||
Path(self.rollback_artifact_path),
|
||||
target["artifact_sha256"],
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
object.__setattr__(self, "rollback_target", target)
|
||||
self.verify_code()
|
||||
|
||||
def verify_code(self) -> None:
|
||||
_secure_registered_file(
|
||||
Path(self.executable),
|
||||
self.executable_sha256,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.adapter_path is not None:
|
||||
_secure_registered_file(
|
||||
Path(self.adapter_path),
|
||||
str(self.adapter_sha256),
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
values = [str(Path(self.executable))]
|
||||
if self.adapter_path is not None:
|
||||
values.append(str(Path(self.adapter_path)))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
class C5ExactDeploymentAdapter:
|
||||
"""Callable runtime-switch phase with exact C5 admission and approval."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
release_plan: ReleasePlan,
|
||||
approval: SignedReleasePlanApproval,
|
||||
approval_verifier: SignedApprovalVerifier,
|
||||
source_admissions: ReleaseSourceAdmissionStore,
|
||||
executor: RegisteredC5DeploymentExecutor,
|
||||
) -> None:
|
||||
if not isinstance(approval_verifier, SignedApprovalVerifier):
|
||||
raise C5DeploymentBindingError("C5_APPROVAL_VERIFIER_REQUIRED")
|
||||
if not isinstance(source_admissions, ReleaseSourceAdmissionStore):
|
||||
raise C5DeploymentBindingError("C5_SOURCE_ADMISSION_STORE_REQUIRED")
|
||||
self.candidate = candidate
|
||||
self.release_plan = release_plan
|
||||
self.approval = approval
|
||||
self.approval_verifier = approval_verifier
|
||||
self.source_admissions = source_admissions
|
||||
self.executor = executor
|
||||
self._binding = self._verify_binding(candidate, release_plan)
|
||||
|
||||
@staticmethod
|
||||
def _global_writes_disabled() -> bool:
|
||||
return os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() == "false"
|
||||
|
||||
def _verify_binding(
|
||||
self,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
if candidate.fingerprint != self.candidate.fingerprint or plan.sha256 != self.release_plan.sha256:
|
||||
raise C5DeploymentBindingError("C5_RUNTIME_INPUT_BINDING_MISMATCH")
|
||||
plan.validate()
|
||||
if plan.tag.version != C5_VERSION or plan.finalization.version != C5_VERSION:
|
||||
raise C5DeploymentBindingError("C5_VERSION_BINDING_INVALID")
|
||||
if candidate.source_commit == C5_PREPUBLICATION_SOURCE_COMMIT:
|
||||
raise C5DeploymentBindingError("C5_PREPUBLICATION_SOURCE_FORBIDDEN")
|
||||
rollback_target = dict(self.executor.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if plan.deployment.rollback_target != rollback_target:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if plan.deployment.service != C5_PRODUCTION_SERVICE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_SERVICE_INVALID")
|
||||
if plan.deployment.writer_expected is not False:
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION,
|
||||
candidate.candidate_id,
|
||||
candidate.source_commit,
|
||||
)
|
||||
if (
|
||||
admitted.get("source_commit") != candidate.source_commit
|
||||
or admitted.get("tree_oid") != candidate.tree_oid
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_ADMITTED_SOURCE_BINDING_INVALID")
|
||||
if (
|
||||
plan.deployment.source_commit != candidate.source_commit
|
||||
or plan.deployment.tree_oid != candidate.tree_oid
|
||||
or plan.deployment.artifact_sha256 != candidate.artifact_sha256
|
||||
or plan.deployment.manifest_sha256 != candidate.manifest_sha256
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_CANDIDATE_PLAN_BINDING_INVALID")
|
||||
self.approval_verifier.verify_release_plan(
|
||||
self.approval,
|
||||
candidate,
|
||||
plan,
|
||||
plan.policy_sha256,
|
||||
)
|
||||
|
||||
artifact = Path(self.executor.artifact_path)
|
||||
_secure_registered_file(
|
||||
artifact,
|
||||
candidate.artifact_sha256,
|
||||
require_root_owner=self.executor.require_root_owner,
|
||||
)
|
||||
artifact_size = artifact.stat().st_size
|
||||
if artifact_size <= 0:
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_BYTES_MISMATCH")
|
||||
self.executor.verify_code()
|
||||
return {
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"target_oid": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"artifact_path": str(artifact),
|
||||
"artifact_size": artifact_size,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"owner_approval_record_oid": self.approval.owner_approval_record_oid,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
"systemd_unit_sha256": plan.deployment.systemd_unit_sha256,
|
||||
"dropin_sha256": plan.deployment.dropin_sha256,
|
||||
"rollback_target": rollback_target,
|
||||
"rollback_artifact_path": str(
|
||||
self.executor.rollback_artifact_path
|
||||
),
|
||||
"production_environment_file": str(
|
||||
self.executor.production_environment_file
|
||||
),
|
||||
"writer_expected": False,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"executor_executable_sha256": self.executor.executable_sha256,
|
||||
"executor_adapter_sha256": self.executor.adapter_sha256,
|
||||
}
|
||||
|
||||
def _request(self, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
**self._binding,
|
||||
}
|
||||
|
||||
def _invoke(self, operation: str) -> dict[str, Any]:
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
self.executor.verify_code()
|
||||
value = _run_registered_adapter(
|
||||
self.executor.command, self._request(operation)
|
||||
)
|
||||
for key, expected in self._binding.items():
|
||||
if value.get(key) != expected:
|
||||
raise C5DeploymentBindingError(f"C5_DEPLOYMENT_RECEIPT_MISMATCH:{key}")
|
||||
if value.get("writer_disabled") is not True:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_WRITER_STATE_INVALID")
|
||||
receipt = value.get("deployment_receipt")
|
||||
if not isinstance(receipt, Mapping) or not receipt:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_RECEIPT_REQUIRED")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _classify(value: Mapping[str, Any]) -> ClaimProbeResult:
|
||||
status = value.get("status")
|
||||
if (
|
||||
status == "ACTIVE_VERIFIED"
|
||||
and value.get("verified") is True
|
||||
and value.get("deployment_state") == "ACTIVE"
|
||||
and value.get("production_deployed") is True
|
||||
and value.get("writer_disabled") is True
|
||||
and value.get("global_legacy_writes_enabled") is False
|
||||
):
|
||||
return ClaimProbeResult.APPLIED_VERIFIED
|
||||
if status == "NOT_APPLIED":
|
||||
return ClaimProbeResult.NOT_APPLIED
|
||||
if status == "CONFLICT":
|
||||
return ClaimProbeResult.CONFLICT
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def recovery_probe(self) -> ClaimProbeResult:
|
||||
"""Read-only exact probe used by ``CapabilityLedger`` after UNKNOWN."""
|
||||
try:
|
||||
self._verify_binding(self.candidate, self.release_plan)
|
||||
return self._classify(self._invoke("probe"))
|
||||
except C5DeploymentConflict:
|
||||
return ClaimProbeResult.CONFLICT
|
||||
except Exception:
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute only the explicit Saga rollback phase and verify exact C4."""
|
||||
self._verify_binding(candidate, plan)
|
||||
value = self._invoke("rollback")
|
||||
if (
|
||||
value.get("status") != "ROLLED_BACK_VERIFIED"
|
||||
or value.get("verified") is not True
|
||||
or value.get("rollback_verified") is not True
|
||||
or value.get("rollback_version") != self.executor.rollback_target["version"]
|
||||
or value.get("rollback_target_oid")
|
||||
!= self.executor.rollback_target["target_oid"]
|
||||
or value.get("rollback_artifact_sha256")
|
||||
!= self.executor.rollback_target["artifact_sha256"]
|
||||
):
|
||||
raise C5DeploymentOutcomeUnknown("C5_ROLLBACK_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"rollback_verified": True,
|
||||
"rollback_version": self.executor.rollback_target["version"],
|
||||
"rollback_target_oid": self.executor.rollback_target["target_oid"],
|
||||
"rollback_artifact_sha256": self.executor.rollback_target["artifact_sha256"],
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
self._verify_binding(candidate, plan)
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification == ClaimProbeResult.INDETERMINATE:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
if classification == ClaimProbeResult.NOT_APPLIED:
|
||||
applied = self._invoke("apply")
|
||||
if applied.get("status") == "CONFLICT":
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if applied.get("status") not in {"APPLIED", "ACTIVE_VERIFIED"}:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"rollback_target": dict(self.executor.rollback_target),
|
||||
"writer_disabled": True,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"deployment_receipt_sha256": hashlib.sha256(
|
||||
_canonical_json(dict(probe))
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
def controller(
|
||||
self,
|
||||
ledger: CapabilityLedger,
|
||||
*,
|
||||
lease_seconds: float = 300,
|
||||
) -> RuntimeSwitchController:
|
||||
"""Bind this exact adapter to the existing capability controller."""
|
||||
return RuntimeSwitchController(
|
||||
ledger,
|
||||
self,
|
||||
recovery_probe=self.recovery_probe,
|
||||
executor_id=self.executor.executor_id,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
"""Owner-registered dispatch boundary for the fixed-scope C5 installer.
|
||||
|
||||
The registration authorizes one exact signed installation contract and one
|
||||
exact set of installed executor bytes. It is not an execution receipt; the
|
||||
wrapped :class:`Installer` remains the component that durably records and
|
||||
reconciles installation state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import c5_staging_installation as _installer_module
|
||||
from c5_staging_installation import (
|
||||
BRIDGE_EFFECTS,
|
||||
BRIDGE_TARGET,
|
||||
HOST,
|
||||
InstallationError,
|
||||
InstallationOutcomeUnknown,
|
||||
ISSUER_EFFECTS,
|
||||
ISSUER_TARGET,
|
||||
OWNER_NAMESPACE,
|
||||
R14_SOURCE_COMMIT,
|
||||
REPOSITORY,
|
||||
SUCCESSOR_SCHEMA,
|
||||
Installer,
|
||||
RegistryOwnerVerifier,
|
||||
canonical_bytes,
|
||||
verify_signed_contract,
|
||||
)
|
||||
from registered_owner_signer_adapter import RegisteredOwnerSignerAdapter
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.registered-installer-executor.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.executor-provenance.v1"
|
||||
EXECUTION_DIGEST = "0779fa615e8e7971a8dcfccf0f295b1323cd9b64787e174aea4fd2affc414339"
|
||||
BRIDGE_PORT = 18892
|
||||
ISSUER_PORT = 18946
|
||||
CAPABILITIES = frozenset({"install", "rollback", "reconcile"})
|
||||
TARGET_EFFECTS = {BRIDGE_TARGET: BRIDGE_EFFECTS, ISSUER_TARGET: ISSUER_EFFECTS}
|
||||
MAX_TTL_SECONDS = 900
|
||||
MAX_CODE_BYTES = 16 * 1024 * 1024
|
||||
DEFAULT_REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_PROVENANCE_MANIFEST = Path("/etc/aios/c5-executor-provenance.json")
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
NONCE = re.compile(r"^[A-Za-z0-9._:\-]{16,128}$")
|
||||
SCOPE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:\-]{2,127}$")
|
||||
|
||||
|
||||
class ExecutorRegistrationError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise ExecutorRegistrationError(code)
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _require_digest(value: Any, pattern: re.Pattern[str], code: str) -> str:
|
||||
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
_fail("EXECUTOR_JSON_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _secure_bytes(
|
||||
path: Path, *, require_root_owner: bool = True, maximum: int = MAX_CODE_BYTES,
|
||||
) -> bytes:
|
||||
path = Path(path)
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
if path.is_symlink() or path.resolve(strict=True) != path:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
before = path.stat()
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
try:
|
||||
current = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(current.st_mode)
|
||||
or current.st_dev != before.st_dev
|
||||
or current.st_ino != before.st_ino
|
||||
or current.st_size < 1
|
||||
or current.st_size > maximum
|
||||
):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
if require_root_owner:
|
||||
if os.name != "posix" or current.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(current.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_stat = parent.stat()
|
||||
if parent.is_symlink() or parent_stat.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(parent_stat.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
chunks: list[bytes] = []
|
||||
remaining = current.st_size
|
||||
while remaining:
|
||||
chunk = os.read(fd, min(remaining, 1024 * 1024))
|
||||
if not chunk:
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
if os.read(fd, 1):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
integration_commit: str
|
||||
integration_tree: str
|
||||
installer_module_path: Path
|
||||
installer_script_path: Path
|
||||
executor_module_path: Path
|
||||
installer_module_sha256: str
|
||||
installer_script_sha256: str
|
||||
executor_module_sha256: str
|
||||
authority_manifest_sha256: str
|
||||
|
||||
|
||||
def _actual_installer_module_path() -> Path:
|
||||
value = getattr(_installer_module, "__file__", None)
|
||||
if not isinstance(value, str):
|
||||
_fail("EXECUTOR_LOADED_INSTALLER_PATH_INVALID")
|
||||
return Path(value).resolve(strict=True)
|
||||
|
||||
|
||||
def _actual_executor_module_path() -> Path:
|
||||
return Path(__file__).resolve(strict=True)
|
||||
|
||||
|
||||
def load_code_provenance(
|
||||
manifest: Path, *, require_root_owner: bool = True,
|
||||
) -> CodeProvenance:
|
||||
"""Load identity from an authority manifest and actual loaded paths."""
|
||||
manifest = Path(manifest)
|
||||
raw = _secure_bytes(manifest, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_PROVENANCE_INVALID") from exc
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {
|
||||
"schema", "environment", "instance_id", "integration_commit",
|
||||
"integration_tree", "entries",
|
||||
}
|
||||
or value["schema"] != PROVENANCE_SCHEMA
|
||||
or not isinstance(value["entries"], dict)
|
||||
or set(value["entries"]) != {
|
||||
"installer_module", "installer_script", "executor_module",
|
||||
}
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
expected_paths = {
|
||||
"installer_module": _actual_installer_module_path(),
|
||||
"executor_module": _actual_executor_module_path(),
|
||||
}
|
||||
resolved: dict[str, Path] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for label, entry in value["entries"].items():
|
||||
if not isinstance(entry, dict) or set(entry) != {"path", "sha256"}:
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
if not isinstance(entry["path"], str):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
path = Path(entry["path"])
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
canonical = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
if path != canonical:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
if label in expected_paths and canonical != expected_paths[label]:
|
||||
_fail("EXECUTOR_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
if label == "installer_script" and canonical.name != "c5_staging_installation.py":
|
||||
_fail("EXECUTOR_INSTALLER_SCRIPT_PATH_INVALID")
|
||||
digest = _sha256_bytes(
|
||||
_secure_bytes(canonical, require_root_owner=require_root_owner)
|
||||
)
|
||||
if entry["sha256"] != digest:
|
||||
_fail("EXECUTOR_PROVENANCE_CODE_MISMATCH")
|
||||
resolved[label] = canonical
|
||||
hashes[label] = digest
|
||||
if (
|
||||
not isinstance(value["environment"], str)
|
||||
or SCOPE_ID.fullmatch(value["environment"]) is None
|
||||
or not isinstance(value["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(value["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_SCOPE_INVALID")
|
||||
return CodeProvenance(
|
||||
environment=value["environment"],
|
||||
instance_id=value["instance_id"],
|
||||
integration_commit=_require_digest(
|
||||
value["integration_commit"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_COMMIT_INVALID",
|
||||
),
|
||||
integration_tree=_require_digest(
|
||||
value["integration_tree"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_TREE_INVALID",
|
||||
),
|
||||
installer_module_path=resolved["installer_module"],
|
||||
installer_script_path=resolved["installer_script"],
|
||||
executor_module_path=resolved["executor_module"],
|
||||
installer_module_sha256=hashes["installer_module"],
|
||||
installer_script_sha256=hashes["installer_script"],
|
||||
executor_module_sha256=hashes["executor_module"],
|
||||
authority_manifest_sha256=_sha256_bytes(raw),
|
||||
)
|
||||
|
||||
|
||||
def _contract_binding(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"old_snapshot_sha256": _sha256_bytes(canonical_bytes(payload["existing"])),
|
||||
"new_manifest_sha256": _sha256_bytes(canonical_bytes({
|
||||
"release_root": payload["release_root"],
|
||||
"files": payload["files"],
|
||||
"readiness": payload["readiness"],
|
||||
})),
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
}
|
||||
|
||||
|
||||
def _issuer_dependency(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"source_commit": payload["source_commit"],
|
||||
"support_commit": payload["support_commit"],
|
||||
"owner_signer_id": payload["owner_signer_id"],
|
||||
"owner_key_id": payload["owner_key_id"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
}
|
||||
|
||||
|
||||
def _registration_payload(
|
||||
*, executor_id: Any, nonce: Any, issued_at: Any, expires_at: Any,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
provenance: CodeProvenance,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"executor_id": executor_id,
|
||||
"owner_signer_id": contract_payload["owner_signer_id"],
|
||||
"owner_key_id": contract_payload["owner_key_id"],
|
||||
"host": HOST,
|
||||
"repository": REPOSITORY,
|
||||
"environment": provenance.environment,
|
||||
"instance_id": provenance.instance_id,
|
||||
"source_commit": R14_SOURCE_COMMIT,
|
||||
"support_commit": contract_payload["support_commit"],
|
||||
"integration_commit": provenance.integration_commit,
|
||||
"integration_tree": provenance.integration_tree,
|
||||
"installer_module_sha256": provenance.installer_module_sha256,
|
||||
"installer_script_sha256": provenance.installer_script_sha256,
|
||||
"executor_module_sha256": provenance.executor_module_sha256,
|
||||
"authority_manifest_sha256": provenance.authority_manifest_sha256,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"bridge_port": BRIDGE_PORT,
|
||||
"issuer_port": ISSUER_PORT,
|
||||
"capabilities": sorted(CAPABILITIES),
|
||||
"effects": sorted(contract_payload["effects"]),
|
||||
"issuer_dependency": dict(issuer_dependency) if issuer_dependency is not None else None,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"nonce": nonce,
|
||||
**_contract_binding(contract_payload, contract_digest),
|
||||
}
|
||||
|
||||
|
||||
def _validate_issuer_dependency(
|
||||
dependency: Any, contract_payload: Mapping[str, Any],
|
||||
) -> None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if dependency is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return
|
||||
if (
|
||||
not isinstance(dependency, Mapping)
|
||||
or set(dependency) != {
|
||||
"target", "contract_digest", "source_commit", "support_commit",
|
||||
"owner_signer_id", "owner_key_id", "contract_nonce",
|
||||
"contract_issued_at", "contract_expires_at", "rollback_sha256",
|
||||
}
|
||||
or dependency["target"] != ISSUER_TARGET
|
||||
or dependency["source_commit"] != contract_payload["source_commit"]
|
||||
or dependency["support_commit"] != contract_payload["support_commit"]
|
||||
or dependency["owner_signer_id"] != contract_payload["owner_signer_id"]
|
||||
or dependency["owner_key_id"] != contract_payload["owner_key_id"]
|
||||
or type(dependency["contract_issued_at"]) is not int
|
||||
or type(dependency["contract_expires_at"]) is not int
|
||||
):
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
_require_digest(
|
||||
dependency["contract_digest"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
_require_digest(
|
||||
dependency["rollback_sha256"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
|
||||
|
||||
def _validate_payload(
|
||||
payload: Mapping[str, Any], *, now: int, provenance: CodeProvenance,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
expected = _registration_payload(
|
||||
executor_id=payload.get("executor_id"), nonce=payload.get("nonce"),
|
||||
issued_at=payload.get("issued_at"), expires_at=payload.get("expires_at"),
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
provenance=provenance, issuer_dependency=issuer_dependency,
|
||||
)
|
||||
if set(payload) != set(expected) or any(
|
||||
payload.get(key) != value for key, value in expected.items()
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_BINDING_INVALID")
|
||||
if (
|
||||
not isinstance(payload["executor_id"], str)
|
||||
or not payload["executor_id"]
|
||||
or not isinstance(payload["nonce"], str)
|
||||
or NONCE.fullmatch(payload["nonce"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_IDENTITY_INVALID")
|
||||
if (
|
||||
type(payload["issued_at"]) is not int
|
||||
or type(payload["expires_at"]) is not int
|
||||
or payload["issued_at"] > now
|
||||
or now >= payload["expires_at"]
|
||||
or not 1 <= payload["expires_at"] - payload["issued_at"] <= MAX_TTL_SECONDS
|
||||
or payload["issued_at"] < contract_payload["issued_at"]
|
||||
or payload["expires_at"] > contract_payload["expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if payload["support_commit"] != payload["integration_commit"]:
|
||||
_fail("EXECUTOR_COMMIT_BINDING_INVALID")
|
||||
if (
|
||||
issuer_dependency is not None
|
||||
and payload["expires_at"] > issuer_dependency["contract_expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if (
|
||||
not isinstance(payload["environment"], str)
|
||||
or SCOPE_ID.fullmatch(payload["environment"]) is None
|
||||
or not isinstance(payload["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(payload["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_ENVIRONMENT_BINDING_INVALID")
|
||||
if payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_SOURCE_COMMIT_INVALID")
|
||||
if (
|
||||
contract_payload["schema"] != SUCCESSOR_SCHEMA
|
||||
or contract_payload["source_commit"] != R14_SOURCE_COMMIT
|
||||
):
|
||||
_fail("EXECUTOR_CONTRACT_GENERATION_INVALID")
|
||||
effects = TARGET_EFFECTS.get(payload["target"])
|
||||
if (
|
||||
effects is None
|
||||
or not isinstance(payload["effects"], list)
|
||||
or frozenset(payload["effects"]) != effects
|
||||
or len(payload["effects"]) != len(effects)
|
||||
):
|
||||
_fail("EXECUTOR_EFFECTS_INVALID")
|
||||
if (
|
||||
not isinstance(payload["capabilities"], list)
|
||||
or frozenset(payload["capabilities"]) != CAPABILITIES
|
||||
or len(payload["capabilities"]) != len(CAPABILITIES)
|
||||
):
|
||||
_fail("EXECUTOR_CAPABILITIES_INVALID")
|
||||
_validate_issuer_dependency(issuer_dependency, contract_payload)
|
||||
for name in (
|
||||
"installer_module_sha256", "installer_script_sha256",
|
||||
"executor_module_sha256", "authority_manifest_sha256",
|
||||
"execution_digest", "contract_digest", "old_snapshot_sha256",
|
||||
"new_manifest_sha256", "rollback_sha256",
|
||||
):
|
||||
_require_digest(payload[name], HEX64, f"EXECUTOR_{name.upper()}_INVALID")
|
||||
|
||||
|
||||
def _verified_contract(
|
||||
contract: Path, installer: Installer, *, now: int,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
return verify_signed_contract(
|
||||
contract, installer.registry_path, now=now,
|
||||
require_root_owner=installer.require_root_owner,
|
||||
_layout=installer.layout,
|
||||
)
|
||||
|
||||
|
||||
def _verified_issuer_dependency(
|
||||
issuer_contract: Path | None, installer: Installer, *, now: int,
|
||||
contract_payload: Mapping[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if issuer_contract is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return None
|
||||
if issuer_contract is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
payload, digest = _verified_contract(issuer_contract, installer, now=now)
|
||||
if payload["schema"] != SUCCESSOR_SCHEMA or payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
dependency = _issuer_dependency(payload, digest)
|
||||
_validate_issuer_dependency(dependency, contract_payload)
|
||||
return dependency
|
||||
|
||||
|
||||
def issue_registration(
|
||||
*, executor_id: str, nonce: str, ttl_seconds: int, contract: Path,
|
||||
installer: Installer, provenance_manifest: Path,
|
||||
registered_signer: RegisteredOwnerSignerAdapter,
|
||||
clock: Callable[[], int], issuer_contract: Path | None = None,
|
||||
require_root_owner: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue authorization after deriving every scope field from trusted input."""
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(registered_signer, RegisteredOwnerSignerAdapter):
|
||||
_fail("REGISTERED_SIGNER_ADAPTER_REQUIRED")
|
||||
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_TTL_SECONDS:
|
||||
_fail("EXECUTOR_REGISTRATION_TTL_INVALID")
|
||||
now = int(clock())
|
||||
provenance = load_code_provenance(
|
||||
provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
contract_payload, contract_digest = _verified_contract(contract, installer, now=now)
|
||||
dependency = _verified_issuer_dependency(
|
||||
issuer_contract, installer, now=now, contract_payload=contract_payload,
|
||||
)
|
||||
payload = _registration_payload(
|
||||
executor_id=executor_id, nonce=nonce, issued_at=now,
|
||||
expires_at=now + ttl_seconds, contract_payload=contract_payload,
|
||||
contract_digest=contract_digest, provenance=provenance,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if registered_signer.fingerprint != payload["owner_key_id"]:
|
||||
_fail("EXECUTOR_SIGNER_BINDING_INVALID")
|
||||
signature = registered_signer._sign_canonical_payload(
|
||||
OWNER_NAMESPACE, canonical_bytes(payload),
|
||||
)
|
||||
return {"payload": payload, "signature": signature}
|
||||
|
||||
|
||||
def load_registration(path: Path, *, require_root_owner: bool = True) -> dict[str, Any]:
|
||||
raw = _secure_bytes(
|
||||
Path(path), require_root_owner=require_root_owner, maximum=1024 * 1024,
|
||||
)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_REGISTRATION_INVALID") from exc
|
||||
if not isinstance(value, dict) or set(value) != {"payload", "signature"}:
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
class RegisteredInstallerExecutor:
|
||||
def __init__(
|
||||
self, *, owner_verifier: RegistryOwnerVerifier, installer: Installer,
|
||||
provenance_manifest: Path, clock: Callable[[], int],
|
||||
host: Callable[[], str] = socket.gethostname,
|
||||
require_root_owner: bool = True,
|
||||
) -> None:
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(owner_verifier, RegistryOwnerVerifier):
|
||||
_fail("EXECUTOR_OWNER_VERIFIER_INVALID")
|
||||
self.owner_verifier = owner_verifier
|
||||
self.installer = installer
|
||||
self.provenance_manifest = Path(provenance_manifest)
|
||||
self.clock = clock
|
||||
self.host = host
|
||||
self.require_root_owner = require_root_owner
|
||||
self.provenance = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
|
||||
def _check_code_provenance(self) -> None:
|
||||
current = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if current != self.provenance:
|
||||
_fail("EXECUTOR_CODE_PROVENANCE_CHANGED")
|
||||
|
||||
def _verify(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
self._check_code_provenance()
|
||||
if (
|
||||
not isinstance(registration, Mapping)
|
||||
or set(registration) != {"payload", "signature"}
|
||||
or not isinstance(registration["payload"], Mapping)
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
if self.host() != HOST:
|
||||
_fail("EXECUTOR_HOST_MISMATCH")
|
||||
now = int(self.clock())
|
||||
contract_payload, contract_digest = _verified_contract(
|
||||
contract, self.installer, now=now,
|
||||
)
|
||||
payload = dict(registration["payload"])
|
||||
dependency = payload.get("issuer_dependency")
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=self.provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if payload["owner_key_id"] != self.owner_verifier.record.fingerprint:
|
||||
_fail("EXECUTOR_OWNER_KEY_BINDING_INVALID")
|
||||
signature = registration["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not self.owner_verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=canonical_bytes(payload),
|
||||
signature=signature, key_id=payload["owner_key_id"],
|
||||
)
|
||||
):
|
||||
_fail("EXECUTOR_OWNER_SIGNATURE_INVALID")
|
||||
return payload, contract_digest, contract_payload
|
||||
|
||||
def dispatch(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
artifact: Path, operation: str, *,
|
||||
issuer_contract: Path | None = None,
|
||||
issuer_artifact: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload, contract_digest, contract_payload = self._verify(registration, contract)
|
||||
capability = "install" if operation in {"preflight", "install"} else operation
|
||||
if capability not in CAPABILITIES:
|
||||
_fail("EXECUTOR_OPERATION_INVALID")
|
||||
if capability not in payload["capabilities"]:
|
||||
_fail("EXECUTOR_CAPABILITY_INVALID")
|
||||
if operation == "rollback" and contract_payload["target"] == BRIDGE_TARGET:
|
||||
if issuer_artifact is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
actual_dependency = _verified_issuer_dependency(
|
||||
issuer_contract, self.installer, now=int(self.clock()),
|
||||
contract_payload=contract_payload,
|
||||
)
|
||||
if actual_dependency != payload["issuer_dependency"]:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_MISMATCH")
|
||||
elif issuer_contract is not None or issuer_artifact is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_UNEXPECTED")
|
||||
if operation == "preflight":
|
||||
result = self.installer.preflight(contract, artifact)
|
||||
elif operation == "install":
|
||||
result = self.installer.apply(contract, artifact)
|
||||
elif operation == "rollback":
|
||||
result = self.installer.rollback_applied(
|
||||
contract, artifact, issuer_contract=issuer_contract,
|
||||
issuer_artifact=issuer_artifact,
|
||||
)
|
||||
else:
|
||||
result = self.installer.reconcile_unknown(contract, artifact)
|
||||
return {
|
||||
**result,
|
||||
"operation": operation,
|
||||
"executor_id": payload["executor_id"],
|
||||
"contract_digest": contract_digest,
|
||||
"code_binding": {
|
||||
"integration_commit": payload["integration_commit"],
|
||||
"integration_tree": payload["integration_tree"],
|
||||
"installer_module_sha256": payload["installer_module_sha256"],
|
||||
"installer_script_sha256": payload["installer_script_sha256"],
|
||||
"executor_module_sha256": payload["executor_module_sha256"],
|
||||
},
|
||||
"registration_policy_digest": _sha256_bytes(canonical_bytes(payload)),
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one Owner-registered fixed-scope C5 installer operation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"operation", choices=("preflight", "install", "rollback", "reconcile"),
|
||||
)
|
||||
parser.add_argument("--registration", type=Path, required=True)
|
||||
parser.add_argument("--contract", type=Path, required=True)
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
parser.add_argument("--issuer-contract", type=Path)
|
||||
parser.add_argument("--issuer-artifact", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Production CLI with fixed registry and provenance authority paths."""
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
registration = load_registration(args.registration)
|
||||
payload = registration.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("owner_signer_id"), str):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
installer = Installer(DEFAULT_REGISTRY)
|
||||
verifier = RegistryOwnerVerifier(
|
||||
DEFAULT_REGISTRY, owner_signer_id=payload["owner_signer_id"],
|
||||
)
|
||||
executor = RegisteredInstallerExecutor(
|
||||
owner_verifier=verifier, installer=installer,
|
||||
provenance_manifest=DEFAULT_PROVENANCE_MANIFEST,
|
||||
clock=lambda: int(time.time()),
|
||||
)
|
||||
result = executor.dispatch(
|
||||
registration, args.contract, args.artifact, args.operation,
|
||||
issuer_contract=args.issuer_contract,
|
||||
issuer_artifact=args.issuer_artifact,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except InstallationOutcomeUnknown as exc:
|
||||
print(
|
||||
json.dumps({"status": "UNKNOWN", "error": exc.code}, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
except (ExecutorRegistrationError, InstallationError, OSError, ValueError, TypeError) as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "REJECTED", "error": getattr(exc, "code", str(exc))},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,977 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-owned one-shot assembly for the exact V2R2C5 release Saga.
|
||||
|
||||
This module only assembles existing hardened Saga phases. Each mutating phase
|
||||
is a SHA-pinned process adapter; no caller-provided Python callable is accepted.
|
||||
The staging HTTP service remains shadow-only and does not import this launcher.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import approved_release_executor as approved_executor_module
|
||||
from approved_release_executor import run_adapter
|
||||
import c5_exact_deployment_adapter as exact_deployment_module
|
||||
from c5_exact_deployment_adapter import (
|
||||
C4_ROLLBACK_ARTIFACT_PATH,
|
||||
C4_ROLLBACK_TARGET,
|
||||
C5ExactDeploymentAdapter,
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE,
|
||||
C5_PRODUCTION_PORT,
|
||||
C5_PRODUCTION_SERVICE,
|
||||
C5_VERSION,
|
||||
RegisteredC5DeploymentExecutor,
|
||||
)
|
||||
from release_source_admission import open_source_admission_store
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseCandidateStore
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BaselineController,
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
GovernedFinalizationCoordinator,
|
||||
HardenedTagController,
|
||||
PreSwitchValidator,
|
||||
RegistryController,
|
||||
ReleaseOrchestrator,
|
||||
RecoverableReleaseError,
|
||||
ReleasePlan,
|
||||
ReleasePlanStore,
|
||||
ReleaseSagaStore,
|
||||
SignedApprovalStore,
|
||||
SignedApprovalVerifier,
|
||||
TagPlan,
|
||||
TagPublicationConflict,
|
||||
TagPublicationProbeEvidence,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.release-execution-host.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.release-execution-provenance.v1"
|
||||
REQUEST_SCHEMA = "aios.production-executor-request.v1"
|
||||
REPOSITORY = "aios/aios"
|
||||
SCOPES = frozenset({"PRODUCTION", "ISOLATED_REHEARSAL"})
|
||||
SHA256 = __import__("re").compile(r"^[0-9a-f]{64}$")
|
||||
OID = __import__("re").compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class C5ExecutionHostBlocked(RuntimeError):
|
||||
"""Exact host assembly or phase verification failed closed."""
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _secure_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
expected_owner_uid: int | None,
|
||||
) -> Path:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(info.st_mode) or (
|
||||
os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_SECURITY_INVALID")
|
||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_OWNER_INVALID")
|
||||
if expected_owner_uid is not None:
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PARENT_SECURITY_INVALID")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if not SHA256.fullmatch(expected_sha256) or _sha256(path) != expected_sha256:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_DIGEST_MISMATCH")
|
||||
return path
|
||||
|
||||
|
||||
def _absolute(value: Any, name: str) -> Path:
|
||||
path = Path(str(value or ""))
|
||||
if not path.is_absolute():
|
||||
raise C5ExecutionHostBlocked(f"{name}_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredPhaseCommand:
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
adapter_path: Path
|
||||
adapter_sha256: str
|
||||
arguments: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Any) -> "RegisteredPhaseCommand":
|
||||
if not isinstance(raw, Mapping) or set(raw) != {
|
||||
"executable",
|
||||
"executable_sha256",
|
||||
"adapter_path",
|
||||
"adapter_sha256",
|
||||
"arguments",
|
||||
}:
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_SCHEMA_INVALID")
|
||||
arguments = raw["arguments"]
|
||||
if not isinstance(arguments, list) or not all(
|
||||
isinstance(item, str) and item and "\0" not in item
|
||||
for item in arguments
|
||||
):
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_ARGUMENTS_INVALID")
|
||||
return cls(
|
||||
executable=_absolute(raw["executable"], "PHASE_EXECUTABLE"),
|
||||
executable_sha256=str(raw["executable_sha256"]),
|
||||
adapter_path=_absolute(raw["adapter_path"], "PHASE_ADAPTER"),
|
||||
adapter_sha256=str(raw["adapter_sha256"]),
|
||||
arguments=tuple(arguments),
|
||||
)
|
||||
|
||||
def verify(self, *, expected_owner_uid: int | None) -> None:
|
||||
_secure_file(
|
||||
self.executable,
|
||||
self.executable_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
_secure_file(
|
||||
self.adapter_path,
|
||||
self.adapter_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
return (str(self.executable), str(self.adapter_path), *self.arguments)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5HostProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
authority_commit: str
|
||||
authority_tree: str
|
||||
entries: Mapping[str, tuple[Path, str]]
|
||||
manifest_sha256: str
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5HostProvenance":
|
||||
path = _secure_file(
|
||||
Path(path), expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID") from exc
|
||||
labels = {
|
||||
"host", "exact_deployment", "deployment", "approved_executor",
|
||||
"tag", "registry", "baseline",
|
||||
}
|
||||
if (
|
||||
not isinstance(raw, Mapping)
|
||||
or set(raw) != {
|
||||
"schema", "environment", "instance_id",
|
||||
"authority_commit", "authority_tree", "entries",
|
||||
}
|
||||
or raw["schema"] != PROVENANCE_SCHEMA
|
||||
or raw["environment"] not in SCOPES
|
||||
or not isinstance(raw["entries"], Mapping)
|
||||
or set(raw["entries"]) != labels
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INSTANCE_INVALID")
|
||||
authority_commit = str(raw["authority_commit"])
|
||||
authority_tree = str(raw["authority_tree"])
|
||||
if not OID.fullmatch(authority_commit) or not OID.fullmatch(authority_tree):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_AUTHORITY_INVALID")
|
||||
actual_loaded = {
|
||||
"host": Path(__file__).resolve(strict=True),
|
||||
"exact_deployment": Path(exact_deployment_module.__file__).resolve(strict=True),
|
||||
"approved_executor": Path(approved_executor_module.__file__).resolve(strict=True),
|
||||
}
|
||||
entries: dict[str, tuple[Path, str]] = {}
|
||||
for label, item in raw["entries"].items():
|
||||
if not isinstance(item, Mapping) or set(item) != {"path", "sha256"}:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ENTRY_INVALID")
|
||||
entry_path = _absolute(item["path"], "HOST_PROVENANCE_ENTRY")
|
||||
digest = str(item["sha256"])
|
||||
_secure_file(
|
||||
entry_path, digest, expected_owner_uid=expected_owner_uid
|
||||
)
|
||||
if label in actual_loaded and entry_path != actual_loaded[label]:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
entries[label] = (entry_path, digest)
|
||||
return cls(
|
||||
environment=str(raw["environment"]),
|
||||
instance_id=instance_id,
|
||||
authority_commit=authority_commit,
|
||||
authority_tree=authority_tree,
|
||||
entries=entries,
|
||||
manifest_sha256=expected_sha256,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5ExecutionHostConfig:
|
||||
host_provenance: C5HostProvenance
|
||||
executor_authority_commit: str
|
||||
executor_authority_tree: str
|
||||
execution_scope: str
|
||||
instance_id: str
|
||||
provenance: Mapping[str, Any]
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_fingerprint: str
|
||||
state_root: Path
|
||||
trusted_policy_path: Path
|
||||
trusted_policy_sha256: str
|
||||
approval_keyring_path: Path
|
||||
approval_keyring_sha256: str
|
||||
source_admission_root: Path
|
||||
source_admission_policy_path: Path
|
||||
source_admission_policy_sha256: str
|
||||
source_admission_task_id: str
|
||||
source_admission_hmac_path: Path
|
||||
source_admission_hmac_sha256: str
|
||||
deployment_executable: Path
|
||||
deployment_executable_sha256: str
|
||||
deployment_adapter_path: Path
|
||||
deployment_adapter_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_target: Mapping[str, str]
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
authoritative_git_dir: Path
|
||||
git_executable: Path
|
||||
git_executable_sha256: str
|
||||
tag_command: RegisteredPhaseCommand
|
||||
registry_command: RegisteredPhaseCommand
|
||||
baseline_command: RegisteredPhaseCommand
|
||||
registry_record_path: Path
|
||||
baseline_record_path: Path
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
host_provenance: C5HostProvenance,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5ExecutionHostConfig":
|
||||
path = _secure_file(
|
||||
Path(path),
|
||||
expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_INVALID") from exc
|
||||
keys = {
|
||||
"schema", "version", "execution_scope", "instance_id",
|
||||
"executor_authority_commit", "executor_authority_tree",
|
||||
"provenance", "candidate_id",
|
||||
"source_commit", "tree_oid", "artifact_sha256",
|
||||
"release_plan_sha256", "approval_fingerprint", "state_root",
|
||||
"trusted_policy_path", "trusted_policy_sha256",
|
||||
"approval_keyring_path", "approval_keyring_sha256",
|
||||
"source_admission_root", "source_admission_policy_path",
|
||||
"source_admission_policy_sha256", "source_admission_task_id", "source_admission_hmac_path",
|
||||
"source_admission_hmac_sha256", "deployment_executable",
|
||||
"deployment_executable_sha256", "deployment_adapter_path",
|
||||
"deployment_adapter_sha256", "artifact_path",
|
||||
"rollback_target", "rollback_artifact_path",
|
||||
"production_environment_file", "production_service",
|
||||
"production_port", "repository_identity", "global_legacy_writes_enabled",
|
||||
"authoritative_git_dir", "git_executable", "git_executable_sha256",
|
||||
"tag_command", "registry_command", "baseline_command",
|
||||
"registry_record_path", "baseline_record_path",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != keys:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_SCHEMA_INVALID")
|
||||
rollback_artifact_path = _absolute(raw["rollback_artifact_path"], "ROLLBACK_ARTIFACT")
|
||||
production_environment_file = _absolute(raw["production_environment_file"], "PRODUCTION_ENVIRONMENT_FILE")
|
||||
exact = (
|
||||
raw["schema"] == SCHEMA
|
||||
and raw["version"] == C5_VERSION
|
||||
and raw["execution_scope"] in SCOPES
|
||||
and isinstance(raw["rollback_target"], Mapping)
|
||||
and raw["production_service"] == C5_PRODUCTION_SERVICE
|
||||
and raw["production_port"] == C5_PRODUCTION_PORT
|
||||
and raw["repository_identity"] == REPOSITORY
|
||||
and raw["global_legacy_writes_enabled"] is False
|
||||
)
|
||||
if not exact:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
provenance = raw["provenance"]
|
||||
expected_provenance = (
|
||||
{
|
||||
"environment": "ISOLATED_REHEARSAL",
|
||||
"fixture": True,
|
||||
"r14_source_receipt": False,
|
||||
}
|
||||
if raw["execution_scope"] == "ISOLATED_REHEARSAL"
|
||||
else {
|
||||
"environment": "PRODUCTION",
|
||||
"fixture": False,
|
||||
"r14_source_receipt": True,
|
||||
}
|
||||
)
|
||||
if provenance != expected_provenance:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128 or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-"
|
||||
for character in instance_id
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_INSTANCE_ID_INVALID")
|
||||
for name, pattern in (
|
||||
("executor_authority_commit", OID),
|
||||
("executor_authority_tree", OID),
|
||||
("source_commit", OID), ("tree_oid", OID),
|
||||
("artifact_sha256", SHA256),
|
||||
("release_plan_sha256", SHA256),
|
||||
("approval_fingerprint", SHA256),
|
||||
):
|
||||
if pattern.fullmatch(str(raw[name])) is None:
|
||||
raise C5ExecutionHostBlocked(f"{name.upper()}_INVALID")
|
||||
value = cls(
|
||||
host_provenance=host_provenance,
|
||||
executor_authority_commit=str(raw["executor_authority_commit"]),
|
||||
executor_authority_tree=str(raw["executor_authority_tree"]),
|
||||
execution_scope=str(raw["execution_scope"]),
|
||||
instance_id=instance_id,
|
||||
provenance=dict(provenance),
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
source_commit=str(raw["source_commit"]),
|
||||
tree_oid=str(raw["tree_oid"]),
|
||||
artifact_sha256=str(raw["artifact_sha256"]),
|
||||
release_plan_sha256=str(raw["release_plan_sha256"]),
|
||||
approval_fingerprint=str(raw["approval_fingerprint"]),
|
||||
state_root=_absolute(raw["state_root"], "STATE_ROOT"),
|
||||
trusted_policy_path=_absolute(raw["trusted_policy_path"], "TRUSTED_POLICY"),
|
||||
trusted_policy_sha256=str(raw["trusted_policy_sha256"]),
|
||||
approval_keyring_path=_absolute(raw["approval_keyring_path"], "APPROVAL_KEYRING"),
|
||||
approval_keyring_sha256=str(raw["approval_keyring_sha256"]),
|
||||
source_admission_root=_absolute(raw["source_admission_root"], "SOURCE_ADMISSION_ROOT"),
|
||||
source_admission_policy_path=_absolute(raw["source_admission_policy_path"], "SOURCE_ADMISSION_POLICY"),
|
||||
source_admission_policy_sha256=str(raw["source_admission_policy_sha256"]),
|
||||
source_admission_task_id=str(raw["source_admission_task_id"]),
|
||||
source_admission_hmac_path=_absolute(raw["source_admission_hmac_path"], "SOURCE_ADMISSION_HMAC"),
|
||||
source_admission_hmac_sha256=str(raw["source_admission_hmac_sha256"]),
|
||||
deployment_executable=_absolute(raw["deployment_executable"], "DEPLOYMENT_EXECUTABLE"),
|
||||
deployment_executable_sha256=str(raw["deployment_executable_sha256"]),
|
||||
deployment_adapter_path=_absolute(raw["deployment_adapter_path"], "DEPLOYMENT_ADAPTER"),
|
||||
deployment_adapter_sha256=str(raw["deployment_adapter_sha256"]),
|
||||
artifact_path=_absolute(raw["artifact_path"], "ARTIFACT"),
|
||||
rollback_target=dict(raw["rollback_target"]),
|
||||
rollback_artifact_path=rollback_artifact_path,
|
||||
production_environment_file=production_environment_file,
|
||||
authoritative_git_dir=_absolute(raw["authoritative_git_dir"], "AUTHORITATIVE_GIT_DIR"),
|
||||
git_executable=_absolute(raw["git_executable"], "GIT_EXECUTABLE"),
|
||||
git_executable_sha256=str(raw["git_executable_sha256"]),
|
||||
tag_command=RegisteredPhaseCommand.from_mapping(raw["tag_command"]),
|
||||
registry_command=RegisteredPhaseCommand.from_mapping(raw["registry_command"]),
|
||||
baseline_command=RegisteredPhaseCommand.from_mapping(raw["baseline_command"]),
|
||||
registry_record_path=_absolute(raw["registry_record_path"], "REGISTRY_RECORD"),
|
||||
baseline_record_path=_absolute(raw["baseline_record_path"], "BASELINE_RECORD"),
|
||||
)
|
||||
if (not value.source_admission_task_id
|
||||
or len(value.source_admission_task_id) > 160
|
||||
or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-"
|
||||
for character in value.source_admission_task_id)):
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_ID_INVALID")
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
rehearsal_root = value.state_root.resolve(strict=False)
|
||||
lowered_parts = {part.lower() for part in rehearsal_root.parts}
|
||||
if (
|
||||
"isolated-rehearsal" not in lowered_parts
|
||||
or value.instance_id not in rehearsal_root.parts
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROOT_NOT_ISOLATED")
|
||||
for fixture_path in (
|
||||
value.source_admission_root,
|
||||
value.artifact_path,
|
||||
value.rollback_artifact_path,
|
||||
value.production_environment_file,
|
||||
value.registry_record_path,
|
||||
value.baseline_record_path,
|
||||
):
|
||||
try:
|
||||
fixture_path.resolve(strict=False).relative_to(rehearsal_root)
|
||||
except ValueError as exc:
|
||||
raise C5ExecutionHostBlocked(
|
||||
"REHEARSAL_FIXTURE_OUTSIDE_ISOLATION_ROOT"
|
||||
) from exc
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
if set(value.rollback_target) != set(C4_ROLLBACK_TARGET) or value.rollback_target.get("version") != C4_ROLLBACK_TARGET["version"] or value.rollback_target.get("target_oid") != C4_ROLLBACK_TARGET["target_oid"]:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if SHA256.fullmatch(str(value.rollback_target.get("artifact_sha256", ""))) is None:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if (
|
||||
host_provenance.environment != value.execution_scope
|
||||
or host_provenance.instance_id != value.instance_id
|
||||
or host_provenance.authority_commit
|
||||
!= value.executor_authority_commit
|
||||
or host_provenance.authority_tree != value.executor_authority_tree
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_BINDING_MISMATCH")
|
||||
if value.execution_scope == "PRODUCTION" and (
|
||||
value.rollback_target != C4_ROLLBACK_TARGET
|
||||
or value.rollback_artifact_path != C4_ROLLBACK_ARTIFACT_PATH
|
||||
or value.production_environment_file != C5_PRODUCTION_ENVIRONMENT_FILE
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
expected_adapter_paths = {
|
||||
"deployment": value.deployment_adapter_path,
|
||||
"tag": value.tag_command.adapter_path,
|
||||
"registry": value.registry_command.adapter_path,
|
||||
"baseline": value.baseline_command.adapter_path,
|
||||
}
|
||||
if any(
|
||||
host_provenance.entries[label][0] != path
|
||||
for label, path in expected_adapter_paths.items()
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ADAPTER_MISMATCH")
|
||||
production_names = {
|
||||
"deployment": "c5_production_deployment_adapter.py",
|
||||
"tag": "governed_tag_adapter.py",
|
||||
"registry": "release_metadata_adapter.py",
|
||||
"baseline": "release_metadata_adapter.py",
|
||||
}
|
||||
observed_names = {
|
||||
label: path.name for label, path in expected_adapter_paths.items()
|
||||
}
|
||||
if value.execution_scope == "PRODUCTION":
|
||||
if observed_names != production_names:
|
||||
raise C5ExecutionHostBlocked("PRODUCTION_ADAPTER_IDENTITY_INVALID")
|
||||
elif any(
|
||||
observed_names[label] == production_names[label]
|
||||
for label in production_names
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_PRODUCTION_ADAPTER_FORBIDDEN")
|
||||
value.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
return value
|
||||
|
||||
def verify_files(self, *, expected_owner_uid: int | None) -> None:
|
||||
for path, digest in (
|
||||
(self.trusted_policy_path, self.trusted_policy_sha256),
|
||||
(self.approval_keyring_path, self.approval_keyring_sha256),
|
||||
(self.source_admission_policy_path, self.source_admission_policy_sha256),
|
||||
(self.source_admission_hmac_path, self.source_admission_hmac_sha256),
|
||||
(self.git_executable, self.git_executable_sha256),
|
||||
):
|
||||
_secure_file(path, digest, expected_owner_uid=expected_owner_uid)
|
||||
for command in (
|
||||
self.tag_command, self.registry_command, self.baseline_command
|
||||
):
|
||||
command.verify(expected_owner_uid=expected_owner_uid)
|
||||
|
||||
|
||||
def _load_policy(path: Path) -> TrustedReleasePolicy:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
required = {
|
||||
"services", "release_roots", "cutover_links", "dropin_paths",
|
||||
"authoritative_git_dirs", "repository_identities",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != required:
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_SCHEMA_INVALID")
|
||||
values: dict[str, frozenset[str]] = {}
|
||||
for name in required:
|
||||
items = raw[name]
|
||||
if not isinstance(items, list) or not items or not all(
|
||||
isinstance(item, str) and item for item in items
|
||||
):
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_VALUES_INVALID")
|
||||
values[name] = frozenset(items)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
def _phase_request(candidate: ReleaseCandidate, plan: ReleasePlan, approval, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
"version": C5_VERSION,
|
||||
"target_oid": candidate.source_commit,
|
||||
"evidence_closure_oid": approval.evidence_closure_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"approval_plan_id": approval.approval_plan_id,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
}
|
||||
|
||||
|
||||
def _checked_phase_response(
|
||||
value: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
for name in (
|
||||
"version", "target_oid", "evidence_closure_oid",
|
||||
"artifact_sha256", "approval_plan_id",
|
||||
):
|
||||
if value.get(name) != request[name]:
|
||||
raise C5ExecutionHostBlocked(f"PHASE_RECEIPT_MISMATCH:{name}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
class _PreSwitchProbe:
|
||||
def __init__(self, config: C5ExecutionHostConfig, source_admissions):
|
||||
self.config = config
|
||||
self.source_admissions = source_admissions
|
||||
|
||||
def _git(self, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
[str(self.config.git_executable), f"--git-dir={self.config.authoritative_git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise C5ExecutionHostBlocked("PRE_SWITCH_GIT_UNAVAILABLE")
|
||||
return completed.stdout.strip()
|
||||
|
||||
def collect(self, candidate: ReleaseCandidate, plan) -> Mapping[str, Any]:
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
tree = self._git("rev-parse", f"{candidate.source_commit}^{{tree}}")
|
||||
artifact_ok = (
|
||||
self.config.artifact_path.is_file()
|
||||
and _sha256(self.config.artifact_path) == candidate.artifact_sha256
|
||||
)
|
||||
rollback_path = (
|
||||
C4_ROLLBACK_ARTIFACT_PATH
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_artifact_path
|
||||
)
|
||||
rollback_target = (
|
||||
C4_ROLLBACK_TARGET
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_target
|
||||
)
|
||||
rollback_ok = (
|
||||
rollback_path.is_file()
|
||||
and _sha256(rollback_path) == rollback_target["artifact_sha256"]
|
||||
)
|
||||
return {
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": tree,
|
||||
"artifact_sha256": candidate.artifact_sha256 if artifact_ok else "",
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"rollback_ready": rollback_ok,
|
||||
"git_refs_consistent": (
|
||||
admitted.get("source_commit") == candidate.source_commit
|
||||
and admitted.get("tree_oid") == tree
|
||||
),
|
||||
"repository_identity": plan.repository_identity,
|
||||
"writer_disabled": os.environ.get(
|
||||
"AIOS_GITOPS_WRITES_ENABLED", "false"
|
||||
).lower() == "false",
|
||||
}
|
||||
|
||||
|
||||
class _TagPhase:
|
||||
def __init__(self, command: RegisteredPhaseCommand, candidate, plan, approval, owner_uid):
|
||||
self.command_config = command
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
return _checked_phase_response(
|
||||
run_adapter(self.command_config.command, request), request
|
||||
)
|
||||
|
||||
def probe_evidence(self, tag_plan: TagPlan) -> TagPublicationProbeEvidence:
|
||||
value = self._call("probe")
|
||||
status = value.get("status")
|
||||
listing = value.get("gitea_listing_visible")
|
||||
if status == "NOT_APPLIED":
|
||||
listing = False
|
||||
return TagPublicationProbeEvidence(
|
||||
repository_identity=tag_plan.repository_identity,
|
||||
remote_reachable=True,
|
||||
gitea_api_reachable=True,
|
||||
gitea_listing_reachable=True,
|
||||
remote_tag_oid=value.get("remote_tag_oid"),
|
||||
annotated_tag_object_oid=value.get("tag_oid"),
|
||||
remote_peeled_target_oid=value.get("remote_peeled_target_oid"),
|
||||
gitea_api_tag_oid=value.get("gitea_tag_oid"),
|
||||
gitea_api_peeled_target_oid=value.get("gitea_peeled_target_oid"),
|
||||
gitea_listing_visible=listing,
|
||||
gitea_listing_tag_oid=(value.get("gitea_tag_oid") if listing else None),
|
||||
gitea_listing_peeled_target_oid=(value.get("gitea_peeled_target_oid") if listing else None),
|
||||
observed_at=time.time(),
|
||||
)
|
||||
|
||||
def __call__(self, *, tag_plan: TagPlan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") in {"NOT_APPLIED", "PARTIAL_LOCAL"}:
|
||||
value = self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "CONFLICT":
|
||||
raise TagPublicationConflict(
|
||||
"tag publication conflicts with the exact C5 plan"
|
||||
)
|
||||
if value.get("status") != "APPLIED_VERIFIED":
|
||||
raise RecoverableReleaseError(
|
||||
"tag publication outcome is indeterminate"
|
||||
)
|
||||
proof = self.probe_evidence(tag_plan)
|
||||
if proof.remote_tag_oid != tag_plan.expected_tag_oid:
|
||||
raise C5ExecutionHostBlocked("TAG_OBJECT_BINDING_MISMATCH")
|
||||
return {
|
||||
"verified": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"repository_identity": tag_plan.repository_identity,
|
||||
"remote_tag_oid": proof.remote_tag_oid,
|
||||
"remote_peeled_target_oid": proof.remote_peeled_target_oid,
|
||||
"gitea_api_tag_oid": proof.gitea_api_tag_oid,
|
||||
"gitea_api_peeled_target_oid": proof.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": proof.gitea_listing_visible,
|
||||
}
|
||||
|
||||
|
||||
class _MetadataPhase:
|
||||
def __init__(self, *, mode: str, command: RegisteredPhaseCommand, record_path: Path,
|
||||
candidate, plan, approval, tag_phase: _TagPhase, owner_uid):
|
||||
self.mode = mode
|
||||
self.command_config = command
|
||||
self.record_path = record_path
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.tag_phase = tag_phase
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
request["tag_oid"] = self.plan.tag.expected_tag_oid
|
||||
return _checked_phase_response(run_adapter(self.command_config.command, request), request)
|
||||
|
||||
def __call__(self, *, finalization_plan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "NOT_APPLIED":
|
||||
self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") != "VERIFIED":
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_FINALIZATION_NOT_VERIFIED")
|
||||
try:
|
||||
record = json.loads(self.record_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_INVALID") from exc
|
||||
expected_record = (
|
||||
finalization_plan.registry_expected_record
|
||||
if self.mode == "registry"
|
||||
else finalization_plan.baseline_expected_record
|
||||
)
|
||||
if any(record.get(key) != item for key, item in expected_record.items()):
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_BINDING_MISMATCH")
|
||||
tag = self.tag_phase.probe_evidence(self.plan.tag)
|
||||
if (
|
||||
tag.remote_tag_oid != self.plan.tag.expected_tag_oid
|
||||
or tag.remote_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_api_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_listing_visible is not True
|
||||
):
|
||||
raise C5ExecutionHostBlocked("FINALIZATION_TAG_PROOF_INVALID")
|
||||
proof = {
|
||||
"verified": True,
|
||||
"tag_verified": True,
|
||||
"remote_tag_oid": tag.remote_tag_oid,
|
||||
"remote_peeled_target_oid": tag.remote_peeled_target_oid,
|
||||
"gitea_api_peeled_target_oid": tag.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
f"{self.mode}_target_oid": self.candidate.source_commit,
|
||||
}
|
||||
if self.mode == "baseline":
|
||||
proof.update(
|
||||
{
|
||||
"registry_record": finalization_plan.registry_expected_record,
|
||||
"baseline_record": finalization_plan.baseline_expected_record,
|
||||
"baseline_written_last": True,
|
||||
}
|
||||
)
|
||||
return proof
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class C5ExecutionHost:
|
||||
config: C5ExecutionHostConfig
|
||||
orchestrator: ReleaseOrchestrator
|
||||
|
||||
def execute(self) -> dict[str, Any]:
|
||||
result = dict(self.orchestrator.execute(self.config.candidate_id))
|
||||
production_receipt = (
|
||||
self.config.execution_scope == "PRODUCTION"
|
||||
and result.get("state") == "COMPLETED"
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"execution_scope": self.config.execution_scope,
|
||||
"instance_id": self.config.instance_id,
|
||||
"provenance": dict(self.config.provenance),
|
||||
"host_authority_commit": self.config.host_provenance.authority_commit,
|
||||
"host_authority_tree": self.config.host_provenance.authority_tree,
|
||||
"host_provenance_sha256": self.config.host_provenance.manifest_sha256,
|
||||
"fixture_evidence": self.config.execution_scope == "ISOLATED_REHEARSAL",
|
||||
"production_receipt": production_receipt,
|
||||
"public_business_e2e_verified": False,
|
||||
"production_acceptance": False,
|
||||
}
|
||||
|
||||
|
||||
def _validate_source_admission_task(config, candidate, admitted) -> None:
|
||||
if admitted.get("task_id") != config.source_admission_task_id:
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_BINDING_INVALID")
|
||||
|
||||
|
||||
def build_execution_host(
|
||||
config: C5ExecutionHostConfig,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
completion_approvals: Any = None,
|
||||
) -> C5ExecutionHost:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise C5ExecutionHostBlocked("LEGACY_GLOBAL_WRITES_ENABLED")
|
||||
config.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
candidates = ReleaseCandidateStore(config.state_root / "candidates")
|
||||
plans = ReleasePlanStore(config.state_root / "release-plans")
|
||||
approvals = SignedApprovalStore(config.state_root / "approvals")
|
||||
completion_authority = None
|
||||
if completion_approvals is not None:
|
||||
from c5_completion_renewal import (
|
||||
RegisteredCompletionApprovalStore, RegisteredCompletionAuthority,
|
||||
CompletionRuntimeGuard,
|
||||
)
|
||||
if (type(completion_approvals) is not RegisteredCompletionApprovalStore
|
||||
or type(completion_approvals.authority) is not RegisteredCompletionAuthority
|
||||
or expected_owner_uid != 0 or config.execution_scope != "PRODUCTION"
|
||||
or Path(__file__) != completion_approvals.authority.root / "c5_release_execution_host.py"
|
||||
or Path(completion_approvals.original.root) != approvals.root):
|
||||
raise C5ExecutionHostBlocked("REGISTERED_COMPLETION_DEPENDENCY_REQUIRED")
|
||||
completion_authority = completion_approvals.authority
|
||||
completion_authority.validate_host(
|
||||
state_root=config.state_root, candidate_id=config.candidate_id,
|
||||
source_commit=config.source_commit, tree_oid=config.tree_oid,
|
||||
artifact_sha256=config.artifact_sha256,
|
||||
release_plan_sha256=config.release_plan_sha256,
|
||||
)
|
||||
approvals = completion_approvals
|
||||
sagas = ReleaseSagaStore(config.state_root / "sagas")
|
||||
candidate = candidates.load(config.candidate_id)
|
||||
plan = plans.load(config.candidate_id).validate()
|
||||
approval = approvals.load(config.candidate_id)
|
||||
observed = (
|
||||
candidate.source_commit, candidate.tree_oid, candidate.artifact_sha256,
|
||||
plan.sha256, approval.fingerprint,
|
||||
)
|
||||
expected = (
|
||||
config.source_commit, config.tree_oid, config.artifact_sha256,
|
||||
config.release_plan_sha256, config.approval_fingerprint,
|
||||
)
|
||||
if observed != expected or plan.tag.version != C5_VERSION:
|
||||
raise C5ExecutionHostBlocked("HOST_DURABLE_BINDING_MISMATCH")
|
||||
if (
|
||||
plan.policy_sha256 != config.trusted_policy_sha256
|
||||
or Path(plan.deployment.authoritative_git_dir)
|
||||
!= config.authoritative_git_dir
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_ACTUAL_PLAN_BINDING_MISMATCH")
|
||||
policy = _load_policy(config.trusted_policy_path)
|
||||
policy.validate_deployment(plan.deployment)
|
||||
verifier = SignedApprovalVerifier(
|
||||
ApprovalKeyRing.from_file(config.approval_keyring_path),
|
||||
ApprovalRevocationStore(config.state_root / "revocations"),
|
||||
)
|
||||
verifier.verify_release_plan(approval, candidate, plan, plan.policy_sha256)
|
||||
hmac_key = config.source_admission_hmac_path.read_bytes().strip()
|
||||
source_admissions = open_source_admission_store(
|
||||
config.source_admission_root,
|
||||
repository=REPOSITORY,
|
||||
policy=config.source_admission_policy_path,
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=False,
|
||||
)
|
||||
admitted = source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
_validate_source_admission_task(config, candidate, admitted)
|
||||
deployment_executor = RegisteredC5DeploymentExecutor(
|
||||
executor_id="c5-production-runtime-switch-v1",
|
||||
executable=config.deployment_executable,
|
||||
executable_sha256=config.deployment_executable_sha256,
|
||||
adapter_path=config.deployment_adapter_path,
|
||||
adapter_sha256=config.deployment_adapter_sha256,
|
||||
artifact_path=config.artifact_path,
|
||||
rollback_artifact_path=config.rollback_artifact_path,
|
||||
production_environment_file=config.production_environment_file,
|
||||
execution_scope=config.execution_scope,
|
||||
isolation_root=(config.state_root if config.execution_scope == "ISOLATED_REHEARSAL" else None),
|
||||
rollback_target=config.rollback_target,
|
||||
require_root_owner=expected_owner_uid == 0,
|
||||
)
|
||||
runtime = C5ExactDeploymentAdapter(
|
||||
candidate=candidate,
|
||||
release_plan=plan,
|
||||
approval=approval,
|
||||
approval_verifier=verifier,
|
||||
source_admissions=source_admissions,
|
||||
executor=deployment_executor,
|
||||
)
|
||||
ledger = CapabilityLedger(config.state_root / "capabilities")
|
||||
tag_phase = _TagPhase(
|
||||
config.tag_command, candidate, plan, approval, expected_owner_uid
|
||||
)
|
||||
tag = HardenedTagController(
|
||||
ledger,
|
||||
tag_phase,
|
||||
expected_repository=REPOSITORY,
|
||||
publication_probe=tag_phase.probe_evidence,
|
||||
executor_id="c5-production-tag-v1",
|
||||
)
|
||||
registry_adapter = _MetadataPhase(
|
||||
mode="registry", command=config.registry_command,
|
||||
record_path=config.registry_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
baseline_adapter = _MetadataPhase(
|
||||
mode="baseline", command=config.baseline_command,
|
||||
record_path=config.baseline_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
finalizer = GovernedFinalizationCoordinator(
|
||||
RegistryController(ledger, registry_adapter, executor_id="c5-production-registry-v1"),
|
||||
BaselineController(ledger, baseline_adapter, executor_id="c5-production-baseline-v1"),
|
||||
)
|
||||
|
||||
def actual_plan_factory(actual_candidate: ReleaseCandidate) -> ReleasePlan:
|
||||
actual = plans.load(actual_candidate.candidate_id).validate()
|
||||
runtime._verify_binding(actual_candidate, actual)
|
||||
return actual
|
||||
|
||||
def runtime_verify(*, candidate: ReleaseCandidate, plan: ReleasePlan) -> Mapping[str, Any]:
|
||||
runtime._verify_binding(candidate, plan)
|
||||
result = runtime._invoke("probe")
|
||||
if runtime._classify(result) != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5ExecutionHostBlocked("RUNTIME_VERIFY_INDETERMINATE")
|
||||
return {
|
||||
"verified": True,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"authoritative_git_dir": plan.deployment.authoritative_git_dir,
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
orchestrator = ReleaseOrchestrator(
|
||||
candidates=candidates,
|
||||
plans=plans,
|
||||
approvals=approvals,
|
||||
sagas=sagas,
|
||||
approval_verifier=verifier,
|
||||
trusted_policy=policy,
|
||||
actual_plan_factory=actual_plan_factory,
|
||||
pre_switch_validator=PreSwitchValidator(
|
||||
_PreSwitchProbe(config, source_admissions)
|
||||
),
|
||||
runtime_switch=(CompletionRuntimeGuard(completion_authority) if completion_authority
|
||||
else runtime.controller(ledger)),
|
||||
runtime_verify=runtime_verify,
|
||||
runtime_rollback=(completion_authority.deny_runtime_effect if completion_authority
|
||||
else runtime.rollback),
|
||||
tag_publish=tag,
|
||||
finalizer=finalizer,
|
||||
)
|
||||
return C5ExecutionHost(config=config, orchestrator=orchestrator)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--config-sha256", required=True)
|
||||
parser.add_argument("--provenance", type=Path, required=True)
|
||||
parser.add_argument("--provenance-sha256", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
provenance = C5HostProvenance.load(
|
||||
args.provenance,
|
||||
expected_sha256=args.provenance_sha256,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = C5ExecutionHostConfig.load(
|
||||
args.config,
|
||||
expected_sha256=args.config_sha256,
|
||||
host_provenance=provenance,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
result = build_execution_host(config, expected_owner_uid=0).execute()
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (C5ExecutionHostBlocked, CapabilityAuthorizationError) as exc:
|
||||
print(json.dumps({"status": "FAIL_CLOSED", "error": type(exc).__name__}))
|
||||
raise SystemExit(2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
"""Fail-closed component deployment identity projection.
|
||||
|
||||
This module deliberately does not discover a trust root or treat process
|
||||
environment/health responses as runtime evidence. A host supplies a trusted
|
||||
comparison callback after it has performed its own authenticated observation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_COMPONENT_SCHEMA = "aios.component-manifest.v1"
|
||||
_RECEIPT_SCHEMA = "aios.component-artifact-receipt.v1"
|
||||
|
||||
|
||||
class RuntimeIdentityError(ValueError):
|
||||
"""A declared or observed identity has an invalid shape."""
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise RuntimeIdentityError("DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
if not path.is_file() or path.is_symlink() or metadata.st_size > 2 * 1024 * 1024:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_strict_object)
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("IDENTITY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def _require_sha(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_oid(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _OID.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
try:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_VALUE_INVALID") from exc
|
||||
|
||||
|
||||
def _validate_modules(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping) or not value:
|
||||
raise RuntimeIdentityError("MODULES_INVALID")
|
||||
modules: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "module_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("MODULE_PATH_INVALID")
|
||||
modules[name] = _require_sha(digest, "module_sha256")
|
||||
return dict(sorted(modules.items()))
|
||||
|
||||
|
||||
def _validate_extra_files(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("EXTRA_FILES_INVALID")
|
||||
files: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "extra_file_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("EXTRA_FILE_PATH_INVALID")
|
||||
files[name] = _require_sha(digest, "extra_file_sha256")
|
||||
return dict(sorted(files.items()))
|
||||
|
||||
|
||||
def _relative_artifact_path(value: object, field: str, *, allow_current: bool = False) -> str:
|
||||
name = _require_text(value, field)
|
||||
path = Path(name)
|
||||
if "\\" in name or path.is_absolute() or ".." in path.parts or (name == "." and not allow_current):
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return name
|
||||
|
||||
|
||||
def _validate_start_identity(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"entrypoint", "config_path", "python_path"}:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
entrypoint = _require_text(value.get("entrypoint"), "entrypoint")
|
||||
config_path = _relative_artifact_path(value.get("config_path"), "config_path")
|
||||
python_path = value.get("python_path")
|
||||
if not isinstance(python_path, list) or not python_path:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
result = {
|
||||
"entrypoint": entrypoint,
|
||||
"config_path": config_path,
|
||||
"python_path": [
|
||||
_relative_artifact_path(path, "python_path", allow_current=True)
|
||||
for path in python_path
|
||||
],
|
||||
}
|
||||
_canonical(result)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_component(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256",
|
||||
"start_identity", "dependency_contract_version", "rollback_artifact_sha256",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
rollback = value.get("rollback_artifact_sha256")
|
||||
if rollback is not None:
|
||||
rollback = _require_sha(rollback, "rollback_artifact_sha256")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"dependency_contract_version": _require_text(value.get("dependency_contract_version"), "dependency_contract_version"),
|
||||
"rollback_artifact_sha256": rollback,
|
||||
}
|
||||
|
||||
|
||||
def validate_component_manifest(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "schema_version", "components"}:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
if value.get("schema") != _COMPONENT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_SCHEMA_INVALID")
|
||||
components = value.get("components")
|
||||
if not isinstance(components, list) or not components:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_COMPONENTS_INVALID")
|
||||
normalized = [_validate_component(component) for component in components]
|
||||
ids = [component["component_id"] for component in normalized]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise RuntimeIdentityError("COMPONENT_ID_DUPLICATE")
|
||||
return {"schema": _COMPONENT_SCHEMA, "schema_version": 1, "components": normalized}
|
||||
|
||||
|
||||
def load_component_manifest(path: Path) -> tuple[dict[str, Any], str]:
|
||||
raw = path.read_bytes()
|
||||
return validate_component_manifest(_load_json(path)), hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_artifact_receipt(value: object) -> dict[str, Any]:
|
||||
required = {"schema", "schema_version", "artifact_sha256", "component_manifest_sha256", "manifest_sha256", "evidence_refs"}
|
||||
optional = {"version", "source_commit", "tree_oid", "artifact_size", "production_deployed"}
|
||||
if not isinstance(value, Mapping) or not required.issubset(value) or set(value) - required - optional:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_FIELDS_INVALID")
|
||||
if value.get("schema") != _RECEIPT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_SCHEMA_INVALID")
|
||||
references = value.get("evidence_refs")
|
||||
if not isinstance(references, list) or not references or any(not isinstance(ref, str) or not ref.strip() for ref in references):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
result = {
|
||||
"schema": _RECEIPT_SCHEMA,
|
||||
"schema_version": 1,
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"component_manifest_sha256": _require_sha(value.get("component_manifest_sha256"), "component_manifest_sha256"),
|
||||
"manifest_sha256": _require_sha(value.get("manifest_sha256"), "manifest_sha256"),
|
||||
"evidence_refs": list(references),
|
||||
}
|
||||
if "version" in value:
|
||||
result["version"] = _require_text(value["version"], "version")
|
||||
if "source_commit" in value:
|
||||
result["source_commit"] = _require_oid(value["source_commit"], "source_commit")
|
||||
if "tree_oid" in value:
|
||||
result["tree_oid"] = _require_oid(value["tree_oid"], "tree_oid")
|
||||
if "artifact_size" in value:
|
||||
size = value["artifact_size"]
|
||||
if type(size) is not int or size < 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_SIZE_INVALID")
|
||||
result["artifact_size"] = size
|
||||
if "production_deployed" in value:
|
||||
deployed = value["production_deployed"]
|
||||
if type(deployed) is not bool:
|
||||
raise RuntimeIdentityError("PRODUCTION_DEPLOYED_INVALID")
|
||||
result["production_deployed"] = deployed
|
||||
return result
|
||||
|
||||
|
||||
def load_artifact_receipt(path: Path) -> dict[str, Any]:
|
||||
return validate_artifact_receipt(_load_json(path))
|
||||
|
||||
|
||||
def _identity_result(
|
||||
status: str,
|
||||
*,
|
||||
component_id: str | None,
|
||||
declared: Mapping[str, Any] | None = None,
|
||||
observed: Mapping[str, Any] | None = None,
|
||||
reason: str,
|
||||
evidence_refs: list[str] | None = None,
|
||||
mismatches: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"component_id": component_id,
|
||||
"declared_identity": dict(declared) if declared is not None else None,
|
||||
"observed_identity": dict(observed) if observed is not None else None,
|
||||
"manifest_digest": declared.get("component_manifest_sha256") if declared else None,
|
||||
"config_digest": declared.get("config_sha256") if declared else None,
|
||||
"observed_at": observed.get("observed_at") if observed else None,
|
||||
"expires_at": observed.get("expires_at") if observed else None,
|
||||
"evidence_refs": list(evidence_refs or ()),
|
||||
"reason": reason,
|
||||
"mismatch_fields": list(mismatches or ()),
|
||||
}
|
||||
|
||||
|
||||
def declared_component_identity(manifest_path: Path | None, receipt_path: Path | None, component_id: str | None) -> dict[str, Any] | None:
|
||||
if manifest_path is None or receipt_path is None or not component_id:
|
||||
return None
|
||||
manifest, manifest_digest = load_component_manifest(manifest_path)
|
||||
receipt = load_artifact_receipt(receipt_path)
|
||||
if receipt["component_manifest_sha256"] != manifest_digest:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_DIGEST_MISMATCH")
|
||||
component = next((item for item in manifest["components"] if item["component_id"] == component_id), None)
|
||||
if component is None:
|
||||
raise RuntimeIdentityError("COMPONENT_ID_NOT_DECLARED")
|
||||
return {
|
||||
**component,
|
||||
"artifact_sha256": receipt["artifact_sha256"],
|
||||
"component_manifest_sha256": receipt["component_manifest_sha256"],
|
||||
"manifest_sha256": receipt["manifest_sha256"],
|
||||
}
|
||||
|
||||
|
||||
def _timestamp(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or float(value) < 0:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _validate_observed(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity",
|
||||
"artifact_sha256", "observed_at", "expires_at", "evidence_refs",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("OBSERVED_IDENTITY_FIELDS_INVALID")
|
||||
observed_at = _timestamp(value.get("observed_at"), "observed_at")
|
||||
expires_at = _timestamp(value.get("expires_at"), "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise RuntimeIdentityError("OBSERVATION_EXPIRY_INVALID")
|
||||
refs = value.get("evidence_refs")
|
||||
if not isinstance(refs, list) or not refs or any(not isinstance(ref, str) or not ref.strip() for ref in refs):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"observed_at": observed_at,
|
||||
"expires_at": expires_at,
|
||||
"evidence_refs": list(refs),
|
||||
}
|
||||
|
||||
|
||||
TrustedComparison = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
||||
|
||||
|
||||
def evaluate_component_runtime_identity(
|
||||
*,
|
||||
component_id: str | None,
|
||||
manifest_path: Path | None,
|
||||
receipt_path: Path | None,
|
||||
trusted_comparison: TrustedComparison | None = None,
|
||||
now: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project identity without granting trust to a declaration or self-report."""
|
||||
try:
|
||||
declared = declared_component_identity(manifest_path, receipt_path, component_id)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, reason=str(exc))
|
||||
if declared is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, reason="DECLARED_IDENTITY_UNAVAILABLE")
|
||||
if trusted_comparison is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
raw_observed = trusted_comparison(dict(declared))
|
||||
except Exception:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_COMPARISON_UNAVAILABLE")
|
||||
if raw_observed is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="OBSERVATION_MISSING")
|
||||
try:
|
||||
observed = _validate_observed(raw_observed)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, reason=str(exc))
|
||||
current = time.time() if now is None else now
|
||||
if observed["expires_at"] <= current:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, observed=observed, reason="OBSERVATION_EXPIRED", evidence_refs=observed["evidence_refs"])
|
||||
fields = ("component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity", "artifact_sha256")
|
||||
mismatches = [field for field in fields if _canonical(declared[field]) != _canonical(observed[field])]
|
||||
if mismatches:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, observed=observed, reason="DECLARED_OBSERVED_DRIFT", evidence_refs=observed["evidence_refs"], mismatches=mismatches)
|
||||
return _identity_result("PASS", component_id=component_id, declared=declared, observed=observed, reason="TRUSTED_COMPARISON_MATCH", evidence_refs=observed["evidence_refs"])
|
||||
@@ -9,6 +9,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_STATES = {"STAGED", "ACTIVE", "ROLLED_BACK"}
|
||||
@@ -25,6 +27,13 @@ class RuntimeDeploymentState:
|
||||
return self.deployment_state == "ACTIVE"
|
||||
|
||||
def health_payload(self) -> dict[str, Any]:
|
||||
# This listener owns liveness only. Its environment-derived release
|
||||
# declaration must never become runtime identity evidence by itself.
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=None,
|
||||
manifest_path=None,
|
||||
receipt_path=None,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": self.version,
|
||||
@@ -33,6 +42,8 @@ class RuntimeDeploymentState:
|
||||
"deployment_state": self.deployment_state,
|
||||
"production_deployed": self.production_deployed,
|
||||
"health_timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Concrete transport adapter for the governed source publication boundary.
|
||||
|
||||
The adapter composes the already existing live writer executor. It owns no
|
||||
leases, TaskPrincipal, credentials, retry policy, or release state machine.
|
||||
Those remain server side. ``authenticated_readback`` is deliberately an
|
||||
injected port: a local commit response is not evidence of an authenticated
|
||||
source/tree binding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from governed_unattended_release import SourcePublicationIntent
|
||||
from task_plane.governed_writer_automation import GovernedWriterRequest
|
||||
|
||||
|
||||
class GovernedPublicationError(RuntimeError):
|
||||
"""Stable fail-closed publication error."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class GovernedWriterExecutor(Protocol):
|
||||
def acquire(self, *, request: GovernedWriterRequest, job_id: str) -> Any: ...
|
||||
|
||||
def commit_and_readback(
|
||||
self, *, session: Any, request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes], expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def submit_task_result(
|
||||
self, *, session: Any, result: Mapping[str, Any], idempotency_key: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class SourceFileReader(Protocol):
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]: ...
|
||||
|
||||
|
||||
class AuthenticatedReadback(Protocol):
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent, request: GovernedWriterRequest,
|
||||
session: Any, commit_result: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class AdmissionBindingReader(Protocol):
|
||||
"""Read the server-side task/governed binding after admission."""
|
||||
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent,
|
||||
admission_receipt: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class GitTreeFileReader(Protocol):
|
||||
"""Read bytes from an immutable committed tree object, never a workspace."""
|
||||
|
||||
def __call__(self, *, tree_oid: str, path: str) -> bytes: ...
|
||||
|
||||
|
||||
class CommittedTreeSourceReader:
|
||||
"""Adapt an authoritative git-object reader to the publication port."""
|
||||
|
||||
def __init__(self, reader: GitTreeFileReader) -> None:
|
||||
if not callable(reader):
|
||||
raise ValueError("git_object_reader_required")
|
||||
self.reader = reader
|
||||
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]:
|
||||
return {
|
||||
path: self.reader(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SAFE_CODE = re.compile(r"^(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}$")
|
||||
_SAFE_OPERATION = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,63}$")
|
||||
|
||||
|
||||
def _oid(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _OID.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_OID_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _SHA.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_SHA_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
dict(value), sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_EVIDENCE_INVALID") from exc
|
||||
|
||||
|
||||
class GovernedPublicationClient:
|
||||
"""Run TaskLease -> TaskPrincipal -> WriterLease -> commit through ports.
|
||||
|
||||
``writer_executor`` is normally ``LiveGovernedWriterExecutor`` from
|
||||
``task_plane.live_governed_writer_adapters``. ``source_files`` must return
|
||||
the exact immutable bytes for the intent's allowlist; it is never allowed
|
||||
to return a path or a mutable object. The authenticated readback port
|
||||
must be backed by the authoritative provider/bridge and prove the final
|
||||
source OID, tree OID, task, branch, base, generation, and execution digest.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
writer_executor: GovernedWriterExecutor,
|
||||
source_files: SourceFileReader | None = None,
|
||||
git_tree_file: GitTreeFileReader | None = None,
|
||||
admission_binding: AdmissionBindingReader,
|
||||
authenticated_readback: AuthenticatedReadback,
|
||||
subject: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
commit_message: str = "governed source publication",
|
||||
diagnostic_sink: Callable[[Mapping[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
writer_executor is None
|
||||
or any(
|
||||
not callable(getattr(writer_executor, name, None))
|
||||
for name in (
|
||||
"acquire", "commit_and_readback", "submit_task_result",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("writer_executor_required")
|
||||
if source_files is not None:
|
||||
raise ValueError("workspace_source_reader_forbidden")
|
||||
if git_tree_file is None:
|
||||
raise ValueError("git_object_source_reader_required")
|
||||
if not callable(admission_binding) or not callable(authenticated_readback):
|
||||
raise ValueError("authenticated_ports_required")
|
||||
for name, value in (("subject", subject), ("manifest_id", manifest_id),
|
||||
("runtime_fingerprint", runtime_fingerprint),
|
||||
("process_start_identity", process_start_identity),
|
||||
("commit_message", commit_message)):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(name + "_required")
|
||||
self.writer_executor = writer_executor
|
||||
self.source_files = None
|
||||
self.git_tree_file = git_tree_file
|
||||
self.admission_binding = admission_binding
|
||||
self.authenticated_readback = authenticated_readback
|
||||
self.subject = subject
|
||||
self.manifest_id = manifest_id
|
||||
self.runtime_fingerprint = runtime_fingerprint
|
||||
self.process_start_identity = process_start_identity
|
||||
self.commit_message = commit_message
|
||||
if diagnostic_sink is not None and not callable(diagnostic_sink):
|
||||
raise ValueError("diagnostic_sink_invalid")
|
||||
self.diagnostic_sink = diagnostic_sink
|
||||
|
||||
def _record_uncertain_effect(self, *, exc: Exception, task_id: str,
|
||||
stage: str) -> None:
|
||||
"""Emit only allowlisted facts; never serialize an exception or body."""
|
||||
if self.diagnostic_sink is None:
|
||||
return
|
||||
raw_status = getattr(exc, "status", None)
|
||||
status = raw_status if type(raw_status) is int and 100 <= raw_status <= 599 else None
|
||||
raw_code = getattr(exc, "error_code", None)
|
||||
code = raw_code if isinstance(raw_code, str) and _SAFE_CODE.fullmatch(raw_code) else None
|
||||
raw_operation = getattr(exc, "operation", None)
|
||||
operation = (
|
||||
raw_operation if isinstance(raw_operation, str)
|
||||
and _SAFE_OPERATION.fullmatch(raw_operation) else stage
|
||||
)
|
||||
diagnostic = {
|
||||
"task_id": task_id, "operation": operation,
|
||||
"http_status": status,
|
||||
"error_code": (
|
||||
"transport_unknown" if status is None else (code or "issuer_error_unknown")
|
||||
),
|
||||
}
|
||||
try:
|
||||
self.diagnostic_sink(diagnostic)
|
||||
except Exception:
|
||||
# Diagnostic observation must never change the uncertainty policy.
|
||||
pass
|
||||
|
||||
def _validate_admission(self, intent: SourcePublicationIntent, receipt: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_INVALID")
|
||||
for key, expected in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid),
|
||||
("target_branch", intent.target_branch), ("generation", intent.generation),
|
||||
("execution_digest", intent.execution_digest)):
|
||||
if receipt.get(key) != expected:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
try:
|
||||
binding = self.admission_binding(intent=intent, admission_receipt=receipt)
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_UNAVAILABLE") from exc
|
||||
if not isinstance(binding, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_INVALID")
|
||||
expected = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(binding.get(key) != value for key, value in expected.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
job_id = binding.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise GovernedPublicationError("PUBLICATION_JOB_BINDING_MISSING")
|
||||
return dict(binding)
|
||||
|
||||
def _read_files(self, intent: SourcePublicationIntent) -> tuple[dict[str, bytes], dict[str, str]]:
|
||||
try:
|
||||
raw = {
|
||||
path: self.git_tree_file(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH") from exc
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_READ_FAILED") from exc
|
||||
if not isinstance(raw, Mapping) or set(raw) != set(intent.file_allowlist):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH")
|
||||
files: dict[str, bytes] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for path in intent.file_allowlist:
|
||||
data = raw.get(path)
|
||||
if isinstance(data, bytearray):
|
||||
data = bytes(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_BYTES_REQUIRED")
|
||||
files[path] = bytes(data)
|
||||
hashes[path] = hashlib.sha256(files[path]).hexdigest()
|
||||
return files, hashes
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise GovernedPublicationError("PUBLICATION_INTENT_REQUIRED")
|
||||
admission_binding = self._validate_admission(intent, admission_receipt)
|
||||
job_id = str(admission_binding["job_id"])
|
||||
files, hashes = self._read_files(intent)
|
||||
request = GovernedWriterRequest(
|
||||
subject=self.subject, task_id=intent.task_id, repository=intent.repository,
|
||||
target_branch=intent.target_branch, risk=intent.risk, generation=intent.generation,
|
||||
base_oid=intent.authority_base_oid,
|
||||
required_capabilities=frozenset(intent.requested_capabilities),
|
||||
file_allowlist=tuple(intent.file_allowlist),
|
||||
plan_digest="sha256:" + intent.execution_digest,
|
||||
manifest_id=self.manifest_id, runtime_fingerprint=self.runtime_fingerprint,
|
||||
process_start_identity=self.process_start_identity,
|
||||
)
|
||||
stage = "acquireWriterLease"
|
||||
try:
|
||||
session = self.writer_executor.acquire(request=request, job_id=job_id)
|
||||
stage = "commitFiles"
|
||||
commit_result = self.writer_executor.commit_and_readback(
|
||||
session=session, request=request, files=files,
|
||||
expected_sha256=hashes, message=self.commit_message,
|
||||
)
|
||||
if not isinstance(commit_result, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_READBACK_INVALID")
|
||||
readback = self.authenticated_readback(
|
||||
intent=intent, request=request, session=session,
|
||||
commit_result=commit_result,
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A transport exception after a mutation is indeterminate. Never
|
||||
# resubmit it here; the caller must reconcile through the existing
|
||||
# Job/recovery service.
|
||||
self._record_uncertain_effect(exc=exc, task_id=intent.task_id, stage=stage)
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
if not isinstance(readback, Mapping) or readback.get("authenticated") is not True:
|
||||
raise GovernedPublicationError("PUBLICATION_AUTHENTICATED_READBACK_REQUIRED")
|
||||
required = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(readback.get(key) != value for key, value in required.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_READBACK_BINDING_MISMATCH")
|
||||
for key in ("task_lease", "task_principal", "writer_lease"):
|
||||
if not isinstance(readback.get(key), Mapping) or readback[key].get("task_id") != intent.task_id:
|
||||
raise GovernedPublicationError("PUBLICATION_LEASE_CHAIN_MISSING")
|
||||
source_oid = _oid(readback.get("source_oid"))
|
||||
tree_oid = _oid(readback.get("tree_oid"))
|
||||
nested_commit = readback.get("commit_receipt")
|
||||
if nested_commit is not None and not isinstance(nested_commit, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
commit_oid = _oid(readback.get("commit_oid") or (nested_commit or {}).get("commit_oid"))
|
||||
if source_oid != commit_oid or tree_oid != intent.source_tree_oid:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_TREE_BINDING_MISMATCH")
|
||||
commit_receipt = readback.get("commit_receipt")
|
||||
if not isinstance(commit_receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_RECEIPT_REQUIRED")
|
||||
# The live governed receipt binds commit_oid; the independently read
|
||||
# Git tree is carried by authenticated readback. Preserve the original
|
||||
# receipt instead of inventing a tree field absent from its schema.
|
||||
if (commit_receipt.get("commit_oid") != commit_oid
|
||||
or ("tree_oid" in commit_receipt and commit_receipt["tree_oid"] != tree_oid)):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
publication = {
|
||||
**dict(readback), "authenticated": True, "task_id": intent.task_id,
|
||||
"job_id": job_id, "source_oid": source_oid, "tree_oid": tree_oid,
|
||||
"generation": intent.generation, "target_branch": intent.target_branch,
|
||||
"authority_base_oid": intent.authority_base_oid,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"commit_receipt": dict(commit_receipt),
|
||||
}
|
||||
task_result = {
|
||||
"schema": "aios.governed-source-publication-result.v1",
|
||||
"task_id": intent.task_id,
|
||||
"job_id": job_id,
|
||||
"repository": intent.repository,
|
||||
"target_branch": intent.target_branch,
|
||||
"generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"authority_base_oid": _oid(intent.authority_base_oid),
|
||||
"candidate_source_oid": _oid(intent.source_commit),
|
||||
"published_source_oid": source_oid,
|
||||
"source_tree_oid": tree_oid,
|
||||
"commit_receipt_sha256": hashlib.sha256(
|
||||
_canonical(commit_receipt)
|
||||
).hexdigest(),
|
||||
}
|
||||
result_sha256 = hashlib.sha256(_canonical(task_result)).hexdigest()
|
||||
idempotency_key = "governed-source-result-" + result_sha256
|
||||
try:
|
||||
submitted = self.writer_executor.submit_task_result(
|
||||
session=session, result=task_result,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if (
|
||||
not isinstance(submitted, Mapping)
|
||||
or submitted.get("task_id") != intent.task_id
|
||||
or submitted.get("status") != "VERIFYING"
|
||||
or submitted.get("result") != task_result
|
||||
):
|
||||
raise GovernedPublicationError(
|
||||
"PUBLICATION_TASK_RESULT_READBACK_INVALID"
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A lost result response may have changed the durable Task. Never
|
||||
# re-register, reclaim or retry here.
|
||||
self._record_uncertain_effect(
|
||||
exc=exc, task_id=intent.task_id, stage="submitTaskResult",
|
||||
)
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
publication.update(
|
||||
task_result=task_result,
|
||||
task_result_idempotency_key=idempotency_key,
|
||||
task_result_submission={
|
||||
"task_id": submitted["task_id"], "status": submitted["status"],
|
||||
"revision": submitted.get("revision"),
|
||||
"idempotent": submitted.get("idempotent", False),
|
||||
"result_sha256": result_sha256,
|
||||
},
|
||||
)
|
||||
return publication
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Narrow C5 unattended source-publication policy lifecycle.
|
||||
|
||||
The policy is an Owner-signed, exact-scope admission object. It is checked
|
||||
against the configured Owner policy at issuance and against a live revocation
|
||||
source for every request. It is not an activation/configuration mechanism
|
||||
and does not create release or production authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
SOURCE_PUBLICATION_CAPABILITIES,
|
||||
SOURCE_PUBLICATION_POLICY_ID,
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
SignedArtifact,
|
||||
SingleHostAuthorityError,
|
||||
SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
|
||||
_POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "policy_id", "owner_identity", "repository", "trust_domain",
|
||||
"policy_digest",
|
||||
"authority_base_oid", "source_oid", "source_tree_oid", "target_agent",
|
||||
"target_branch", "generation", "execution_digest", "exact_files",
|
||||
"capabilities", "risk", "production_impact", "renewal", "nonce",
|
||||
"issued_at", "not_before", "expires_at", "status", "signature_namespace",
|
||||
"signature_key_id", "canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
_REQUEST_FIELDS = frozenset(SourcePublicationPolicyRequest.__dataclass_fields__)
|
||||
|
||||
# The typed Owner epoch producer uses the configured policy's production_scope.
|
||||
# The installed C5 source-only policy contract uses ``none`` for production
|
||||
# impact; keep this exact value bound to the C5 policy rather than accepting an
|
||||
# arbitrary non-empty scope from a raw signer.
|
||||
SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE = "none"
|
||||
SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicyProjection:
|
||||
"""The only policy projection exposed after an exact request guard."""
|
||||
|
||||
max_risk: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
capabilities: tuple[str, ...]
|
||||
exact_files: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _request_dict(request: SourcePublicationPolicyRequest | Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(request, SourcePublicationPolicyRequest):
|
||||
value = asdict(request)
|
||||
elif isinstance(request, Mapping):
|
||||
value = dict(request)
|
||||
else:
|
||||
raise TypeError("request must be SourcePublicationPolicyRequest or mapping")
|
||||
if set(value) != _REQUEST_FIELDS:
|
||||
raise ValueError("SOURCE_PUBLICATION_REQUEST_FIELDS_INVALID")
|
||||
if "exact_files" in value:
|
||||
value["exact_files"] = tuple(value["exact_files"])
|
||||
return value
|
||||
|
||||
|
||||
class SourcePublicationPolicyVerifier:
|
||||
"""Strict verifier over the existing Owner registry verifier interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner_verifier: Any,
|
||||
*,
|
||||
revocation_check: Callable[[Mapping[str, Any]], bool] | None,
|
||||
expected_policy_digest: str | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.owner_verifier = owner_verifier
|
||||
self.revocation_check = revocation_check
|
||||
self.expected_policy_digest = expected_policy_digest
|
||||
self.clock = clock
|
||||
|
||||
def _deny(self, code: str) -> None:
|
||||
raise SingleHostAuthorityError(code)
|
||||
|
||||
def _validate_payload(self, artifact: SignedArtifact) -> dict[str, Any]:
|
||||
if not isinstance(artifact, SignedArtifact):
|
||||
self._deny("SOURCE_PUBLICATION_ARTIFACT_INVALID")
|
||||
payload = dict(artifact.payload)
|
||||
if set(payload) != _POLICY_FIELDS:
|
||||
self._deny("SOURCE_PUBLICATION_UNKNOWN_FIELDS")
|
||||
try:
|
||||
if artifact.canonical_payload != canonical_json_bytes(payload):
|
||||
self._deny("SOURCE_PUBLICATION_CANONICAL_BYTES_MISMATCH")
|
||||
if payload["canonical_payload_digest"] != sha256_json(
|
||||
{k: v for k, v in payload.items() if k != "canonical_payload_digest"}
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_PAYLOAD_DIGEST_INVALID")
|
||||
if payload["schema"] != SOURCE_PUBLICATION_POLICY_SCHEMA:
|
||||
self._deny("SOURCE_PUBLICATION_SCHEMA_INVALID")
|
||||
if payload["policy_id"] != SOURCE_PUBLICATION_POLICY_ID:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_ID_INVALID")
|
||||
if payload["owner_identity"] != OWNER_IDENTITY:
|
||||
self._deny("SOURCE_PUBLICATION_OWNER_INVALID")
|
||||
if not isinstance(payload["policy_digest"], str) or not _HEX64.fullmatch(payload["policy_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_INVALID")
|
||||
if self.expected_policy_digest is not None and payload["policy_digest"] != self.expected_policy_digest:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_MISMATCH")
|
||||
if payload["signature_namespace"] != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_NAMESPACE_INVALID")
|
||||
if payload["signature_key_id"] != artifact.key_id or artifact.namespace != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_ISSUER_MISMATCH")
|
||||
for name in ("authority_base_oid", "source_oid", "source_tree_oid"):
|
||||
if not isinstance(payload[name], str) or not _HEX40.fullmatch(payload[name]):
|
||||
self._deny("SOURCE_PUBLICATION_OID_INVALID")
|
||||
if not isinstance(payload["execution_digest"], str) or not _HEX64.fullmatch(payload["execution_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_EXECUTION_DIGEST_INVALID")
|
||||
branch = payload["target_branch"]
|
||||
agent = payload["target_agent"]
|
||||
if (
|
||||
not isinstance(branch, str) or not branch.startswith("agent/")
|
||||
or "\\" in branch or ".." in PurePosixPath(branch).parts
|
||||
or branch.endswith(("/", "."))
|
||||
or not isinstance(agent, str) or not agent.startswith("agent/")
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_TARGET_INVALID")
|
||||
if payload["risk"] != "engineering" or payload["production_impact"] != "none" or payload["renewal"] != "none":
|
||||
self._deny("SOURCE_PUBLICATION_SCOPE_INVALID")
|
||||
if payload["capabilities"] != sorted(SOURCE_PUBLICATION_CAPABILITIES):
|
||||
self._deny("SOURCE_PUBLICATION_CAPABILITIES_INVALID")
|
||||
if not isinstance(payload["exact_files"], list) or not payload["exact_files"] or len(set(payload["exact_files"])) != len(payload["exact_files"]):
|
||||
self._deny("SOURCE_PUBLICATION_FILES_INVALID")
|
||||
if type(payload["generation"]) is not int or payload["generation"] < 1:
|
||||
self._deny("SOURCE_PUBLICATION_GENERATION_INVALID")
|
||||
if type(payload["issued_at"]) is not int or type(payload["not_before"]) is not int or type(payload["expires_at"]) is not int:
|
||||
self._deny("SOURCE_PUBLICATION_TIME_INVALID")
|
||||
now = int(self.clock())
|
||||
if not payload["not_before"] <= payload["issued_at"] <= now < payload["expires_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_EXPIRED")
|
||||
if payload["expires_at"] <= payload["issued_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if payload["expires_at"] - payload["issued_at"] > 3600 or payload["status"] != "ACTIVE":
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if self.revocation_check is None:
|
||||
self._deny("SOURCE_PUBLICATION_REVOCATION_CHECK_MISSING")
|
||||
if self.revocation_check(payload) is not True:
|
||||
self._deny("SOURCE_PUBLICATION_REVOKED")
|
||||
except SingleHostAuthorityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_INVALID") from exc
|
||||
return payload
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self._validate_payload(artifact)
|
||||
# Verify exactly the bytes supplied by the signed artifact. Do not
|
||||
# reconstruct or reserialize them before invoking the registry API.
|
||||
if self.owner_verifier.verify(
|
||||
namespace=artifact.namespace,
|
||||
payload=artifact.canonical_payload,
|
||||
signature=artifact.signature,
|
||||
key_id=artifact.key_id,
|
||||
) is not True:
|
||||
return False
|
||||
if request is not None:
|
||||
expected = _request_dict(request)
|
||||
for field in (
|
||||
"policy_id", "repository", "authority_base_oid", "source_oid",
|
||||
"source_tree_oid", "target_agent", "target_branch", "generation",
|
||||
"execution_digest", "exact_files", "nonce", "risk",
|
||||
"production_impact", "renewal",
|
||||
):
|
||||
actual = tuple(payload[field]) if field == "exact_files" else payload[field]
|
||||
if actual != expected.get(field):
|
||||
return False
|
||||
if int(payload["expires_at"]) - int(payload["issued_at"]) != int(expected["ttl_seconds"]):
|
||||
return False
|
||||
return True
|
||||
except (SingleHostAuthorityError, TypeError, ValueError, KeyError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def require(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.verify(artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return dict(artifact.payload)
|
||||
|
||||
|
||||
def require_source_publication_epoch_scope(epoch: Any, policy_payload: Mapping[str, Any]) -> None:
|
||||
"""Require v2 epoch provenance to match the signed C5 policy exactly."""
|
||||
schema = getattr(epoch, "schema", None)
|
||||
if schema is None and isinstance(epoch, Mapping):
|
||||
schema = epoch.get("schema")
|
||||
if schema != "aios.owner-delegation-epoch.v2":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCHEMA_REQUIRED")
|
||||
|
||||
def field(name: str) -> Any:
|
||||
if isinstance(epoch, Mapping):
|
||||
return epoch.get(name)
|
||||
return getattr(epoch, name, None)
|
||||
|
||||
if (
|
||||
field("production_scope") != SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE
|
||||
or field("destructive_policy") != SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY
|
||||
or field("policy_digest") != policy_payload.get("policy_digest")
|
||||
or field("repository") != policy_payload.get("repository")
|
||||
or field("trust_domain") != policy_payload.get("trust_domain")
|
||||
):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCOPE_MISMATCH")
|
||||
|
||||
|
||||
class ScopedSourcePublicationPolicyAdmission:
|
||||
"""Rechecks a policy for every exact request before scoped admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SourcePublicationPolicyVerifier,
|
||||
*,
|
||||
bootstrap: Any | None = None,
|
||||
artifact: SignedArtifact | None = None,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
expected_agent_id: str | None = None,
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.bootstrap = bootstrap
|
||||
self.artifact = artifact
|
||||
self.request = request
|
||||
self.expected_agent_id = expected_agent_id
|
||||
|
||||
def _check_bootstrap_binding(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.bootstrap is None:
|
||||
return
|
||||
policy = getattr(self.bootstrap, "policy", None)
|
||||
digest_provider = getattr(policy, "normalized_policy_digest", None)
|
||||
digest = digest_provider() if callable(digest_provider) else None
|
||||
trust_domain = getattr(policy, "trust_domain", None)
|
||||
if not isinstance(digest, str) or digest != payload.get("policy_digest"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_POLICY_DIGEST_MISMATCH")
|
||||
if not isinstance(trust_domain, str) or trust_domain != payload.get("trust_domain"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_TRUST_DOMAIN_MISMATCH")
|
||||
if self.expected_agent_id is None or payload.get("target_agent") != self.expected_agent_id:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_TARGET_AGENT_MISMATCH")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self.verifier._validate_payload(artifact)
|
||||
if self.expected_agent_id is not None and payload.get("target_agent") != self.expected_agent_id:
|
||||
return False
|
||||
self._check_bootstrap_binding(payload)
|
||||
except SingleHostAuthorityError:
|
||||
return False
|
||||
return self.verifier.verify(artifact, request)
|
||||
|
||||
verify = authorize
|
||||
|
||||
def _request_for_raw(self, raw: Mapping[str, Any]) -> SourcePublicationPolicyRequest:
|
||||
if self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_ARTIFACT_REQUIRED")
|
||||
expected_task = getattr(self, "expected_task_id", None)
|
||||
if expected_task is not None and raw.get("task_id") != expected_task:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_PLAN_TASK_MISMATCH")
|
||||
payload = self.verifier._validate_payload(self.artifact)
|
||||
self._check_bootstrap_binding(payload)
|
||||
if self.request is not None:
|
||||
base = _request_dict(self.request)
|
||||
else:
|
||||
base = {
|
||||
"policy_id": payload["policy_id"], "repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"], "source_oid": payload["source_oid"],
|
||||
"source_tree_oid": payload["source_tree_oid"], "target_agent": payload["target_agent"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "exact_files": tuple(payload["exact_files"]),
|
||||
"nonce": payload["nonce"], "ttl_seconds": payload["expires_at"] - payload["issued_at"],
|
||||
"risk": payload["risk"], "production_impact": payload["production_impact"], "renewal": payload["renewal"],
|
||||
}
|
||||
checks = {
|
||||
"repository": base["repository"], "authority_base_oid": base["authority_base_oid"],
|
||||
"target_branch": base["target_branch"], "generation": base["generation"],
|
||||
"execution_digest": base["execution_digest"], "risk": base["risk"],
|
||||
}
|
||||
for field, expected in checks.items():
|
||||
if raw.get(field) != expected:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_REQUEST_SCOPE_MISMATCH")
|
||||
caps = tuple(raw.get("requested_capabilities", ()))
|
||||
if set(caps) != set(base["capabilities"] if "capabilities" in base else payload["capabilities"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_CAPABILITY_SCOPE_MISMATCH")
|
||||
if tuple(raw.get("file_allowlist", ())) != tuple(base["exact_files"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_FILE_SCOPE_MISMATCH")
|
||||
return SourcePublicationPolicyRequest(**base)
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
request = self._request_for_raw(raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return self.bootstrap.create_envelope(raw)
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if raw.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=str(raw.get("delegation_epoch_id") or ""),
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
return self.bootstrap.authorize_and_admit(raw)
|
||||
|
||||
def status(self, raw: Mapping[str, Any] | str) -> Any:
|
||||
"""Read status only after the same policy and epoch guard as admission."""
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if isinstance(raw, str):
|
||||
raw = {"envelope_id": raw}
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
self.verifier.require(self.artifact, request)
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
grant = self.bootstrap.store.get_grant_by_task_id(envelope.task_id)
|
||||
epoch_id = grant.parent_epoch_id if grant is not None else ""
|
||||
if not epoch_id or not hasattr(self.bootstrap, "_verify_epoch"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_STATUS_EPOCH_REQUIRED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=epoch_id,
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
if hasattr(self.bootstrap, "status"):
|
||||
return self.bootstrap.status(envelope.envelope_id)
|
||||
return envelope_raw
|
||||
|
||||
def reconcile_status(self, envelope_id: str, authorization: Mapping[str, Any]) -> Any:
|
||||
"""Carry the verified epoch identity when a Task effect has no receipt yet."""
|
||||
if authorization.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
return self.status({"envelope_id": envelope_id,
|
||||
"delegation_epoch_id": authorization.get("delegation_epoch_id")})
|
||||
|
||||
def project_task_admission_policy(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> TaskAdmissionPolicyProjection:
|
||||
payload = self.verifier.require(artifact, request)
|
||||
return TaskAdmissionPolicyProjection(
|
||||
max_risk="engineering",
|
||||
repository=payload["repository"],
|
||||
target_branch=payload["target_branch"],
|
||||
capabilities=tuple(payload["capabilities"]),
|
||||
exact_files=tuple(payload["exact_files"]),
|
||||
)
|
||||
|
||||
to_task_admission_policy = project_task_admission_policy
|
||||
|
||||
|
||||
# Short aliases for callers that prefer the lifecycle terminology.
|
||||
SourcePublicationPolicyAdmission = ScopedSourcePublicationPolicyAdmission
|
||||
SourcePublicationVerifier = SourcePublicationPolicyVerifier
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Thin unattended release runner.
|
||||
|
||||
This module is an adapter around the existing admission and silent-release
|
||||
services. It deliberately owns no release state machine and never creates
|
||||
approval evidence. The runner only binds immutable source facts, delegates
|
||||
authority, and validates durable readbacks before moving to the next phase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
RETRYABLE_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
RETRYABLE_NAMES = {
|
||||
"UNAVAILABLE", "BUSY", "TEMPORARY_FAILURE", "TASK_ADMISSION_IN_PROGRESS",
|
||||
}
|
||||
RISKS = {"low", "medium", "engineering", "high"}
|
||||
|
||||
|
||||
class UnattendedReleaseError(RuntimeError):
|
||||
"""A fail-closed runner error with a stable code."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canon(value)).hexdigest()
|
||||
|
||||
|
||||
def _require_oid(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourcePublicationIntent:
|
||||
"""Immutable, deterministic source and execution binding."""
|
||||
|
||||
repository: str
|
||||
source_commit: str
|
||||
source_tree_oid: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
requested_capabilities: tuple[str, ...]
|
||||
risk: str
|
||||
execution_digest: str
|
||||
generation: int = 1
|
||||
event_id: str = "release"
|
||||
version: str = ""
|
||||
admission_task_id: str = ""
|
||||
admission_idempotency_key: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or not isinstance(self.repository, str):
|
||||
raise UnattendedReleaseError("INVALID_REPOSITORY")
|
||||
for name in ("source_commit", "source_tree_oid", "authority_base_oid"):
|
||||
object.__setattr__(self, name, _require_oid(name, getattr(self, name)))
|
||||
if not isinstance(self.target_branch, str) or not self.target_branch or not self.target_branch.startswith("agent/") or self.target_branch.startswith("-"):
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if ".." in self.target_branch or "//" in self.target_branch or "@{" in self.target_branch:
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise UnattendedReleaseError("INVALID_GENERATION")
|
||||
object.__setattr__(self, "execution_digest", _require_sha("execution_digest", self.execution_digest))
|
||||
if not self.file_allowlist or any(not isinstance(p, str) or not p or p.startswith(("/", "\\")) or ".." in p or "\\" in p or p == ".git" or p.startswith(".git/") for p in self.file_allowlist):
|
||||
raise UnattendedReleaseError("INVALID_FILE_ALLOWLIST")
|
||||
if len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise UnattendedReleaseError("DUPLICATE_FILE_ALLOWLIST")
|
||||
if not self.requested_capabilities or any(not isinstance(c, str) or not c or "*" in c for c in self.requested_capabilities):
|
||||
raise UnattendedReleaseError("INVALID_CAPABILITIES")
|
||||
if not isinstance(self.risk, str) or self.risk.lower() not in RISKS:
|
||||
raise UnattendedReleaseError("INVALID_RISK")
|
||||
object.__setattr__(self, "risk", self.risk.lower())
|
||||
object.__setattr__(self, "file_allowlist", tuple(sorted(self.file_allowlist)))
|
||||
object.__setattr__(self, "requested_capabilities", tuple(sorted(set(self.requested_capabilities))))
|
||||
if not IDENT_RE.fullmatch(self.event_id):
|
||||
raise UnattendedReleaseError("INVALID_EVENT_ID")
|
||||
if self.version and not IDENT_RE.fullmatch(self.version):
|
||||
raise UnattendedReleaseError("INVALID_VERSION")
|
||||
if bool(self.admission_task_id) != bool(self.admission_idempotency_key):
|
||||
raise UnattendedReleaseError("INCOMPLETE_ADMISSION_IDENTITY")
|
||||
for value in (self.admission_task_id, self.admission_idempotency_key):
|
||||
if value and (not isinstance(value, str) or not IDENT_RE.fullmatch(value)):
|
||||
raise UnattendedReleaseError("INVALID_ADMISSION_IDENTITY")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = {
|
||||
"repository": self.repository, "source_commit": self.source_commit.lower(),
|
||||
"source_tree_oid": self.source_tree_oid.lower(), "authority_base_oid": self.authority_base_oid.lower(),
|
||||
"target_branch": self.target_branch, "file_allowlist": list(self.file_allowlist),
|
||||
"requested_capabilities": list(self.requested_capabilities), "risk": self.risk,
|
||||
"execution_digest": self.execution_digest.lower(), "generation": self.generation,
|
||||
"event_id": self.event_id, "version": self.version,
|
||||
}
|
||||
if self.admission_task_id:
|
||||
value.update(admission_task_id=self.admission_task_id,
|
||||
admission_idempotency_key=self.admission_idempotency_key)
|
||||
return value
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.admission_task_id or "release-task-" + _digest(self.as_dict())[:32]
|
||||
|
||||
@property
|
||||
def idempotency_key(self) -> str:
|
||||
return self.admission_idempotency_key or "release-intake-" + _digest({"task_id": self.task_id, "execution_digest": self.execution_digest.lower()})[:32]
|
||||
|
||||
def admission_request(self) -> dict[str, Any]:
|
||||
"""Return the sole normalized request accepted by Task admission."""
|
||||
return {"idempotency_key": self.idempotency_key, "task_id": self.task_id,
|
||||
"repository": self.repository, "authority_base_oid": self.authority_base_oid,
|
||||
"target_branch": self.target_branch, "generation": self.generation,
|
||||
"execution_digest": self.execution_digest, "requested_capabilities": list(self.requested_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist), "risk": self.risk}
|
||||
|
||||
|
||||
def _error_code(exc: BaseException) -> str:
|
||||
code = getattr(exc, "code", None) or getattr(exc, "status", None) or exc.__class__.__name__
|
||||
value = str(code).upper()
|
||||
return value if re.fullmatch(r"[A-Z0-9_]{1,100}", value) else "BACKEND_ERROR"
|
||||
|
||||
|
||||
def _bound_receipt(receipt: Any, intent: SourcePublicationIntent) -> bool:
|
||||
if not isinstance(receipt, Mapping):
|
||||
return False
|
||||
expected = {"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest}
|
||||
return (receipt.get("schema") == "aios.task-admission-receipt.v1"
|
||||
and all(_canon(receipt.get(k)) == _canon(v) for k, v in expected.items())
|
||||
and receipt.get("task_state") in {"QUEUED", "CLAIMED", "RUNNING"}
|
||||
and all(isinstance(receipt.get(k), str) and receipt[k] for k in ("envelope_id", "grant_id")))
|
||||
|
||||
|
||||
def _retryable(exc: BaseException) -> bool:
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if isinstance(status, int):
|
||||
return status in RETRYABLE_CODES
|
||||
code = _error_code(exc)
|
||||
return code in RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
return isinstance(exc, (TimeoutError, ConnectionError, OSError)) or _error_code(exc) in {"TASK_ADMISSION_TASK_STATE_UNKNOWN", "TIMEOUT", "TIMED_OUT", "CONNECTION_LOST"}
|
||||
|
||||
|
||||
class GovernedUnattendedReleaseRunner:
|
||||
"""Phase-separated runner; preparation has no release/deployment rights."""
|
||||
|
||||
def __init__(self, *, admission: Any, release_service: Any = None,
|
||||
publication_adapter: Any = None, owner_approval_broker: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None,
|
||||
deployment_enabled: bool = False, deployment_service: Any = None,
|
||||
max_attempts: int = 3, deadline_seconds: float = 30.0,
|
||||
environment_mode: str = "SIMULATION",
|
||||
clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep):
|
||||
if admission is None:
|
||||
raise ValueError("admission is required")
|
||||
if max_attempts < 1 or max_attempts > 10 or deadline_seconds <= 0:
|
||||
raise ValueError("invalid retry bounds")
|
||||
self.admission = admission
|
||||
self.environment_mode = str(environment_mode).upper()
|
||||
if self.environment_mode not in {"SIMULATION", "AUTHORITY"}:
|
||||
raise ValueError("environment_mode must be SIMULATION or AUTHORITY")
|
||||
self.release_service = release_service
|
||||
self.publication_adapter = publication_adapter
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.deployment_enabled = bool(deployment_enabled)
|
||||
self.deployment_service = deployment_service
|
||||
self.max_attempts = max_attempts
|
||||
self.deadline_seconds = deadline_seconds
|
||||
self.clock, self.sleep = clock, sleep
|
||||
|
||||
def intake(self, intent: SourcePublicationIntent) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise UnattendedReleaseError("INTENT_TYPE_REQUIRED")
|
||||
return {"phase": "PREPARATION", "mode": self.environment_mode, "task_id": intent.task_id,
|
||||
"idempotency_key": intent.idempotency_key, "intent": intent.as_dict(),
|
||||
"release_rights": False, "deployment_rights": False}
|
||||
|
||||
def admit(self, intent: SourcePublicationIntent, authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(authorization, Mapping):
|
||||
raise UnattendedReleaseError("AUTHORIZATION_REQUIRED")
|
||||
if self.environment_mode == "SIMULATION" and getattr(self.admission, "simulation_only", False) is not True:
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "task_id": intent.task_id, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
raw = intent.admission_request()
|
||||
deadline = self.clock() + self.deadline_seconds
|
||||
envelope_id = None
|
||||
for attempt in range(1, self.max_attempts + 1):
|
||||
if self.clock() >= deadline:
|
||||
return {"phase": "RECONCILIATION_REQUIRED" if envelope_id else "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": "ADMISSION_DEADLINE_EXCEEDED"}
|
||||
try:
|
||||
created = self.admission.create_envelope(raw)
|
||||
envelope = created.get("envelope", created) if isinstance(created, Mapping) else {}
|
||||
envelope_id = envelope.get("envelope_id") or envelope_id
|
||||
if not isinstance(envelope_id, str) or not envelope_id:
|
||||
raise UnattendedReleaseError("ENVELOPE_BINDING_MISSING")
|
||||
auth = dict(authorization)
|
||||
auth["envelope_id"] = envelope_id
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
receipt = self.admission.authorize_and_admit(auth)
|
||||
if not _bound_receipt(receipt, intent) or receipt["envelope_id"] != envelope_id:
|
||||
raise UnattendedReleaseError("ADMISSION_RECEIPT_BINDING_MISMATCH")
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
return {"phase": "ADMITTED", "mode": self.environment_mode, "attempt": attempt,
|
||||
"envelope": created, "receipt": receipt}
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if status_code == 403 or _error_code(exc) in {"403", "FORBIDDEN"}:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
# Once authorization may have run, every failure reconciles.
|
||||
# A 5xx is never sufficient proof of NOT_APPLIED.
|
||||
if envelope_id:
|
||||
return self._reconcile(envelope_id, intent, _error_code(exc), authorization)
|
||||
if not _retryable(exc) or self.clock() >= deadline or attempt >= self.max_attempts:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
self.sleep(min(0.05 * (2 ** (attempt - 1)), max(0.0, deadline - self.clock())))
|
||||
raise UnattendedReleaseError("ADMISSION_RETRY_EXHAUSTED")
|
||||
|
||||
def _reconcile(self, envelope_id: str | None, intent: SourcePublicationIntent, error: str,
|
||||
authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not envelope_id:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error}
|
||||
try:
|
||||
reconcile = getattr(self.admission, "reconcile_status", None)
|
||||
status = reconcile(envelope_id, authorization) if callable(reconcile) else self.admission.status(envelope_id)
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error, "status_error": _error_code(exc)}
|
||||
state = str(status.get("status") or status.get("task_state") or "").upper() if isinstance(status, Mapping) else ""
|
||||
if state in {"NOT_APPLIED", "ABSENT", "NOT_FOUND"}:
|
||||
return {"phase": "RETRYABLE_RECONCILIATION", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
if state == "ADMITTED" and _bound_receipt(status.get("task"), intent):
|
||||
return {"phase": "RECONCILED", "mode": self.environment_mode, "task_id": intent.task_id, "status": status, "receipt": status["task"]}
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if self.publication_adapter is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_BACKEND_MISSING"}
|
||||
required = ("task_lease", "task_principal", "writer_lease", "commit_receipt", "source_oid", "tree_oid", "authenticated")
|
||||
try:
|
||||
if not _bound_receipt(admission_receipt, intent):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "ADMISSION_RECEIPT_BINDING_MISMATCH"}
|
||||
result = self.publication_adapter.publish(intent, admission_receipt)
|
||||
if not isinstance(result, Mapping) or result.get("authenticated") is not True or any(not result.get(k) for k in required[:-1]):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_CHAIN_MISSING"}
|
||||
if any(not isinstance(result[k], Mapping) or result[k].get("task_id") != intent.task_id
|
||||
for k in ("task_lease", "task_principal", "writer_lease")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_BINDING_MISMATCH"}
|
||||
commit = result["commit_receipt"]
|
||||
# Existing continuation receipts carry remote OIDs, while the
|
||||
# authenticated task/job readback carries scope. Preserve both
|
||||
# originals; never fabricate missing fields inside a receipt.
|
||||
optional_bindings = {"task_id": intent.task_id, "tree_oid": result["tree_oid"],
|
||||
"execution_digest": intent.execution_digest,
|
||||
"target_branch": intent.target_branch,
|
||||
"base_oid": intent.authority_base_oid}
|
||||
if (not isinstance(commit, Mapping) or commit.get("commit_oid") != result["source_oid"]
|
||||
or any(key in commit and commit[key] != value for key, value in optional_bindings.items())
|
||||
or result.get("generation") != intent.generation):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_COMMIT_BINDING_MISMATCH"}
|
||||
if not OID_RE.fullmatch(str(result["source_oid"])) or str(result["tree_oid"]).lower() != intent.source_tree_oid.lower() or any(str(result.get(k, "")) != str(v) for k, v in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid), ("target_branch", intent.target_branch), ("execution_digest", intent.execution_digest))):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_RC_BINDING_MISMATCH"}
|
||||
return {"phase": "PUBLISHED", "mode": "AUTHORITY", "readback": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def postpublication_rc(self, *, event_id: str, source_commit: str, version: str = "", source_readback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if not isinstance(source_readback, Mapping) or source_readback.get("authenticated") is not True or str(source_readback.get("source_commit", "")).lower() != str(source_commit).lower():
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "SOURCE_READBACK_REQUIRED"}
|
||||
if self.release_service is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "RELEASE_BACKEND_MISSING"}
|
||||
try:
|
||||
# Only a host-injected authenticated resolver can admit a source.
|
||||
# The caller's readback object alone is never authorization.
|
||||
resolver = getattr(self.release_service, "source_admission_resolver", None)
|
||||
admitted_source = resolver(source_commit) if callable(resolver) else None
|
||||
if (not isinstance(admitted_source, Mapping) or not admitted_source.get("admission_id")
|
||||
or admitted_source.get("source_commit") != source_commit
|
||||
or admitted_source.get("tree_oid") != source_readback.get("tree_oid")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"}
|
||||
intent = self.release_service.create_release_intent({"event_id": event_id, "source_commit": source_commit, **({"version": version} if version else {})})
|
||||
candidate_id = str(intent.get("candidate_id") or intent.get("candidate", {}).get("candidate_id") or "")
|
||||
if not candidate_id:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "CANDIDATE_BINDING_MISSING"}
|
||||
readers = ("get_release_candidate", "get_release_plan", "get_acceptance")
|
||||
if any(not callable(getattr(self.release_service, name, None)) for name in readers):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BACKEND_MISSING"}
|
||||
candidate = self.release_service.get_release_candidate(candidate_id)
|
||||
plan = self.release_service.get_release_plan(candidate_id)
|
||||
acceptance = self.release_service.get_acceptance(candidate_id)
|
||||
deployment = plan.get("deployment") if isinstance(plan, Mapping) else None
|
||||
if (not isinstance(candidate, Mapping) or not isinstance(plan, Mapping) or not isinstance(deployment, Mapping) or not isinstance(acceptance, Mapping)
|
||||
or str(candidate.get("candidate_id")) != candidate_id
|
||||
or str(candidate.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(plan.get("candidate_id", candidate_id)) != candidate_id
|
||||
or str(deployment.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(deployment.get("tree_oid", "")).lower() != str(source_readback.get("tree_oid", "")).lower()
|
||||
or candidate.get("tree_oid") != deployment.get("tree_oid")
|
||||
or candidate.get("artifact_sha256") != deployment.get("artifact_sha256")
|
||||
or candidate.get("manifest_sha256") != deployment.get("manifest_sha256")
|
||||
or str(acceptance.get("candidate_id")) != candidate_id
|
||||
or acceptance.get("release_plan_sha256") != plan.get("sha256")
|
||||
or str(acceptance.get("status")) != "ACCEPTED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BINDING_MISMATCH"}
|
||||
# Public readback is redacted. Reverify original signed bytes inside
|
||||
# the existing service, not by reconstructing them from API output.
|
||||
verify_acceptance = getattr(self.release_service, "verify_release_acceptance", None)
|
||||
if not callable(verify_acceptance):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_REQUIRED"}
|
||||
verified = verify_acceptance(candidate_id)
|
||||
if (not isinstance(verified, Mapping) or verified.get("status") != "VERIFIED"
|
||||
or verified.get("candidate_id") != candidate_id
|
||||
or verified.get("release_plan_sha256") != plan.get("sha256")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_NOT_VERIFIED"}
|
||||
envelope = self.release_service.get_approval_envelope(candidate_id)
|
||||
if self.owner_approval_broker is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_BACKEND_MISSING"}
|
||||
approval = self.owner_approval_broker(dict(envelope))
|
||||
if not isinstance(approval, Mapping):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_MISSING"}
|
||||
accepted = self.release_service.submit_owner_approval(dict(approval))
|
||||
if not isinstance(accepted, Mapping) or accepted.get("status") != "VERIFIED_AND_STORED":
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "APPROVAL_NOT_VERIFIED"}
|
||||
return {"phase": "APPROVED", "mode": "AUTHORITY", "candidate_id": candidate_id,
|
||||
"readback": {"candidate": dict(candidate), "plan": dict(plan), "acceptance": dict(acceptance)},
|
||||
"approval": accepted}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def deploy(self, candidate_id: str) -> dict[str, Any]:
|
||||
if (self.environment_mode != "AUTHORITY" or not self.deployment_enabled or self.deployment_service is None
|
||||
or getattr(self.deployment_service, "execution_enabled", False) is not True):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_ENABLED"}
|
||||
try:
|
||||
result = self.deployment_service.start_deployment(candidate_id)
|
||||
saga = self.deployment_service.get_release_saga(candidate_id)
|
||||
if (not isinstance(result, Mapping) or result.get("candidate_id") != candidate_id
|
||||
or result.get("state") != "COMPLETED" or result.get("status") not in {"VERIFIED", "IDEMPOTENT"}
|
||||
or not isinstance(saga, Mapping) or saga.get("state") != "COMPLETED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_VERIFIED", "result": dict(result) if isinstance(result, Mapping) else {}}
|
||||
return {"phase": "DEPLOYED", "mode": "AUTHORITY", "result": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Local adapter for the root-owned registered Owner signer service.
|
||||
|
||||
The adapter intentionally has no public arbitrary-payload signing method.
|
||||
``LocalOwnerAuthority`` calls the private typed seam after validating and
|
||||
canonicalizing a structured Owner authorization object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_SIGNATURE_BYTES = 128 * 1024
|
||||
MAX_KEY_ID_BYTES = 512
|
||||
|
||||
|
||||
class RegisteredOwnerSignerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RegisteredOwnerSignerAdapter:
|
||||
"""Invoke the registered signer without reading or copying private keys."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: Path | str = DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
*,
|
||||
key_id: str,
|
||||
owner_verifier: Any,
|
||||
require_root: bool = True,
|
||||
test_mode: bool = False,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
self.command = str(command)
|
||||
self.key_id = key_id
|
||||
self.owner_verifier = owner_verifier
|
||||
self.require_root = require_root
|
||||
self.test_mode = test_mode
|
||||
self.runner = runner
|
||||
if not isinstance(key_id, str) or not key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_REQUIRED")
|
||||
if not test_mode:
|
||||
self._validate_command()
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.key_id
|
||||
|
||||
def _validate_command(self) -> None:
|
||||
path = Path(self.command)
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_INVALID")
|
||||
st = path.stat()
|
||||
if not hasattr(os, "geteuid") or st.st_uid != os.geteuid():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_PERMISSIONS_INVALID")
|
||||
|
||||
def _read_output(self, path: Path, maximum: int) -> bytes:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_INVALID")
|
||||
st = path.stat()
|
||||
if not self.test_mode and (not hasattr(os, "geteuid") or st.st_uid != os.geteuid()):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_OWNER_INVALID")
|
||||
if st.st_size > maximum:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_TOO_LARGE")
|
||||
return path.read_bytes()
|
||||
|
||||
def _sign_canonical_payload(self, namespace: str, payload: bytes) -> str:
|
||||
"""Private seam used only by typed ``LocalOwnerAuthority`` issuance."""
|
||||
if namespace != OWNER_NAMESPACE:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_NAMESPACE_INVALID")
|
||||
if not isinstance(payload, bytes) or not payload:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_PAYLOAD_INVALID")
|
||||
if self.require_root:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_ROOT_REQUIRED")
|
||||
if not self.test_mode:
|
||||
self._validate_command()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-owner-sign-") as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "key-id"
|
||||
input_path.write_bytes(payload)
|
||||
try:
|
||||
result = self.runner(
|
||||
[
|
||||
self.command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED") from exc
|
||||
if getattr(result, "returncode", None) != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED")
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_MISSING")
|
||||
supplied_key_id = self._read_output(key_id_path, MAX_KEY_ID_BYTES).decode("utf-8").strip()
|
||||
if supplied_key_id != self.key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_MISMATCH")
|
||||
signature = self._read_output(signature_path, MAX_SIGNATURE_BYTES).decode("utf-8")
|
||||
if not signature.startswith("-----BEGIN SSH SIGNATURE-----"):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = self.owner_verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_FAILED") from exc
|
||||
if verified is not True:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_REJECTED")
|
||||
return signature
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,7 +553,7 @@ class ReleaseSourceAdmissionStore:
|
||||
supplied_candidate = str(candidate_id or "")
|
||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
||||
return {
|
||||
result = {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
@@ -562,6 +562,9 @@ class ReleaseSourceAdmissionStore:
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
if version == "V2R2C5":
|
||||
result["tree_oid"] = record["tree_oid"]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-injected broker for short release approvals under standing authority.
|
||||
|
||||
The broker deliberately owns no business-authority ledger. Its readers must
|
||||
return verified current snapshots from the host's durable authority and
|
||||
release-lineage stores. It only derives, verifies, and persists short-lived
|
||||
``SignedReleasePlanApproval`` children for the exact local ``ReleasePlan``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from silent_release_controller import CANDIDATE_ID_RE, OID_RE, SHA256_RE, _parse_utc
|
||||
from silent_release_hardening import (
|
||||
ALLOWED_CAPABILITIES,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
_canonical_json,
|
||||
release_file_lock,
|
||||
)
|
||||
from task_plane.governance_control_kernel import (
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
ExactScopeState,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
|
||||
|
||||
_EFFECT_STATES = frozenset(
|
||||
{"KNOWN_NOT_APPLIED", "KNOWN_IN_PROGRESS", "APPLIED_VERIFIED", "UNKNOWN"}
|
||||
)
|
||||
_LINEAGE_KINDS = frozenset({"ROOT", "SUCCESSOR"})
|
||||
_BINDING_KEYS = frozenset(
|
||||
{
|
||||
"authorization_id",
|
||||
"generation",
|
||||
"authorization_sha256",
|
||||
"version",
|
||||
"repository",
|
||||
"production_scope",
|
||||
"authority_base_oid",
|
||||
"trust_root_sha256",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StandingAuthorityError(CapabilityAuthorizationError):
|
||||
"""A current standing-authority or exact-scope check failed closed."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _text(name: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
return value
|
||||
|
||||
|
||||
def _sha(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a SHA-256 digest")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a Git object id")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(name: str, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple) or not value:
|
||||
raise ValueError(f"{name} must be a non-empty tuple")
|
||||
normalized = tuple(_text(name, item) for item in value)
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError(f"{name} contains duplicates")
|
||||
return normalized
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError("clock must return a timezone-aware datetime")
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _format_utc(value: datetime) -> str:
|
||||
return _utc(value).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalPlanScope:
|
||||
version: str
|
||||
repository: str
|
||||
service: str
|
||||
release_root: str
|
||||
cutover_link: str
|
||||
authoritative_git_dir: str
|
||||
dropin_relative_path: str
|
||||
rollback_target: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"version",
|
||||
"repository",
|
||||
"service",
|
||||
"release_root",
|
||||
"cutover_link",
|
||||
"authoritative_git_dir",
|
||||
"dropin_relative_path",
|
||||
"rollback_target",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
capabilities = _tuple_of_text(
|
||||
"required_capabilities", self.required_capabilities
|
||||
)
|
||||
if tuple(sorted(capabilities)) != capabilities:
|
||||
raise ValueError("required_capabilities must be sorted")
|
||||
object.__setattr__(self, "required_capabilities", capabilities)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"cutover_link": self.cutover_link,
|
||||
"authoritative_git_dir": self.authoritative_git_dir,
|
||||
"dropin_relative_path": self.dropin_relative_path,
|
||||
"release_root": self.release_root,
|
||||
"repository": self.repository,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"rollback_target": self.rollback_target,
|
||||
"service": self.service,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
|
||||
def canonical_plan_scope(
|
||||
plan: ReleasePlan, required_capabilities: tuple[str, ...]
|
||||
) -> CanonicalPlanScope:
|
||||
"""Derive production scope only from a validated local ReleasePlan."""
|
||||
|
||||
plan.validate()
|
||||
release_path = PurePosixPath(plan.deployment.release_path)
|
||||
dropin_path = PurePosixPath(plan.deployment.dropin_path)
|
||||
try:
|
||||
dropin_relative = dropin_path.relative_to(release_path).as_posix()
|
||||
except ValueError as exc:
|
||||
raise ValueError("drop-in path is outside the candidate release path") from exc
|
||||
if dropin_relative in {"", "."}:
|
||||
raise ValueError("drop-in path must be a child of the release path")
|
||||
return CanonicalPlanScope(
|
||||
version=plan.tag.version,
|
||||
repository=plan.deployment.repository_identity,
|
||||
service=plan.deployment.service,
|
||||
release_root=plan.deployment.release_root,
|
||||
cutover_link=plan.deployment.cutover_link,
|
||||
authoritative_git_dir=plan.deployment.authoritative_git_dir,
|
||||
dropin_relative_path=dropin_relative,
|
||||
rollback_target=plan.deployment.rollback_path,
|
||||
required_capabilities=tuple(sorted(required_capabilities)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StandingReleaseAuthorization:
|
||||
authorization_id: str
|
||||
generation: int
|
||||
authorization_sha256: str
|
||||
version: str
|
||||
repository: str
|
||||
production_scope: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
trust_domain: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
rollback_target: str
|
||||
owner_identity: str
|
||||
allowed_actor_ids: tuple[str, ...]
|
||||
mission_id: str
|
||||
run_id: str
|
||||
lifecycle_state: AuthorityLifecycleState
|
||||
acceptance_complete: bool
|
||||
acceptance_evidence_ref: str | None = None
|
||||
expires_at: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"authorization_id",
|
||||
"version",
|
||||
"repository",
|
||||
"trust_domain",
|
||||
"rollback_target",
|
||||
"owner_identity",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if (
|
||||
not isinstance(self.generation, int)
|
||||
or isinstance(self.generation, bool)
|
||||
or self.generation < 1
|
||||
):
|
||||
raise ValueError("generation must be a positive integer")
|
||||
_sha("authorization_sha256", self.authorization_sha256)
|
||||
_sha("production_scope", self.production_scope)
|
||||
_oid("authority_base_oid", self.authority_base_oid)
|
||||
_sha("trust_root_sha256", self.trust_root_sha256)
|
||||
_sha(
|
||||
"lineage_root_candidate_fingerprint",
|
||||
self.lineage_root_candidate_fingerprint,
|
||||
)
|
||||
capabilities = _tuple_of_text(
|
||||
"allowed_capabilities", self.allowed_capabilities
|
||||
)
|
||||
if not set(capabilities).issubset(ALLOWED_CAPABILITIES):
|
||||
raise ValueError("allowed_capabilities contains an unsupported capability")
|
||||
object.__setattr__(self, "allowed_capabilities", capabilities)
|
||||
object.__setattr__(
|
||||
self, "allowed_actor_ids", _tuple_of_text("allowed_actor_ids", self.allowed_actor_ids)
|
||||
)
|
||||
if not isinstance(self.lifecycle_state, AuthorityLifecycleState):
|
||||
raise ValueError("lifecycle_state must be AuthorityLifecycleState")
|
||||
if type(self.acceptance_complete) is not bool:
|
||||
raise ValueError("acceptance_complete must be a boolean")
|
||||
if self.acceptance_complete:
|
||||
_text("acceptance_evidence_ref", self.acceptance_evidence_ref or "")
|
||||
elif self.acceptance_evidence_ref is not None:
|
||||
raise ValueError("acceptance evidence is only valid for completed acceptance")
|
||||
if self.expires_at is not None:
|
||||
_parse_utc(self.expires_at)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseExecutionScope:
|
||||
release_plan_id: str
|
||||
candidate_id: str
|
||||
candidate_fingerprint: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
deployment_plan_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_plan_id: str
|
||||
evidence_closure_oid: str
|
||||
owner_approval_record_oid: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
actor_id: str
|
||||
mission_id: str
|
||||
run_id: str
|
||||
effect_state: str
|
||||
lineage_kind: str
|
||||
predecessor_candidate_fingerprint: str | None = None
|
||||
successor_lineage_sha256: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"release_plan_id",
|
||||
"candidate_id",
|
||||
"approval_plan_id",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if not CANDIDATE_ID_RE.fullmatch(self.candidate_id):
|
||||
raise ValueError("candidate_id is invalid")
|
||||
for name in ("source_commit", "tree_oid", "evidence_closure_oid", "owner_approval_record_oid", "authority_base_oid"):
|
||||
_oid(name, getattr(self, name))
|
||||
for name in (
|
||||
"candidate_fingerprint",
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"deployment_plan_sha256",
|
||||
"release_plan_sha256",
|
||||
"trust_root_sha256",
|
||||
"lineage_root_candidate_fingerprint",
|
||||
):
|
||||
_sha(name, getattr(self, name))
|
||||
if self.effect_state not in _EFFECT_STATES:
|
||||
raise ValueError("effect_state is invalid")
|
||||
if self.lineage_kind not in _LINEAGE_KINDS:
|
||||
raise ValueError("lineage_kind is invalid")
|
||||
if self.lineage_kind == "ROOT":
|
||||
if self.predecessor_candidate_fingerprint is not None or self.successor_lineage_sha256 is not None:
|
||||
raise ValueError("root candidate cannot carry successor lineage")
|
||||
else:
|
||||
_sha("predecessor_candidate_fingerprint", self.predecessor_candidate_fingerprint or "")
|
||||
_sha("successor_lineage_sha256", self.successor_lineage_sha256 or "")
|
||||
if self.predecessor_candidate_fingerprint == self.candidate_fingerprint:
|
||||
raise ValueError("successor predecessor must be a different candidate")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
class RegisteredApprovalSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str: ...
|
||||
|
||||
def sign(
|
||||
self, unsigned: SignedReleasePlanApproval
|
||||
) -> SignedReleasePlanApproval | Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class StandingReleaseAuthorizationBroker:
|
||||
"""Issue and renew exact short approvals from two trusted read layers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: SilentReleaseActionService,
|
||||
authority_reader: Callable[[], StandingReleaseAuthorization],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], ReleaseExecutionScope],
|
||||
signer: RegisteredApprovalSigner,
|
||||
*,
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
):
|
||||
if not callable(authority_reader) or not callable(execution_scope_reader):
|
||||
raise TypeError("standing authority readers must be callable")
|
||||
if not callable(getattr(signer, "sign", None)):
|
||||
raise TypeError("registered approval signer must provide sign()")
|
||||
if not callable(clock):
|
||||
raise TypeError("clock must be callable")
|
||||
if not isinstance(service, SilentReleaseActionService):
|
||||
raise TypeError("service must be SilentReleaseActionService")
|
||||
if service.execution_enabled and getattr(signer, "test_only", False) is True:
|
||||
raise CapabilityAuthorizationError(
|
||||
"test-only approval signer is forbidden for production execution"
|
||||
)
|
||||
if not isinstance(execution_ttl_seconds, int) or isinstance(execution_ttl_seconds, bool) or not 1 <= execution_ttl_seconds <= 3600:
|
||||
raise ValueError("execution_ttl_seconds must be between 1 and 3600")
|
||||
if not isinstance(renewal_margin_seconds, int) or isinstance(renewal_margin_seconds, bool) or not 0 <= renewal_margin_seconds < execution_ttl_seconds:
|
||||
raise ValueError("renewal_margin_seconds must be shorter than the execution TTL")
|
||||
self.service = service
|
||||
self.authority_reader = authority_reader
|
||||
self.execution_scope_reader = execution_scope_reader
|
||||
self.signer = signer
|
||||
self.clock = clock
|
||||
self.execution_ttl_seconds = execution_ttl_seconds
|
||||
self.renewal_margin_seconds = renewal_margin_seconds
|
||||
self._lock_root = Path(service.approvals_root) / ".standing-authority-locks"
|
||||
|
||||
@staticmethod
|
||||
def _result(status: str, context: AuthorityDecisionContext, approval=None):
|
||||
# silent_release_execution imports this module during host composition.
|
||||
from silent_release_execution import StandingAuthorizationResult
|
||||
|
||||
return StandingAuthorizationResult(
|
||||
status=status, authority_context=context, approval=approval
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _context(
|
||||
authorization: StandingReleaseAuthorization | None,
|
||||
scope_state: ExactScopeState,
|
||||
*,
|
||||
lifecycle_state: AuthorityLifecycleState | None = None,
|
||||
boundary: OwnerBoundaryReason | None = None,
|
||||
) -> AuthorityDecisionContext:
|
||||
refs = () if authorization is None else authorization.evidence_refs
|
||||
return AuthorityDecisionContext(
|
||||
lifecycle_state=lifecycle_state
|
||||
or (authorization.lifecycle_state if authorization else AuthorityLifecycleState.UNKNOWN),
|
||||
scope_state=scope_state,
|
||||
owner_boundary_reason=boundary,
|
||||
evidence_refs=refs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _binding(
|
||||
authorization: StandingReleaseAuthorization, scope: ReleaseExecutionScope
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"generation": authorization.generation,
|
||||
"authorization_sha256": authorization.authorization_sha256,
|
||||
"version": authorization.version,
|
||||
"repository": authorization.repository,
|
||||
"production_scope": authorization.production_scope,
|
||||
"authority_base_oid": authorization.authority_base_oid,
|
||||
"trust_root_sha256": authorization.trust_root_sha256,
|
||||
"actor_id": scope.actor_id,
|
||||
"mission_id": authorization.mission_id,
|
||||
"run_id": authorization.run_id,
|
||||
}
|
||||
|
||||
def _read(self, envelope: Mapping[str, Any]):
|
||||
authorization = self.authority_reader()
|
||||
# The trusted scope reader receives only the locally checked release
|
||||
# identity. Caller-added envelope keys cannot become authority input.
|
||||
query_keys = (
|
||||
"approval_plan_id",
|
||||
"artifact_sha256",
|
||||
"candidate_fingerprint",
|
||||
"candidate_id",
|
||||
"deployment_plan_sha256",
|
||||
"evidence_closure_oid",
|
||||
"manifest_sha256",
|
||||
"policy_sha256",
|
||||
"release_plan_sha256",
|
||||
"required_capabilities",
|
||||
"schema",
|
||||
"source_commit",
|
||||
"tree_oid",
|
||||
)
|
||||
scope = self.execution_scope_reader(
|
||||
{name: envelope[name] for name in query_keys if name in envelope}
|
||||
)
|
||||
if not isinstance(authorization, StandingReleaseAuthorization):
|
||||
raise StandingAuthorityError("AUTHORITY_SNAPSHOT_INVALID", "authority reader returned an invalid snapshot")
|
||||
if not isinstance(scope, ReleaseExecutionScope):
|
||||
raise StandingAuthorityError("RELEASE_SCOPE_INVALID", "scope reader returned an invalid snapshot")
|
||||
return authorization, scope
|
||||
|
||||
def _load_plan_truth(self, envelope: Mapping[str, Any]):
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
if not CANDIDATE_ID_RE.fullmatch(candidate_id):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "candidate id is invalid")
|
||||
candidate = self.service.candidates.load(candidate_id)
|
||||
plan = self.service.release_plans.load(candidate_id)
|
||||
required = tuple(sorted(str(item) for item in envelope.get("required_capabilities", ())))
|
||||
if not required or len(required) != len(set(required)) or not set(required).issubset(ALLOWED_CAPABILITIES):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "required capabilities are invalid")
|
||||
plan_scope = canonical_plan_scope(plan, required)
|
||||
expected_envelope = {
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"candidate_fingerprint": candidate.fingerprint,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_plan_id": plan.tag.approval_plan_id,
|
||||
"evidence_closure_oid": plan.tag.evidence_closure_oid,
|
||||
"policy_sha256": self.service.policy_sha256,
|
||||
}
|
||||
for name, expected in expected_envelope.items():
|
||||
if envelope.get(name) != expected:
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_STALE", f"approval envelope {name} is stale")
|
||||
return candidate, plan, plan_scope, required
|
||||
|
||||
def _assert_current(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
envelope: Mapping[str, Any],
|
||||
plan_scope: CanonicalPlanScope,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if authorization.acceptance_complete:
|
||||
raise StandingAuthorityError("AUTHORITY_ACCEPTED_AND_CLOSED", "trusted release acceptance is complete")
|
||||
if authorization.lifecycle_state is not AuthorityLifecycleState.ACTIVE:
|
||||
raise StandingAuthorityError(f"AUTHORITY_{authorization.lifecycle_state.value}", "standing authorization is not active")
|
||||
if authorization.expires_at is not None and _parse_utc(authorization.expires_at) <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization has expired")
|
||||
if authorization.version != plan_scope.version or authorization.repository != plan_scope.repository:
|
||||
raise StandingAuthorityError("AUTHORITY_RELEASE_SCOPE_MISMATCH", "release identity is outside standing authority")
|
||||
if authorization.production_scope != plan_scope.sha256 or authorization.rollback_target != plan_scope.rollback_target:
|
||||
raise StandingAuthorityError("AUTHORITY_PRODUCTION_SCOPE_MISMATCH", "production or rollback scope drifted")
|
||||
if not set(required).issubset(authorization.allowed_capabilities):
|
||||
raise StandingAuthorityError("AUTHORITY_CAPABILITY_DENIED", "required capability is outside standing authority")
|
||||
exact_scope = (
|
||||
scope.candidate_id == envelope["candidate_id"]
|
||||
and scope.candidate_fingerprint == envelope["candidate_fingerprint"]
|
||||
and scope.source_commit == envelope["source_commit"]
|
||||
and scope.tree_oid == envelope["tree_oid"]
|
||||
and scope.artifact_sha256 == envelope["artifact_sha256"]
|
||||
and scope.manifest_sha256 == envelope["manifest_sha256"]
|
||||
and scope.deployment_plan_sha256 == envelope["deployment_plan_sha256"]
|
||||
and scope.release_plan_sha256 == envelope["release_plan_sha256"]
|
||||
and scope.approval_plan_id == envelope["approval_plan_id"]
|
||||
and scope.evidence_closure_oid == envelope["evidence_closure_oid"]
|
||||
and scope.authority_base_oid == authorization.authority_base_oid
|
||||
and scope.trust_root_sha256 == authorization.trust_root_sha256
|
||||
and scope.actor_id in authorization.allowed_actor_ids
|
||||
and scope.mission_id == authorization.mission_id
|
||||
and scope.run_id == authorization.run_id
|
||||
)
|
||||
if not exact_scope:
|
||||
raise StandingAuthorityError("AUTHORITY_EXACT_SCOPE_MISMATCH", "trusted release execution scope does not match")
|
||||
durable_release_plan_id = self.service.sagas.load(
|
||||
scope.candidate_id
|
||||
).release_plan_id
|
||||
if scope.release_plan_id != durable_release_plan_id:
|
||||
raise StandingAuthorityError(
|
||||
"RELEASE_PLAN_ID_MISMATCH",
|
||||
"trusted scope does not match the durable Saga release plan",
|
||||
)
|
||||
if scope.lineage_root_candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_ROOT_MISMATCH", "candidate lineage is not rooted in standing authority")
|
||||
if scope.lineage_kind == "ROOT" and scope.candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_REQUIRED", "changed candidate requires verified successor lineage")
|
||||
if scope.effect_state == "UNKNOWN":
|
||||
raise StandingAuthorityError("RELEASE_EFFECT_UNKNOWN", "release effect must be reconciled before issuance")
|
||||
|
||||
def _unsigned(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
plan: ReleasePlan,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> SignedReleasePlanApproval:
|
||||
expires = now + timedelta(seconds=self.execution_ttl_seconds)
|
||||
if authorization.expires_at is not None:
|
||||
expires = min(expires, _parse_utc(authorization.expires_at))
|
||||
if expires <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization cannot cover a new child")
|
||||
issued_at = _format_utc(now)
|
||||
return SignedReleasePlanApproval(
|
||||
release_plan_id=scope.release_plan_id,
|
||||
candidate_id=scope.candidate_id,
|
||||
source_commit=scope.source_commit,
|
||||
tree_oid=scope.tree_oid,
|
||||
artifact_sha256=scope.artifact_sha256,
|
||||
manifest_sha256=scope.manifest_sha256,
|
||||
deployment_plan_sha256=plan.deployment.sha256,
|
||||
policy_sha256=self.service.policy_sha256,
|
||||
approval_plan_id=scope.approval_plan_id,
|
||||
evidence_closure_oid=scope.evidence_closure_oid,
|
||||
owner_approval_record_oid=scope.owner_approval_record_oid,
|
||||
capabilities=required,
|
||||
expires_at=_format_utc(expires),
|
||||
nonce="standing-" + secrets.token_hex(16),
|
||||
key_id=_text("signer.key_id", self.signer.key_id),
|
||||
signature="",
|
||||
owner_identity=authorization.owner_identity,
|
||||
trust_domain=authorization.trust_domain,
|
||||
issued_at=issued_at,
|
||||
not_before=issued_at,
|
||||
release_plan_sha256=plan.sha256,
|
||||
authorization_binding=self._binding(authorization, scope),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _signed(value: SignedReleasePlanApproval | Mapping[str, Any]) -> SignedReleasePlanApproval:
|
||||
return value if isinstance(value, SignedReleasePlanApproval) else SignedReleasePlanApproval.from_dict(value)
|
||||
|
||||
def _verify_signed(self, approval, candidate, plan, now):
|
||||
verifier = self.service.approval_verifier
|
||||
if verifier is None:
|
||||
raise StandingAuthorityError("APPROVAL_VERIFIER_MISSING", "approval verifier is not configured")
|
||||
verifier.verify_release_plan(approval, candidate, plan, self.service.policy_sha256, now=now)
|
||||
|
||||
def __call__(self, envelope: Mapping[str, Any]):
|
||||
if not isinstance(envelope, Mapping):
|
||||
raise TypeError("approval envelope must be a mapping")
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
lock_name = hashlib.sha256(candidate_id.encode("utf-8")).hexdigest() + ".lock"
|
||||
lock_path = self._lock_root / lock_name
|
||||
with release_file_lock(lock_path, blocking=True):
|
||||
try:
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
# Expiry is the one expected reason an otherwise valid
|
||||
# short child needs replacement. Verify its signature,
|
||||
# immutable evidence, key and revocation state at the end
|
||||
# of its signed window; a future/not-yet-valid or malformed
|
||||
# child still fails the verifier.
|
||||
# Expiry is exclusive. When renewing an already expired
|
||||
# child, verify its signature at the final instant inside
|
||||
# its original signed window rather than at expiry itself.
|
||||
old_expiry = _parse_utc(existing.expires_at)
|
||||
old_verification_time = min(
|
||||
now, old_expiry - timedelta(microseconds=1)
|
||||
)
|
||||
self._verify_signed(existing, candidate, plan, old_verification_time)
|
||||
self._check_binding(existing, authorization, scope, required)
|
||||
if _parse_utc(existing.expires_at) - now > timedelta(seconds=self.renewal_margin_seconds):
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), existing.to_dict())
|
||||
unsigned = self._unsigned(authorization, scope, plan, required, now)
|
||||
signed = self._signed(self.signer.sign(unsigned))
|
||||
# A signer may only add its signature to the complete request.
|
||||
if signed.canonical_payload != unsigned.canonical_payload:
|
||||
raise StandingAuthorityError("SIGNER_MUTATED_APPROVAL", "registered signer changed the approval request")
|
||||
self._verify_signed(signed, candidate, plan, now)
|
||||
after_authorization, after_scope = self._read(envelope)
|
||||
after_now = _utc(self.clock())
|
||||
self._assert_current(after_authorization, after_scope, envelope, plan_scope, required, after_now)
|
||||
if after_authorization != authorization or after_scope != scope:
|
||||
raise StandingAuthorityError("AUTHORITY_CHANGED_DURING_SIGN", "authority changed while the child was signed")
|
||||
self._verify_signed(signed, candidate, plan, after_now)
|
||||
if existing is None:
|
||||
self.service.submit_owner_approval(signed.to_dict())
|
||||
else:
|
||||
self.service.renew_owner_approval(candidate_id, signed.to_dict(), expected_fingerprint=existing.fingerprint)
|
||||
persisted = self.service.approvals.load(candidate_id)
|
||||
if persisted.fingerprint != signed.fingerprint:
|
||||
raise StandingAuthorityError("APPROVAL_PERSISTENCE_MISMATCH", "persisted approval differs from signed approval")
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), persisted.to_dict())
|
||||
except StandingAuthorityError as exc:
|
||||
authorization = locals().get("authorization")
|
||||
closed_states = {
|
||||
"AUTHORITY_ACCEPTED_AND_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_EXPIRED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_REVOKED": AuthorityLifecycleState.REVOKED,
|
||||
"AUTHORITY_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_CANCELLED": AuthorityLifecycleState.CANCELLED,
|
||||
}
|
||||
if exc.code in closed_states:
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.IN_SCOPE,
|
||||
lifecycle_state=closed_states[exc.code],
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
if exc.code in {
|
||||
"AUTHORITY_UNKNOWN",
|
||||
"AUTHORITY_SNAPSHOT_INVALID",
|
||||
"AUTHORITY_CHANGED_DURING_SIGN",
|
||||
}:
|
||||
return self._result(
|
||||
"WAITING_AUTHORITY",
|
||||
self._context(
|
||||
authorization,
|
||||
ExactScopeState.UNKNOWN,
|
||||
lifecycle_state=AuthorityLifecycleState.UNKNOWN,
|
||||
),
|
||||
)
|
||||
if exc.code.startswith("AUTHORITY_") and exc.code not in {"AUTHORITY_SNAPSHOT_INVALID", "AUTHORITY_CHANGED_DURING_SIGN"}:
|
||||
goal_change = {
|
||||
"AUTHORITY_RELEASE_SCOPE_MISMATCH",
|
||||
"AUTHORITY_PRODUCTION_SCOPE_MISMATCH",
|
||||
"AUTHORITY_CAPABILITY_DENIED",
|
||||
"AUTHORITY_LINEAGE_REQUIRED",
|
||||
}
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.OUT_OF_SCOPE,
|
||||
boundary=(
|
||||
OwnerBoundaryReason.GOAL_CHANGE
|
||||
if exc.code in goal_change
|
||||
else OwnerBoundaryReason.TRUST_BOUNDARY_CHANGE
|
||||
),
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
context = self._context(authorization, ExactScopeState.UNKNOWN)
|
||||
return self._result("WAITING_AUTHORITY", context)
|
||||
except Exception:
|
||||
return self._result("WAITING_AUTHORITY", self._context(None, ExactScopeState.UNKNOWN))
|
||||
|
||||
def _check_binding(self, approval, authorization, scope, required=None):
|
||||
binding = getattr(approval, "authorization_binding", None)
|
||||
if not isinstance(binding, Mapping) or set(binding) != _BINDING_KEYS:
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_INVALID", "approval lacks an exact standing authorization binding")
|
||||
if dict(binding) != self._binding(authorization, scope):
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_STALE", "approval standing authorization binding is stale")
|
||||
if approval.owner_identity != authorization.owner_identity or approval.trust_domain != authorization.trust_domain:
|
||||
raise StandingAuthorityError("AUTHORIZATION_IDENTITY_MISMATCH", "approval owner or trust binding is stale")
|
||||
if (
|
||||
approval.release_plan_id != scope.release_plan_id
|
||||
or approval.evidence_closure_oid != scope.evidence_closure_oid
|
||||
or approval.owner_approval_record_oid != scope.owner_approval_record_oid
|
||||
):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_OPERATION_BINDING_MISMATCH",
|
||||
"approval operation identity is stale",
|
||||
)
|
||||
if required is not None and tuple(sorted(approval.capabilities)) != tuple(required):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_CAPABILITY_BINDING_MISMATCH",
|
||||
"approval capability set does not match the exact plan scope",
|
||||
)
|
||||
|
||||
def check_current(self, approval: SignedReleasePlanApproval, capability: str, operation: str) -> None:
|
||||
if not isinstance(approval, SignedReleasePlanApproval):
|
||||
raise StandingAuthorityError("APPROVAL_INVALID", "current authority guard requires a signed approval")
|
||||
_text("operation", operation)
|
||||
if capability not in approval.capabilities:
|
||||
raise StandingAuthorityError("CAPABILITY_NOT_GRANTED", "approval does not grant the requested capability")
|
||||
envelope = self.service.get_approval_envelope(approval.candidate_id)
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
self._check_binding(approval, authorization, scope, required)
|
||||
self._verify_signed(approval, candidate, plan, now)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Distinct filename/path contracts and no-follow writes at the filesystem edge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ipaddress
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
|
||||
|
||||
def require_isolated_dev_profile(host: str) -> None:
|
||||
if os.getenv("AIOS_GITOPS_DEV_ALLOW", "0") != "1":
|
||||
return
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except (TypeError, ValueError):
|
||||
loopback = False
|
||||
if os.getenv("AIOS_RUNTIME_PROFILE") != "isolated-staging" or not loopback:
|
||||
raise RuntimeError("DEV_ALLOW requires isolated-staging and loopback")
|
||||
|
||||
|
||||
def safe_segment(value: str, *, max_length: int = 160) -> str:
|
||||
if (not isinstance(value, str) or not value or len(value) > max_length
|
||||
or value in (".", "..") or value.endswith((".", " "))
|
||||
or re.fullmatch(r"[A-Za-z0-9_@.-]+", value) is None
|
||||
or value.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(1, 10)], *[f"LPT{i}" for i in range(1, 10)]}):
|
||||
raise ValueError("invalid single path segment")
|
||||
return value
|
||||
|
||||
|
||||
def relative_parts(value: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, str) or "\\" in value or ":" in value or value.startswith("/"):
|
||||
raise ValueError("invalid relative path")
|
||||
parts = tuple(value.split("/"))
|
||||
for part in parts:
|
||||
safe_segment(part, max_length=255)
|
||||
if parts[0] == ".git":
|
||||
raise ValueError("Git metadata path forbidden")
|
||||
return parts
|
||||
|
||||
|
||||
def contained_path(root: Path, segment: str) -> Path:
|
||||
safe_segment(segment)
|
||||
root = Path(root)
|
||||
if root.is_symlink():
|
||||
raise ValueError("symlink root forbidden")
|
||||
path = root / segment
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError("path escapes root")
|
||||
return path
|
||||
|
||||
|
||||
def write_text_no_follow(root: Path, relative: str, content: str) -> None:
|
||||
"""Atomic POSIX write using directory descriptors; no lexical-only fallback."""
|
||||
parts = relative_parts(relative)
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("secure directory-relative writes require POSIX no-follow support")
|
||||
root = Path(root)
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
current = os.open(root, flags)
|
||||
temporary = ".c6-" + secrets.token_hex(16)
|
||||
try:
|
||||
for part in parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o750, dir_fd=current)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child = os.open(part, flags, dir_fd=current)
|
||||
os.close(current)
|
||||
current = child
|
||||
try:
|
||||
info = os.stat(parts[-1], dir_fd=current, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("nonregular or multiply-linked target forbidden")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=current)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content); stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(temporary, parts[-1], src_dir_fd=current, dst_dir_fd=current)
|
||||
os.fsync(current)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=current)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
os.close(current)
|
||||
@@ -22,6 +22,16 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
)
|
||||
|
||||
from silent_release_controller import _sanitize, utc_now
|
||||
from task_plane.governance_control_kernel import (
|
||||
AutonomousDecisionEngine,
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
DecisionInput,
|
||||
DecisionEffectIntent,
|
||||
ExactScopeState,
|
||||
NextAction,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
from silent_release_hardening import (
|
||||
BuilderAttestation,
|
||||
CapabilityAuthorizationError,
|
||||
@@ -33,9 +43,60 @@ from silent_release_hardening import (
|
||||
_canonical_json,
|
||||
_durable_json,
|
||||
_safe_child,
|
||||
release_file_lock,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class StandingAuthorizationResult:
|
||||
"""Typed result from a current authority reader plus formal issuer.
|
||||
|
||||
This object is routing evidence, not an authorization token. A result that
|
||||
carries an approval still has to pass ``SignedApprovalVerifier`` inside the
|
||||
release service before the saga can advance.
|
||||
"""
|
||||
|
||||
status: str
|
||||
authority_context: AuthorityDecisionContext
|
||||
approval: Mapping[str, Any] | None = None
|
||||
effect_intent: DecisionEffectIntent = DecisionEffectIntent.SIDE_EFFECT
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
allowed = {
|
||||
"APPROVAL_ISSUED",
|
||||
"AUTHORIZATION_DENIED",
|
||||
"OWNER_REQUIRED",
|
||||
"WAITING_AUTHORITY",
|
||||
}
|
||||
if self.status not in allowed:
|
||||
raise ValueError("invalid standing authorization status")
|
||||
if not isinstance(self.authority_context, AuthorityDecisionContext):
|
||||
raise ValueError("authority_context must be AuthorityDecisionContext")
|
||||
if self.effect_intent is not DecisionEffectIntent.SIDE_EFFECT:
|
||||
raise ValueError("standing authorization must be a SIDE_EFFECT decision")
|
||||
if self.status == "APPROVAL_ISSUED":
|
||||
if not isinstance(self.approval, Mapping):
|
||||
raise ValueError("issued authorization requires a signed approval")
|
||||
if (
|
||||
self.authority_context.lifecycle_state
|
||||
is not AuthorityLifecycleState.ACTIVE
|
||||
or self.authority_context.scope_state is not ExactScopeState.IN_SCOPE
|
||||
or self.authority_context.owner_boundary_reason is not None
|
||||
):
|
||||
raise ValueError("issued approval is inconsistent with authority context")
|
||||
elif self.approval is not None:
|
||||
raise ValueError("non-issued authorization cannot carry an approval")
|
||||
if self.status == "OWNER_REQUIRED" and (
|
||||
self.authority_context.owner_boundary_reason is None
|
||||
):
|
||||
raise ValueError("OWNER_REQUIRED needs an explicit boundary reason")
|
||||
|
||||
|
||||
StandingAuthorizationBroker = Callable[
|
||||
[Mapping[str, Any]], StandingAuthorizationResult | Mapping[str, Any]
|
||||
]
|
||||
|
||||
|
||||
class BuilderSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
@@ -262,38 +323,56 @@ class LeaderLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
self._lock_context = release_file_lock(self.path, blocking=False)
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
self.handle = self._lock_context.__enter__()
|
||||
except BlockingIOError as exc:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
raise RuntimeError("silent release leader is already active") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self.handle is not None:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self._lock_context.__exit__(exc_type, exc, tb)
|
||||
finally:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
|
||||
class PersistentSilentReleaseController:
|
||||
"""Idempotent main-event controller with durable startup/recovery reports."""
|
||||
|
||||
_UNKNOWN_EFFECT_ACTIONS = frozenset(
|
||||
{
|
||||
"reconcile_baseline_registry",
|
||||
"reconcile_release",
|
||||
"reconcile_tag",
|
||||
"resume_rollback",
|
||||
"resume_runtime_switch",
|
||||
"verify_runtime",
|
||||
}
|
||||
)
|
||||
_RECOVERY_SIDE_EFFECT_STATES = frozenset(
|
||||
{
|
||||
"APPROVED",
|
||||
"PRE_SWITCH_REVALIDATING",
|
||||
"SWITCH_PENDING",
|
||||
"SWITCHED",
|
||||
"RUNTIME_VERIFYING",
|
||||
"RUNTIME_VERIFIED",
|
||||
"TAG_PENDING",
|
||||
"TAG_VERIFIED",
|
||||
"BASELINE_REGISTRY_PENDING",
|
||||
"ROLLBACK_PENDING",
|
||||
"RECONCILIATION_REQUIRED",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
@@ -301,14 +380,42 @@ class PersistentSilentReleaseController:
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
owner_approval_broker: StandingAuthorizationBroker | None = None,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.service = service
|
||||
self.main_probe = main_probe
|
||||
self.recovery_worker = recovery_worker
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
self.leader_path = self.root / "controller.leader.lock"
|
||||
self.status_path = self.root / "controller-status.json"
|
||||
|
||||
def run_once_safely(self) -> dict[str, Any]:
|
||||
"""Keep read-only diagnostics available when a control tick fails.
|
||||
|
||||
A dependency failure never grants write permission or repairs a corrupt
|
||||
journal. Only a later successful, fully checked tick may advance work.
|
||||
Exception text can contain credentials, so persist its type only.
|
||||
"""
|
||||
try:
|
||||
return self.run_once()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
# A failed production tick may have reached an adapter before
|
||||
# losing its acknowledgement. Never assert zero effects here.
|
||||
"side_effects_executed": (
|
||||
None if self.service.execution_enabled else False
|
||||
),
|
||||
"status": "FAIL_CLOSED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
def startup_verify(self) -> dict[str, Any]:
|
||||
checked = 0
|
||||
failed = 0
|
||||
@@ -329,6 +436,196 @@ class PersistentSilentReleaseController:
|
||||
_durable_json(self.status_path, status)
|
||||
return status
|
||||
|
||||
@classmethod
|
||||
def _unknown_effects_pending(cls, recovery: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
isinstance(item, Mapping)
|
||||
and item.get("recovery_action") in cls._UNKNOWN_EFFECT_ACTIONS
|
||||
for item in recovery.get("sagas", ())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _recovery_side_effect_candidates(
|
||||
cls, recovery: Mapping[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
str(item["candidate_id"])
|
||||
for item in recovery.get("sagas", ())
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("state") in cls._RECOVERY_SIDE_EFFECT_STATES
|
||||
and item.get("candidate_id")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _authority_context_payload(
|
||||
context: AuthorityDecisionContext,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"lifecycle_state": context.lifecycle_state.value,
|
||||
"scope_state": context.scope_state.value,
|
||||
"owner_boundary_reason": (
|
||||
context.owner_boundary_reason.value
|
||||
if context.owner_boundary_reason is not None
|
||||
else None
|
||||
),
|
||||
"evidence_refs": list(context.evidence_refs),
|
||||
}
|
||||
|
||||
def _try_standing_authorization(self, candidate_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
|
||||
# Default-off compatibility: an already accepted exact approval may be
|
||||
# revalidated, but absence of a current broker never derives new
|
||||
# authority. When a broker is configured it must be consulted on every
|
||||
# side-effect decision so a still-valid short approval cannot mask a
|
||||
# revoked/closed root authorization or an exact-scope change.
|
||||
if self.owner_approval_broker is None and existing is not None:
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(existing.to_dict())
|
||||
except Exception:
|
||||
accepted = None
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"status": "APPROVED",
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
if self.owner_approval_broker is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_BACKEND_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
envelope = self.service.get_approval_envelope(candidate_id)
|
||||
try:
|
||||
issued = self.owner_approval_broker(dict(envelope))
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
authority_context = None
|
||||
decision = None
|
||||
if isinstance(issued, StandingAuthorizationResult):
|
||||
authority_context = issued.authority_context
|
||||
context_payload = self._authority_context_payload(authority_context)
|
||||
decision = self.decision_engine.decide(
|
||||
DecisionInput(
|
||||
goal_id=candidate_id,
|
||||
admission_ready=True,
|
||||
effect_intent=DecisionEffectIntent.SIDE_EFFECT,
|
||||
authority_context=authority_context,
|
||||
evidence_refs=authority_context.evidence_refs,
|
||||
)
|
||||
)
|
||||
decision_payload = decision.to_dict()
|
||||
if decision.next_valid_action is NextAction.DENY_EFFECT:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or "STANDING_AUTHORIZATION_DENIED",
|
||||
"status": "AUTHORIZATION_DENIED",
|
||||
}
|
||||
if decision.next_valid_action is NextAction.OWNER_BOUNDARY:
|
||||
boundary_reason = decision.owner_boundary_reason
|
||||
if boundary_reason is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": "OWNER_BOUNDARY_REASON_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": boundary_reason.value,
|
||||
"status": "OWNER_REQUIRED",
|
||||
}
|
||||
if decision.next_valid_action is not NextAction.CREATE_OR_RESUME_NEXT_CHILD:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or decision.next_valid_action.value,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if issued.status != "APPROVAL_ISSUED":
|
||||
status = (
|
||||
"AUTHORIZATION_DENIED"
|
||||
if issued.status == "AUTHORIZATION_DENIED"
|
||||
else "WAITING_AUTHORITY"
|
||||
)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": issued.status,
|
||||
"status": status,
|
||||
}
|
||||
approval = dict(issued.approval or {})
|
||||
elif isinstance(issued, Mapping):
|
||||
# Backward-compatible C5 callback contract. Mapping coverage does
|
||||
# not grant authority; submit_owner_approval verifies the signed
|
||||
# object against the current exact candidate and ReleasePlan.
|
||||
approval = dict(issued)
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(approval)
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
if accepted.get("status") not in {"VERIFIED_AND_STORED", "IDEMPOTENT"}:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "APPROVAL_NOT_VERIFIED",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"idempotent": accepted.get("status") == "IDEMPOTENT",
|
||||
"saga_state": accepted.get("saga_state"),
|
||||
"status": "APPROVED",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
|
||||
def run_once(self) -> dict[str, Any]:
|
||||
with LeaderLock(self.leader_path):
|
||||
startup = self.startup_verify()
|
||||
@@ -345,26 +642,98 @@ class PersistentSilentReleaseController:
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
# ReleaseRecoveryWorker can resume an externally visible effect.
|
||||
# Re-resolve current root lifecycle and exact scope for every such
|
||||
# candidate before invoking it; executor/verifier checks remain the
|
||||
# final fence. This also prevents a cached short approval from
|
||||
# bypassing root revocation during restart recovery.
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
unknown_effect = self._unknown_effects_pending(recovery_status)
|
||||
recovery_authority_decisions: list[dict[str, Any]] = []
|
||||
recovery_candidates = (
|
||||
self._recovery_side_effect_candidates(recovery_status)
|
||||
if self.recovery_worker is not None
|
||||
else ()
|
||||
)
|
||||
for candidate_id in recovery_candidates:
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
recovery_authority_decisions.append(authorization)
|
||||
if authorization.get("status") != "APPROVED":
|
||||
notification = None
|
||||
if (
|
||||
not unknown_effect
|
||||
and authorization.get("status") == "OWNER_REQUIRED"
|
||||
):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": notification,
|
||||
"recovery": recovery_status,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": (
|
||||
"RECONCILIATION_REQUIRED"
|
||||
if unknown_effect
|
||||
else authorization.get("status")
|
||||
),
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else recovery_status
|
||||
)
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
if self._unknown_effects_pending(recovery_status):
|
||||
result = {
|
||||
"authority_decision": None,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": None,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": "RECONCILIATION_REQUIRED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
source_commit = str(self.main_probe())
|
||||
event_id = f"main-{source_commit}"
|
||||
intent = self.service.create_release_intent(
|
||||
{"event_id": event_id, "source_commit": source_commit}
|
||||
)
|
||||
notification = None
|
||||
authorization = None
|
||||
if intent.get("candidate_id"):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
str(intent["candidate_id"])
|
||||
)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else self.service.get_recovery_status()
|
||||
)
|
||||
candidate_id = str(intent["candidate_id"])
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
if authorization.get("status") == "OWNER_REQUIRED":
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": intent,
|
||||
"notification": notification,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": recovery_authority_decisions,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
@@ -373,6 +742,51 @@ class PersistentSilentReleaseController:
|
||||
return _sanitize(result)
|
||||
|
||||
|
||||
def compose_standing_release_controller(
|
||||
root: Path,
|
||||
service: SilentReleaseActionService,
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
authority_reader: Callable[[], Any],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], Any],
|
||||
signer: Any,
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
clock: Callable[[], Any] | None = None,
|
||||
) -> PersistentSilentReleaseController:
|
||||
"""Compose trusted host adapters into the existing controller and effects.
|
||||
|
||||
Readers must return the validated snapshot types defined by
|
||||
``release_standing_authority``. The signer must already belong to the
|
||||
service's release approval trust domain. This function neither registers
|
||||
keys nor performs network/authority reads during cold startup.
|
||||
"""
|
||||
from release_standing_authority import StandingReleaseAuthorizationBroker
|
||||
|
||||
options = {
|
||||
"execution_ttl_seconds": execution_ttl_seconds,
|
||||
"renewal_margin_seconds": renewal_margin_seconds,
|
||||
}
|
||||
if clock is not None:
|
||||
options["clock"] = clock
|
||||
broker = StandingReleaseAuthorizationBroker(
|
||||
service,
|
||||
authority_reader,
|
||||
execution_scope_reader,
|
||||
signer,
|
||||
**options,
|
||||
)
|
||||
service.install_current_authority_guard(broker.check_current)
|
||||
return PersistentSilentReleaseController(
|
||||
root,
|
||||
service,
|
||||
main_probe=main_probe,
|
||||
recovery_worker=recovery_worker,
|
||||
owner_approval_broker=broker,
|
||||
)
|
||||
|
||||
|
||||
class ShadowActionHandler(BaseHTTPRequestHandler):
|
||||
service: SilentReleaseActionService
|
||||
controller: PersistentSilentReleaseController
|
||||
@@ -485,24 +899,12 @@ def serve_shadow(
|
||||
service,
|
||||
main_probe=git_main_probe(repository),
|
||||
)
|
||||
controller.run_once()
|
||||
controller.run_once_safely()
|
||||
stop = threading.Event()
|
||||
|
||||
def controller_loop() -> None:
|
||||
while not stop.wait(poll_seconds):
|
||||
try:
|
||||
controller.run_once()
|
||||
except Exception as exc: # fail closed and keep the API available
|
||||
_durable_json(
|
||||
controller.status_path,
|
||||
{
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "FAIL_CLOSED",
|
||||
},
|
||||
)
|
||||
controller.run_once_safely()
|
||||
|
||||
handler = type(
|
||||
"ConfiguredShadowActionHandler",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
"""Exact source-only admission composition within the existing Bridge.
|
||||
|
||||
This module neither installs itself nor issues authority. A host activation must
|
||||
pin an Owner-signed policy and an independently verified epoch. The existing
|
||||
Task admission store, V3 producer, Task Plane and principal verifier are reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
SignedArtifact, canonical_json_bytes, _open_secure,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission, SourcePublicationPolicyVerifier,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
raw = _open_secure(path, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("SOURCE_ACTIVATION_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
class ScopedPrincipalProvider:
|
||||
"""Policy revocation also fences principals from already admitted Tasks."""
|
||||
|
||||
def __init__(self, guarded: ScopedSourcePublicationPolicyAdmission, delegate: Any):
|
||||
self.guarded = guarded
|
||||
self.delegate = delegate
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
self.guarded.verifier.require(self.guarded.artifact, self.guarded.request)
|
||||
return self.delegate.readiness()
|
||||
except Exception as exc:
|
||||
return {"ready": False, "mode": "source-publication-only", "error": str(exc)}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]) -> Any:
|
||||
grant = self.delegate.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is None or grant.parent_epoch_id != self.delegate.epoch_id:
|
||||
raise TaskAdmissionError("SOURCE_PRINCIPAL_GRANT_REQUIRED", 403)
|
||||
# status rechecks the signed policy, exact durable envelope and original
|
||||
# signed epoch. The delegate then verifies the live TaskLease/state.
|
||||
self.guarded.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
class _AdmissionContext:
|
||||
"""Metadata only for legacy hosts retaining their signed principal verifier."""
|
||||
def __init__(self, bundle):
|
||||
self.store, self.authorization = bundle.store, bundle.authorization
|
||||
self.task_port, self.epoch_id = bundle.task_port, bundle.epoch_id
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
raise TaskAdmissionError("REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED", 403)
|
||||
|
||||
|
||||
def guard_source_runtime(
|
||||
runtime: TaskAdmissionRuntimeBundle, *, artifact: SignedArtifact,
|
||||
verifier: SourcePublicationPolicyVerifier, expected_agent_id: str,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
service = runtime.service
|
||||
if service is None:
|
||||
raise ValueError("SOURCE_ACTIVATION_BOOTSTRAP_REQUIRED")
|
||||
guarded = ScopedSourcePublicationPolicyAdmission(
|
||||
verifier, bootstrap=service, artifact=artifact,
|
||||
expected_agent_id=expected_agent_id,
|
||||
)
|
||||
# Build the typed request through the same exact-envelope guard used by the
|
||||
# HTTP contract, instead of accepting a caller-supplied request projection.
|
||||
payload = artifact.payload
|
||||
request = guarded._request_for_raw({
|
||||
"repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "risk": payload["risk"],
|
||||
"file_allowlist": payload["exact_files"], "requested_capabilities": payload["capabilities"],
|
||||
})
|
||||
guarded.request = request
|
||||
verifier.require(artifact, request)
|
||||
principal = runtime.principal_verifier or _AdmissionContext(runtime)
|
||||
epoch = principal.authorization.verify_delegation_epoch(principal.epoch_id)
|
||||
# The complete per-envelope check runs again on authorization and every
|
||||
# principal use; reject a production-capable epoch already at composition.
|
||||
if (getattr(epoch, "production_scope", None) != "none"
|
||||
or getattr(epoch, "destructive_policy", None) != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(guarded).handlers(),
|
||||
principal_verifier=ScopedPrincipalProvider(guarded, principal),
|
||||
service=guarded,
|
||||
)
|
||||
|
||||
|
||||
def build_source_runtime_from_activation_file(path: Path, *, post_admission: Any,
|
||||
ordinary_activation_file: Path):
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
load_owner_approval_verifier,
|
||||
)
|
||||
_, config = _read_object(path)
|
||||
if set(config) != {"schema", "policy_artifact", "policy_sha256", "policy_revocations",
|
||||
"admission_activation", "expected_agent_id"} or config["schema"] != "aios.source-publication-host-activation.v1":
|
||||
raise ValueError("SOURCE_ACTIVATION_FIELDS_INVALID")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = config[key]
|
||||
if not isinstance(value, str) or not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise ValueError("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
raw, signed = _read_object(Path(config["policy_artifact"]))
|
||||
if hashlib.sha256(raw).hexdigest() != config["policy_sha256"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
if set(signed) != {"payload", "signature", "key_id", "namespace"}:
|
||||
raise ValueError("SOURCE_ACTIVATION_ARTIFACT_INVALID")
|
||||
artifact = SignedArtifact(canonical_payload=canonical_json_bytes(signed["payload"]), **signed)
|
||||
_, ordinary_config = _read_object(ordinary_activation_file)
|
||||
_, source_config = _read_object(Path(config["admission_activation"]))
|
||||
for field in ("state_db", "task_api_url", "task_control_token_file", "owner_verifier_module"):
|
||||
if not ordinary_config.get(field) or source_config.get(field) != ordinary_config[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH")
|
||||
|
||||
def not_revoked(payload: Mapping[str, Any]) -> bool:
|
||||
_, value = _read_object(Path(config["policy_revocations"]))
|
||||
if (set(value) != {"schema", "revoked_nonces"}
|
||||
or value["schema"] != "aios.source-publication-revocations.v1"
|
||||
or not isinstance(value["revoked_nonces"], list)
|
||||
or any(not isinstance(nonce, str) or not nonce for nonce in value["revoked_nonces"])):
|
||||
return False
|
||||
return payload["nonce"] not in value["revoked_nonces"]
|
||||
|
||||
runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(config["admission_activation"]), post_admission=post_admission)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
load_owner_approval_verifier(Path(source_config["owner_verifier_module"])),
|
||||
revocation_check=not_revoked,
|
||||
expected_policy_digest=runtime.service.policy.normalized_policy_digest(),
|
||||
)
|
||||
guarded_runtime = guard_source_runtime(runtime, artifact=artifact, verifier=verifier,
|
||||
expected_agent_id=config["expected_agent_id"])
|
||||
payload = artifact.payload
|
||||
plan_path = Path(source_config["execution_plan_dir"]) / (payload["execution_digest"] + ".json")
|
||||
plan_bytes, plan = _read_object(plan_path)
|
||||
if (hashlib.sha256(plan_bytes).hexdigest() != payload["execution_digest"]
|
||||
or canonical_json_bytes(plan) != plan_bytes):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_DIGEST_MISMATCH")
|
||||
hashes = plan.get("file_sha256")
|
||||
if not isinstance(hashes, dict) or set(hashes) != set(payload["exact_files"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_FILE_HASH_SET_MISMATCH")
|
||||
for field in ("repository", "authority_base_oid", "target_branch", "generation", "risk"):
|
||||
if plan.get(field) != payload[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_SCOPE_MISMATCH")
|
||||
if sorted(plan.get("capabilities", [])) != sorted(payload["capabilities"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if not isinstance(plan.get("task_id"), str) or not plan["task_id"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_TASK_REQUIRED")
|
||||
guarded_runtime.service.expected_file_sha256 = dict(hashes)
|
||||
guarded_runtime.service.expected_task_id = plan["task_id"]
|
||||
return guarded_runtime
|
||||
|
||||
|
||||
class AdmissionRuntimeRouter:
|
||||
"""Route an exact branch/epoch without widening the ordinary policy."""
|
||||
|
||||
def __init__(self, ordinary: TaskAdmissionRuntimeBundle, source: TaskAdmissionRuntimeBundle):
|
||||
self.ordinary = ordinary
|
||||
self.source = source
|
||||
self.guard = source.service
|
||||
self.store = self.guard.bootstrap.store
|
||||
|
||||
def _source_request(self, operation: str, request: Mapping[str, Any]) -> bool:
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return request.get("target_branch") == self.guard.artifact.payload["target_branch"]
|
||||
envelope_id = str(request.get("envelope_id") or "")
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
# Never fall through after finding a source envelope, including
|
||||
# when it has expired or been revoked.
|
||||
return False
|
||||
envelope = self.guard.bootstrap._load_envelope(envelope_id)
|
||||
return envelope.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
def dispatch(operation: str, request: Mapping[str, Any]):
|
||||
runtime = self.source if self._source_request(operation, request) else self.ordinary
|
||||
return runtime.handlers[operation](request)
|
||||
return {op: (lambda request, op=op: dispatch(op, request)) for op in self.source.handlers}
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
return {"mode": "scoped-admission-router", "ordinary": self.ordinary.principal_verifier.readiness(),
|
||||
"source_publication": self.source.principal_verifier.readiness()}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
provider = (self.source.principal_verifier if grant is not None
|
||||
and grant.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
else self.ordinary.principal_verifier)
|
||||
return provider(transport, request)
|
||||
|
||||
def bundle(self) -> TaskAdmissionRuntimeBundle:
|
||||
return TaskAdmissionRuntimeBundle(handlers=self.handlers(), principal_verifier=self)
|
||||
|
||||
|
||||
class ExistingPrincipalPolicyFence:
|
||||
"""Add a C5 policy fence while retaining the registered signed verifier.
|
||||
|
||||
Layered staging hosts use a signed TaskPrincipal verifier with their own
|
||||
storage scope. They must not be migrated to a server attestation merely to
|
||||
add source admission. Every request still reaches the original verifier.
|
||||
"""
|
||||
|
||||
def __init__(self, delegate: Any, source_runtime: TaskAdmissionRuntimeBundle):
|
||||
self.delegate = delegate
|
||||
self.guard = source_runtime.service
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.delegate, name)
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.guard.bootstrap.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is not None and grant.target_branch == self.guard.artifact.payload["target_branch"]:
|
||||
self.guard.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
if transport.get("operation") in {"commitFilesGoverned", "commitFileGoverned"}:
|
||||
expected = getattr(self.guard, "expected_file_sha256", None)
|
||||
if not isinstance(expected, dict) or not expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_HASHES_REQUIRED", 403)
|
||||
try:
|
||||
raw = request.get("files")
|
||||
if raw is None:
|
||||
raw = {request["path"]: request["content_base64"]}
|
||||
pairs = list(raw.items()) if isinstance(raw, dict) else [(item["path"], item["content_base64"]) for item in raw]
|
||||
if len({path for path, _ in pairs}) != len(pairs):
|
||||
raise ValueError("duplicate path")
|
||||
actual = {path: hashlib.sha256(base64.b64decode(data, validate=True)).hexdigest()
|
||||
for path, data in pairs}
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_PAYLOAD_INVALID", 403) from exc
|
||||
if actual != expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_CONTENT_SCOPE_MISMATCH", 403)
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
def expired_admission_runtime() -> TaskAdmissionRuntimeBundle:
|
||||
"""Keep a verified-expired ordinary epoch denied while serving other routes."""
|
||||
def deny(*_args: Any, **_kwargs: Any):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
|
||||
class ClosedPrincipal:
|
||||
__call__ = staticmethod(deny)
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "TASK_ADMISSION_EPOCH_EXPIRED"}
|
||||
|
||||
from aios_task_admission_bridge_contract import TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers={op: deny for op in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS},
|
||||
principal_verifier=ClosedPrincipal(),
|
||||
)
|
||||
@@ -1,20 +1,257 @@
|
||||
"""Task Center API."""
|
||||
"""Authenticated read-only Task Center projections and event transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
except Exception: # pragma: no cover - importable without FastAPI for model tests
|
||||
APIRouter = HTTPException = Request = StreamingResponse = None
|
||||
|
||||
from .task_center import TaskCenter
|
||||
from .task_dashboard_view import ProjectionCursorExpired, ProjectionCursorTransportLimit, TaskDashboardView
|
||||
from .task_plane.errors import ForbiddenError, UnauthorizedError
|
||||
|
||||
|
||||
class ProjectionUnavailable(RuntimeError):
|
||||
"""The host did not install the source and identity bindings required to read."""
|
||||
|
||||
|
||||
task_center = TaskCenter()
|
||||
dashboard = TaskDashboardView()
|
||||
_projection_reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None
|
||||
_principal_resolver: Callable[[Any], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def get_task_overview(tasks: list[dict[str, Any]] | None = None):
|
||||
return task_center.summarize(tasks)
|
||||
def set_task_projection_reader(reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None) -> None:
|
||||
"""Install a host-owned reader that returns already-authorized task/event data."""
|
||||
global _projection_reader
|
||||
_projection_reader = reader
|
||||
|
||||
|
||||
def set_task_projection_principal_resolver(
|
||||
resolver: Callable[[Any], Mapping[str, Any] | None] | None,
|
||||
) -> None:
|
||||
"""Install the host's authenticated-principal resolver for all projection reads."""
|
||||
global _principal_resolver
|
||||
_principal_resolver = resolver
|
||||
|
||||
|
||||
def get_task_overview(
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project provided durable records; route callers use ``_read_projection``."""
|
||||
summary = task_center.summarize(tasks)
|
||||
projection = dashboard.build(tasks, events_by_task)
|
||||
return {
|
||||
**summary,
|
||||
"tasks": projection["tasks"],
|
||||
"views": projection["views"],
|
||||
"snapshot_cursor": dashboard.snapshot_cursor(tasks, events_by_task),
|
||||
}
|
||||
|
||||
|
||||
def get_task_events(
|
||||
cursor: str | None,
|
||||
*,
|
||||
principal: Mapping[str, Any],
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
feed = dashboard.event_feed(source["tasks"], source["events_by_task"], cursor, limit=limit)
|
||||
cards = {card["task_id"]: card for card in dashboard.build(source["tasks"], source["events_by_task"])["tasks"]}
|
||||
for event in feed["events"]:
|
||||
card = cards.get(event["entity_id"])
|
||||
if card is None:
|
||||
continue
|
||||
# Event sequence and task revision have different authority domains.
|
||||
# The client fences entity snapshots by task revision, while resumable
|
||||
# transport remains governed by the per-task append sequence.
|
||||
event["event_sequence"] = event["sequence"]
|
||||
event["event_revision"] = event.get("revision")
|
||||
event["task_revision"] = card.get("revision")
|
||||
event["revision"] = card.get("revision")
|
||||
event["task"] = card
|
||||
event["related_tasks"] = _ancestor_cards(card, cards)
|
||||
return feed
|
||||
|
||||
|
||||
def _ancestor_cards(card: Mapping[str, Any], cards: Mapping[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
related: list[dict[str, Any]] = []
|
||||
parent_id = card.get("lineage", {}).get("parent_task_id") if isinstance(card.get("lineage"), Mapping) else None
|
||||
visited: set[str] = set()
|
||||
while isinstance(parent_id, str) and parent_id and parent_id not in visited:
|
||||
visited.add(parent_id)
|
||||
parent = cards.get(parent_id)
|
||||
if parent is None:
|
||||
break
|
||||
related.append(parent)
|
||||
lineage = parent.get("lineage")
|
||||
parent_id = lineage.get("parent_task_id") if isinstance(lineage, Mapping) else None
|
||||
return related
|
||||
|
||||
|
||||
def configure_task_plane_projection(service: Any, principal_resolver: Callable[[Any], Mapping[str, Any] | None]) -> None:
|
||||
"""Bind this FastAPI router to an existing authorized ``TaskPlaneService``.
|
||||
|
||||
The resolver must return a host-authenticated principal containing the
|
||||
server-derived ``task_plane_identity``. No browser-provided role or scope
|
||||
participates in these reads.
|
||||
"""
|
||||
def reader(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
actor = principal.get("task_plane_identity")
|
||||
if not isinstance(actor, str) or not actor:
|
||||
raise PermissionError("task projection identity is missing")
|
||||
return _read_task_plane_snapshot(service, actor)
|
||||
|
||||
set_task_projection_principal_resolver(principal_resolver)
|
||||
set_task_projection_reader(reader)
|
||||
|
||||
|
||||
_TASKPLANE_DISPLAY_STATUSES = {
|
||||
"CREATED": "pending",
|
||||
"QUEUED": "pending",
|
||||
"CLAIMED": "running",
|
||||
"RUNNING": "running",
|
||||
"WAITING_CHILD": "running",
|
||||
"VERIFYING": "running",
|
||||
"COMPLETED": "completed",
|
||||
"FAILED_RECOVERABLE": "blocked",
|
||||
"FAILED_FINAL": "blocked",
|
||||
"CANCELLED": "blocked",
|
||||
"MANUAL_REVIEW_REQUIRED": "waiting_approval",
|
||||
}
|
||||
|
||||
|
||||
def _read_task_plane_snapshot(service: Any, actor: str) -> Mapping[str, Any]:
|
||||
"""Read an authorized, bounded-consistency TaskPlane projection.
|
||||
|
||||
``list_tasks`` and ``get_events`` are separate authorized reads. We only
|
||||
publish their result when a second task and event-position read confirms
|
||||
that no visible task revision, membership, or per-task append position
|
||||
moved while the first snapshot was collected. A busy plane is retried
|
||||
once and then fails closed rather than emitting a mixed snapshot.
|
||||
"""
|
||||
for _attempt in range(2):
|
||||
raw_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
task_ids = [task["task_id"] for task in raw_tasks]
|
||||
events_by_task = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verification_events = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verified_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
if _task_revisions(raw_tasks) == _task_revisions(verified_tasks) and all(
|
||||
_event_positions(events_by_task[task_id]) == _event_positions(verification_events[task_id])
|
||||
for task_id in task_ids
|
||||
):
|
||||
return {"tasks": raw_tasks, "events_by_task": events_by_task}
|
||||
raise ProjectionUnavailable("task projection changed during snapshot")
|
||||
|
||||
|
||||
def _visible_tasks(records: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(records, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid tasks")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for raw in records:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("task_id"), str) or not raw["task_id"]:
|
||||
raise ProjectionUnavailable("task projection source returned invalid task")
|
||||
source_status = str(raw.get("status") or "").upper()
|
||||
if source_status not in _TASKPLANE_DISPLAY_STATUSES:
|
||||
raise ProjectionUnavailable("task projection source returned unknown TaskPlane status")
|
||||
tasks.append({
|
||||
**raw,
|
||||
"source_status": source_status or None,
|
||||
"status": _TASKPLANE_DISPLAY_STATUSES[source_status],
|
||||
})
|
||||
return tasks
|
||||
|
||||
|
||||
def _task_revisions(tasks: list[Mapping[str, Any]]) -> tuple[tuple[str, Any], ...]:
|
||||
return tuple(sorted((task["task_id"], task.get("revision")) for task in tasks))
|
||||
|
||||
|
||||
def _event_positions(events: Any) -> tuple[tuple[str, int], ...]:
|
||||
if not isinstance(events, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid events")
|
||||
positions: list[tuple[str, int]] = []
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
raise ProjectionUnavailable("task projection source returned invalid event")
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
raise ProjectionUnavailable("task projection event has no durable sequence")
|
||||
positions.append((event["event_id"], sequence))
|
||||
return tuple(positions)
|
||||
|
||||
|
||||
def _read_projection(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if _projection_reader is None:
|
||||
raise ProjectionUnavailable("task projection source is not configured")
|
||||
source = _projection_reader(principal)
|
||||
if not isinstance(source, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned invalid data")
|
||||
tasks, events = source.get("tasks"), source.get("events_by_task")
|
||||
if not isinstance(tasks, list) or not isinstance(events, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned incomplete data")
|
||||
return {"tasks": tasks, "events_by_task": events}
|
||||
|
||||
|
||||
def _principal(request: Any) -> Mapping[str, Any]:
|
||||
if _principal_resolver is None:
|
||||
raise ProjectionUnavailable("task projection authentication is not configured")
|
||||
principal = _principal_resolver(request)
|
||||
if not isinstance(principal, Mapping):
|
||||
raise PermissionError("task projection authentication failed")
|
||||
return principal
|
||||
|
||||
|
||||
def _sse(payload: Mapping[str, Any]):
|
||||
# The bounded response is deliberately resumable. The browser reconnects
|
||||
# with the returned cursor; polling remains available when it cannot.
|
||||
for event in payload["events"]:
|
||||
yield f"id: {event['cursor']}\ndata: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
yield ": end-of-batch\n\n"
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ProjectionCursorTransportLimit as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=413, detail="TASK_EVENT_CURSOR_TOO_LARGE") from exc
|
||||
except ProjectionCursorExpired as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=410, detail="EVENT_CURSOR_EXPIRED") from exc
|
||||
except ProjectionUnavailable as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=503, detail="TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
except UnauthorizedError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=401, detail="TASK_PROJECTION_UNAUTHORIZED") from exc
|
||||
except ForbiddenError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except PermissionError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _overview(principal: Mapping[str, Any]) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
return get_task_overview(source["tasks"], source["events_by_task"])
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -23,12 +260,37 @@ def build_router():
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
|
||||
|
||||
@router.get("/overview", operation_id="getTaskCenterOverview")
|
||||
def overview():
|
||||
return get_task_overview()
|
||||
def overview(request: Request):
|
||||
return _http(lambda: _overview(_principal(request)))
|
||||
|
||||
@router.get("/events", operation_id="streamTaskCenterEvents")
|
||||
def events(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
payload = _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
return StreamingResponse(_sse(payload), media_type="text/event-stream")
|
||||
|
||||
@router.get("/events/poll", operation_id="pollTaskCenterEvents")
|
||||
def poll(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
return _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_task_overview"]
|
||||
__all__ = [
|
||||
"router", "get_task_overview", "get_task_events", "set_task_projection_reader",
|
||||
"set_task_projection_principal_resolver", "configure_task_plane_projection",
|
||||
"ProjectionUnavailable",
|
||||
]
|
||||
|
||||
|
||||
def _effective_cursor(request: Any, cursor: str | None, after_cursor: str | None) -> str | None:
|
||||
header_cursor = request.headers.get("Last-Event-ID") if request is not None else None
|
||||
supplied = [value for value in (cursor, after_cursor, header_cursor) if value]
|
||||
if len(set(supplied)) > 1:
|
||||
raise ValueError("conflicting event cursors")
|
||||
return supplied[0] if supplied else None
|
||||
|
||||
@@ -1,81 +1,438 @@
|
||||
"""Task Center visualization model."""
|
||||
"""Read-only Task Center projections built from durable task and event records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import base64
|
||||
import json
|
||||
import zlib
|
||||
from collections import defaultdict
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class ProjectionCursorExpired(ValueError):
|
||||
"""The requested event cursor no longer names a visible durable event."""
|
||||
|
||||
|
||||
class ProjectionCursorTransportLimit(ValueError):
|
||||
"""The authorized per-task watermark cannot fit a request/header cursor."""
|
||||
|
||||
|
||||
_MAX_CURSOR_BYTES = 8192
|
||||
_MAX_CURSOR_PAYLOAD_BYTES = 32768
|
||||
_INITIAL_INTERACTION_KIND = "initial_request"
|
||||
_ADDITIONAL_INTERACTION_KINDS = frozenset({"additional_interaction", "follow_up"})
|
||||
|
||||
|
||||
class TaskDashboardView:
|
||||
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
"""Build the Task Center read model without maintaining another state store."""
|
||||
|
||||
def build(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_tasks = [dict(task) for task in tasks or [] if isinstance(task, Mapping)]
|
||||
by_id = {
|
||||
task_id: task for task in raw_tasks
|
||||
if isinstance((task_id := task.get("task_id")), str) and task_id
|
||||
}
|
||||
children = self._children_by_parent(by_id)
|
||||
cards = [
|
||||
self._task_card(task, by_id, children, events_by_task)
|
||||
for task in raw_tasks
|
||||
]
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS Task Center",
|
||||
"views": {
|
||||
"kanban": self._kanban(tasks),
|
||||
"timeline": self._timeline(tasks),
|
||||
"flow": self._flow(tasks),
|
||||
"statistics": self._statistics(tasks),
|
||||
"kanban": self._kanban(cards),
|
||||
"timeline": self._timeline(cards),
|
||||
"flow": self._flow(cards),
|
||||
"statistics": self._statistics(cards),
|
||||
},
|
||||
"tasks": [self._task_card(task) for task in tasks],
|
||||
"tasks": cards,
|
||||
}
|
||||
|
||||
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
def event_feed(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
cursor: str | None = None,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Return an authorization-filtered, resumable page of durable events."""
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("invalid event page limit")
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks = self._decode_cursor(cursor) if cursor else {}
|
||||
self._validate_watermarks(rows, watermarks)
|
||||
pending = [
|
||||
row for row in rows
|
||||
if row["sequence"] > watermarks.get(row["entity_id"], -1)
|
||||
]
|
||||
page = pending[:limit]
|
||||
next_watermarks = dict(watermarks)
|
||||
emitted_events: list[dict[str, Any]] = []
|
||||
for row in page:
|
||||
next_watermarks[row["entity_id"]] = max(next_watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
# ``Last-Event-ID`` holds the cursor from the final SSE frame. It
|
||||
# must therefore include every preceding entity watermark, not just
|
||||
# the frame's own task, or a reconnect would replay another task.
|
||||
emitted_events.append({**row, "cursor": self._encode_cursor(next_watermarks)})
|
||||
next_cursor = self._encode_cursor(next_watermarks) if next_watermarks else None
|
||||
return {
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"events": emitted_events,
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": len(pending) > len(page),
|
||||
}
|
||||
|
||||
def snapshot_cursor(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> str | None:
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks: dict[str, int] = {}
|
||||
for row in rows:
|
||||
watermarks[row["entity_id"]] = max(watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
return self._encode_cursor(watermarks) if watermarks else None
|
||||
|
||||
def _task_card(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
by_id: Mapping[str, dict[str, Any]],
|
||||
children: Mapping[str, list[str]],
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
task_id = task.get("task_id")
|
||||
progress = task.get("progress") if isinstance(task.get("progress"), Mapping) else {}
|
||||
parent_task_id = task.get("parent_task_id")
|
||||
event_result = self._project_events(
|
||||
task_id,
|
||||
events_by_task,
|
||||
complete=task.get("interaction_history_coverage") == "complete",
|
||||
)
|
||||
direct_children = list(children.get(task_id, ())) if isinstance(task_id, str) else []
|
||||
grandchildren = [grandchild for child in direct_children for grandchild in children.get(child, ())]
|
||||
authorization = self._authorization(task)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"title": task.get("title") or task.get("display_name"),
|
||||
"root_task_id": task.get("root_task_id"),
|
||||
"created_at": task.get("created_at"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"status": task.get("status"),
|
||||
"progress": task.get("progress", {}),
|
||||
"elapsed_seconds": task.get("elapsed_seconds", 0),
|
||||
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
|
||||
"source_status": task.get("source_status", task.get("status")),
|
||||
"revision": task.get("revision"),
|
||||
"progress": progress,
|
||||
"elapsed_seconds": self._known_nonnegative_int(task.get("elapsed_seconds")),
|
||||
"remaining_steps": progress.get("remaining_steps", []),
|
||||
"notification": task.get("notification", {}),
|
||||
"actions": [
|
||||
"start",
|
||||
"update_progress",
|
||||
"block",
|
||||
"request_approval",
|
||||
"approve",
|
||||
"reject",
|
||||
"retry",
|
||||
"complete",
|
||||
"notify",
|
||||
"close",
|
||||
],
|
||||
"lineage": {
|
||||
"parent_task_id": parent_task_id if isinstance(parent_task_id, str) and parent_task_id in by_id else None,
|
||||
"child_task_ids": direct_children,
|
||||
"grandchild_task_ids": grandchildren,
|
||||
},
|
||||
"responsibility": {
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"owner_principal_id": task.get("owner_principal_id"),
|
||||
},
|
||||
"authorization": authorization,
|
||||
"actions": self._actions(task, authorization),
|
||||
"interaction_history": event_result,
|
||||
}
|
||||
|
||||
def _kanban(self, tasks):
|
||||
@staticmethod
|
||||
def _known_nonnegative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _children_by_parent(by_id: Mapping[str, dict[str, Any]]) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = defaultdict(list)
|
||||
for task_id, task in by_id.items():
|
||||
parent_id = task.get("parent_task_id")
|
||||
if isinstance(parent_id, str) and parent_id in by_id:
|
||||
result[parent_id].append(task_id)
|
||||
return {parent: sorted(child_ids) for parent, child_ids in result.items()}
|
||||
|
||||
def _project_events(
|
||||
self,
|
||||
task_id: Any,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
*,
|
||||
complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(task_id, str) or events_by_task is None or task_id not in events_by_task:
|
||||
return self._unknown_history()
|
||||
raw_events = events_by_task[task_id]
|
||||
if not isinstance(raw_events, list):
|
||||
return self._unknown_history()
|
||||
seen: dict[str, dict[str, Any]] = {}
|
||||
semantic_ids: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
events: list[dict[str, Any]] = []
|
||||
conflicts: list[dict[str, str]] = []
|
||||
for raw in raw_events:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("event_id"), str):
|
||||
conflicts.append({"kind": "invalid_event", "event_id": ""})
|
||||
continue
|
||||
event = dict(raw)
|
||||
event_id = event["event_id"]
|
||||
previous = seen.get(event_id)
|
||||
if previous is not None:
|
||||
if previous != event:
|
||||
conflicts.append({"kind": "duplicate_event_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
seen[event_id] = event
|
||||
item = self._event_item(event)
|
||||
if self._malformed_human_interaction(event):
|
||||
conflicts.append({"kind": "invalid_human_interaction_evidence", "event_id": event_id})
|
||||
if self._malformed_metric_evidence(event):
|
||||
conflicts.append({"kind": "invalid_metric_evidence", "event_id": event_id})
|
||||
semantic_id = item.get("semantic_id")
|
||||
if semantic_id:
|
||||
semantic_namespace = "human_interaction" if item["category"] in {"initial_request", "additional_interaction"} else item["category"]
|
||||
previous_semantic = semantic_ids.get((semantic_namespace, semantic_id))
|
||||
if previous_semantic is not None:
|
||||
if self._semantic_content(previous_semantic) != self._semantic_content(item):
|
||||
conflicts.append({"kind": "duplicate_semantic_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
semantic_ids[(semantic_namespace, semantic_id)] = item
|
||||
events.append(item)
|
||||
events.sort(key=lambda event: (event.get("created_at") or "", event["event_id"]))
|
||||
interactions = sum(event["category"] in {"initial_request", "additional_interaction"} for event in events)
|
||||
is_exact = complete and not conflicts
|
||||
return {
|
||||
"history_status": "partial" if not complete else ("conflicted" if conflicts else "complete"),
|
||||
"interaction_count": interactions if is_exact else None,
|
||||
"initial_request_count": sum(event["category"] == "initial_request" for event in events) if is_exact else None,
|
||||
"additional_interaction_count": sum(event["category"] == "additional_interaction" for event in events) if is_exact else None,
|
||||
"decision_count": sum(event["category"] == "decision" for event in events) if is_exact else None,
|
||||
"offered_choice_count": sum(event.get("choice_count", 0) for event in events if event["category"] == "offered_choice") if is_exact else None,
|
||||
"selection_submission_count": sum(event["category"] == "selection_submission" for event in events) if is_exact else None,
|
||||
"events": events,
|
||||
"source_conflicts": conflicts,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _unknown_history() -> dict[str, Any]:
|
||||
return {
|
||||
"history_status": "partial",
|
||||
"interaction_count": None,
|
||||
"initial_request_count": None,
|
||||
"additional_interaction_count": None,
|
||||
"decision_count": None,
|
||||
"offered_choice_count": None,
|
||||
"selection_submission_count": None,
|
||||
"events": [],
|
||||
"source_conflicts": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _semantic_content(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value for key, value in event.items()
|
||||
if key not in {"event_id", "sequence", "revision", "created_at"}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _malformed_human_interaction(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
if source != "HUMAN_INTERACTION":
|
||||
return False
|
||||
interaction_id = data.get("interaction_id")
|
||||
kind = data.get("kind")
|
||||
return not (
|
||||
isinstance(interaction_id, str) and interaction_id
|
||||
and isinstance(kind, str)
|
||||
and (kind == _INITIAL_INTERACTION_KIND or kind in _ADDITIONAL_INTERACTION_KINDS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_metric_evidence(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
event_type = str(event.get("type") or "").upper()
|
||||
if event_type in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"}:
|
||||
return not isinstance(data.get("decision_id"), str) or not data["decision_id"]
|
||||
if event_type in {"CHOICE_OFFERED", "OPTIONS_OFFERED"}:
|
||||
choice_count = data.get("choice_count")
|
||||
return not (
|
||||
isinstance(data.get("choice_offer_id"), str) and data["choice_offer_id"]
|
||||
and isinstance(choice_count, int) and not isinstance(choice_count, bool) and choice_count >= 0
|
||||
)
|
||||
if event_type in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"}:
|
||||
return not isinstance(data.get("selection_submission_id"), str) or not data["selection_submission_id"]
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _event_item(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
event_type = str(event.get("type") or "UNKNOWN")
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
normalized = event_type.upper()
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
interaction_id = data.get("interaction_id")
|
||||
decision_id = data.get("decision_id")
|
||||
selection_id = data.get("selection_submission_id")
|
||||
kind = data.get("kind")
|
||||
if source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and kind == _INITIAL_INTERACTION_KIND:
|
||||
category = "initial_request"
|
||||
semantic_id = interaction_id
|
||||
elif source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and isinstance(kind, str) and kind in _ADDITIONAL_INTERACTION_KINDS:
|
||||
category = "additional_interaction"
|
||||
semantic_id = interaction_id
|
||||
elif normalized in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"} and isinstance(decision_id, str) and decision_id:
|
||||
category = "decision"
|
||||
semantic_id = decision_id
|
||||
elif normalized in {"CHOICE_OFFERED", "OPTIONS_OFFERED"} and isinstance(data.get("choice_offer_id"), str) and isinstance(data.get("choice_count"), int) and not isinstance(data["choice_count"], bool) and data["choice_count"] >= 0:
|
||||
category = "offered_choice"
|
||||
semantic_id = data["choice_offer_id"]
|
||||
elif normalized in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"} and isinstance(selection_id, str) and selection_id:
|
||||
category = "selection_submission"
|
||||
semantic_id = selection_id
|
||||
else:
|
||||
category = "other"
|
||||
semantic_id = None
|
||||
return {
|
||||
"event_id": event["event_id"],
|
||||
"sequence": event.get("seq"),
|
||||
"revision": event.get("revision", event.get("seq")),
|
||||
"type": event_type,
|
||||
"category": category,
|
||||
"semantic_id": semantic_id,
|
||||
"subject": event.get("subject"),
|
||||
"created_at": event.get("created_at"),
|
||||
"decision": data.get("decision") if category == "decision" else None,
|
||||
"choice_id": data.get("choice_id") if category in {"offered_choice", "selection_submission"} else None,
|
||||
"choice_count": data.get("choice_count", 0) if category == "offered_choice" else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _authorization(task: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raw = task.get("authorization")
|
||||
raw = raw if isinstance(raw, Mapping) else {}
|
||||
capabilities = raw.get("capabilities", task.get("capabilities", []))
|
||||
return {
|
||||
"owner_gate_required": bool(task.get("owner_gate_required", raw.get("owner_gate_required", False))),
|
||||
"owner_gate_status": task.get("owner_gate_status", raw.get("owner_gate_status")),
|
||||
"decision": raw.get("decision"),
|
||||
"capabilities": list(capabilities) if isinstance(capabilities, (list, tuple, set, frozenset)) else [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _actions(task: Mapping[str, Any], authorization: Mapping[str, Any]) -> list[str]:
|
||||
explicit = task.get("authorized_actions")
|
||||
if not isinstance(explicit, (list, tuple, set, frozenset)):
|
||||
return []
|
||||
allowed = [action for action in explicit if isinstance(action, str)]
|
||||
capabilities = set(authorization.get("capabilities", []))
|
||||
decision = authorization.get("decision")
|
||||
if "approve" in allowed and not (
|
||||
{"approve", "decision.approve", "task.approve"} & capabilities
|
||||
and isinstance(decision, Mapping)
|
||||
and decision.get("status") in {"pending", "eligible"}
|
||||
):
|
||||
allowed.remove("approve")
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def _kanban(tasks: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
|
||||
for task in tasks:
|
||||
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
|
||||
return columns
|
||||
|
||||
def _timeline(self, tasks):
|
||||
return [
|
||||
{
|
||||
"task_id": task.get("task_id"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"status": task.get("status"),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
@staticmethod
|
||||
def _timeline(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [{"task_id": task.get("task_id"), "status": task.get("status"), "revision": task.get("revision")} for task in tasks]
|
||||
|
||||
def _flow(self, tasks):
|
||||
return {
|
||||
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
|
||||
"task_count": len(tasks),
|
||||
}
|
||||
@staticmethod
|
||||
def _flow(tasks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"], "task_count": len(tasks)}
|
||||
|
||||
def _statistics(self, tasks):
|
||||
@staticmethod
|
||||
def _statistics(tasks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"running": len([t for t in tasks if t.get("status") == "running"]),
|
||||
"blocked": len([t for t in tasks if t.get("status") == "blocked"]),
|
||||
"waiting_approval": len([t for t in tasks if t.get("status") == "waiting_approval"]),
|
||||
"completed": len([t for t in tasks if t.get("status") == "completed"]),
|
||||
"running": sum(task.get("status") == "running" for task in tasks),
|
||||
"blocked": sum(task.get("status") == "blocked" for task in tasks),
|
||||
"waiting_approval": sum(task.get("status") == "waiting_approval" for task in tasks),
|
||||
"completed": sum(task.get("status") == "completed" for task in tasks),
|
||||
}
|
||||
|
||||
def _event_rows(self, tasks, events_by_task) -> list[dict[str, Any]]:
|
||||
visible_ids = {task.get("task_id") for task in tasks or [] if isinstance(task, Mapping)}
|
||||
result: list[dict[str, Any]] = []
|
||||
for task_id, events in (events_by_task or {}).items():
|
||||
if task_id not in visible_ids or not isinstance(events, list):
|
||||
continue
|
||||
# The stream carries every durable append. Semantic deduplication
|
||||
# belongs only to human-history metrics: a later conflicting event
|
||||
# must reach the client so its current task card becomes conflicted.
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
continue
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
continue
|
||||
result.append({"event_id": event["event_id"], "entity_id": task_id, "sequence": sequence, "revision": event.get("revision"), "type": event.get("type", "UNKNOWN"), "created_at": event.get("created_at")})
|
||||
# A per-task append sequence is the cursor authority. Timestamp order
|
||||
# can be displayed by clients, but cannot drive paging: a backdated
|
||||
# seq=6 must never be emitted before seq=5 for the same task.
|
||||
result.sort(key=lambda event: (event["sequence"], event["entity_id"], event["event_id"]))
|
||||
return result
|
||||
|
||||
__all__ = ["TaskDashboardView"]
|
||||
@staticmethod
|
||||
def _encode_cursor(watermarks: Mapping[str, int]) -> str:
|
||||
raw = json.dumps({"v": 2, "after": dict(watermarks)}, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
token = base64.urlsafe_b64encode(zlib.compress(raw, level=9)).decode("ascii").rstrip("=")
|
||||
if len(token) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _decode_cursor(cursor: str) -> dict[str, int]:
|
||||
if not isinstance(cursor, str) or not cursor or len(cursor) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
encoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
decompressor = zlib.decompressobj()
|
||||
raw = decompressor.decompress(encoded, _MAX_CURSOR_PAYLOAD_BYTES + 1)
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ValueError("cursor payload invalid")
|
||||
raw += decompressor.flush(_MAX_CURSOR_PAYLOAD_BYTES + 1 - len(raw))
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES or decompressor.unconsumed_tail or not decompressor.eof:
|
||||
raise ValueError("cursor payload invalid")
|
||||
value = json.loads(raw)
|
||||
except (UnicodeEncodeError, ValueError, zlib.error, json.JSONDecodeError) as exc:
|
||||
raise ProjectionCursorExpired("event cursor expired") from exc
|
||||
after = value.get("after") if isinstance(value, Mapping) and value.get("v") == 2 else None
|
||||
if not isinstance(after, Mapping) or not after or any(
|
||||
not isinstance(task_id, str) or not task_id or not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool) or sequence < 0
|
||||
for task_id, sequence in after.items()
|
||||
):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
return dict(after)
|
||||
|
||||
@staticmethod
|
||||
def _validate_watermarks(rows: list[dict[str, Any]], watermarks: Mapping[str, int]) -> None:
|
||||
positions_by_task: dict[str, set[int]] = defaultdict(set)
|
||||
for row in rows:
|
||||
positions_by_task[row["entity_id"]].add(row["sequence"])
|
||||
# A retained sequence floor is not otherwise exposed by TaskPlane.
|
||||
# Requiring the acknowledged position itself prevents silently skipping
|
||||
# purged seqs 4..9 when a stale cursor still says seq=3.
|
||||
if any(sequence not in positions_by_task.get(task_id, set()) for task_id, sequence in watermarks.items()):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
|
||||
|
||||
__all__ = ["ProjectionCursorExpired", "ProjectionCursorTransportLimit", "TaskDashboardView"]
|
||||
|
||||
@@ -35,7 +35,7 @@ from .models import (
|
||||
contains_secret_text,
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .policy import IdentityVerifier, PolicyEngine
|
||||
from .root_action_consumer import (
|
||||
RootActionConsumer,
|
||||
RootActionExecutor,
|
||||
@@ -71,7 +71,9 @@ class TaskPlaneService:
|
||||
root_action_executor: RootActionExecutor | None = None,
|
||||
):
|
||||
self.store = store
|
||||
self.c6_runtime = None
|
||||
self.policy = policy or PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({"anon"}),
|
||||
execution_enabled=False,
|
||||
identity_capabilities={
|
||||
"anon": {"task.creator", "task.read", "task.audit"}
|
||||
@@ -117,6 +119,9 @@ class TaskPlaneService:
|
||||
else None
|
||||
)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
self.store.configure_evidence_attestation_verifier(
|
||||
self.policy.evidence_attestation_verifier
|
||||
)
|
||||
|
||||
def consume_one_root_action(
|
||||
self,
|
||||
@@ -412,6 +417,11 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.claim_task(
|
||||
task_id=task_id, worker_id=worker_id, actor_identity=actor,
|
||||
lease_seconds=lease_seconds,
|
||||
).to_dict()
|
||||
claim = self.lease.claim(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
@@ -436,6 +446,10 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.renew_claim(task_id=task_id, claim_id=claim_id,
|
||||
actor_identity=actor, receipt=body.get("claim_receipt"),
|
||||
lease_seconds=lease_seconds)
|
||||
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
||||
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
||||
|
||||
@@ -538,16 +552,32 @@ class TaskPlaneService:
|
||||
previous = self.store._last_evidence_hash(task_id)
|
||||
evidence_entries = []
|
||||
for evidence in evidences:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
||||
worker_identity=actor,
|
||||
payload=evidence,
|
||||
previous_evidence_hash=previous,
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
)
|
||||
if "schema_version" in evidence:
|
||||
try:
|
||||
envelope = EvidenceEnvelope.from_dict(evidence)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid evidence envelope") from exc
|
||||
else:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(
|
||||
evidence.get("kind") or evidence.get("type") or "generic"
|
||||
),
|
||||
worker_identity=actor,
|
||||
payload=evidence.get("payload", evidence),
|
||||
previous_evidence_hash=previous,
|
||||
evidence_id=evidence.get("evidence_id"),
|
||||
created_at=evidence.get("created_at"),
|
||||
expires_at=evidence.get("expires_at"),
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
signature_algorithm=evidence.get("signature_algorithm"),
|
||||
signing_key_id=evidence.get("signing_key_id"),
|
||||
verifier_identity=evidence.get("verifier_identity"),
|
||||
authority_domain=evidence.get("authority_domain"),
|
||||
sequence=evidence.get("sequence"),
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
final_status = (
|
||||
@@ -910,8 +940,29 @@ def run_server(
|
||||
*,
|
||||
authenticator: Callable[[str], str | None] | None = None,
|
||||
):
|
||||
handler = TaskPlaneRequestHandler
|
||||
handler.service = service
|
||||
handler.authenticator = authenticator
|
||||
# A separate handler class prevents one server/tenant from overwriting
|
||||
# another concurrently running server's service or authenticator.
|
||||
handler = type("BoundTaskPlaneRequestHandler", (TaskPlaneRequestHandler,), {
|
||||
"service": service, "authenticator": authenticator,
|
||||
})
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
server.serve_forever()
|
||||
runtime = service.c6_runtime
|
||||
stop = __import__("threading").Event()
|
||||
runner = None
|
||||
if runtime is not None and runtime.bindings.enabled:
|
||||
import threading
|
||||
def run_ticks():
|
||||
while not stop.is_set():
|
||||
runtime.tick()
|
||||
stop.wait(1.0)
|
||||
runner = threading.Thread(target=run_ticks, name="c6-host-tick", daemon=True)
|
||||
runner.start()
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
if runtime is not None:
|
||||
runtime.close()
|
||||
if runner is not None:
|
||||
runner.join(timeout=2.0)
|
||||
server.server_close()
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""C6 ARO Phase A: bounded read-only audit evaluation and immutable reports."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .operation_view import DomainStamp, validate_digest
|
||||
|
||||
|
||||
class Dimension(str, Enum):
|
||||
FUNCTION = "function"
|
||||
PERFORMANCE = "performance"
|
||||
EXCEPTION = "exception"
|
||||
USAGE = "usage"
|
||||
CODE_DEFECT = "code_defect"
|
||||
HISTORY = "history"
|
||||
DEADLOCK = "deadlock"
|
||||
LONG_RUNNING = "long_running"
|
||||
|
||||
|
||||
_DIMENSIONS = tuple(Dimension)
|
||||
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,255}$")
|
||||
_METRICS: dict[Dimension, frozenset[str]] = {
|
||||
Dimension.FUNCTION: frozenset({"required_checks", "passed_checks"}),
|
||||
Dimension.PERFORMANCE: frozenset({"duration_p95_ms"}),
|
||||
Dimension.EXCEPTION: frozenset({"unhandled_exception_count"}),
|
||||
Dimension.USAGE: frozenset({"operational_prompt_count", "manual_continue_count"}),
|
||||
Dimension.CODE_DEFECT: frozenset({"validated_open_defect_count"}),
|
||||
Dimension.HISTORY: frozenset({"same_fingerprint_failure_count"}),
|
||||
Dimension.DEADLOCK: frozenset({"wait_cycle_count", "oldest_no_progress_seconds"}),
|
||||
Dimension.LONG_RUNNING: frozenset({"oldest_nonterminal_age_seconds"}),
|
||||
}
|
||||
_COUNT_METRICS = frozenset(
|
||||
{
|
||||
"required_checks",
|
||||
"passed_checks",
|
||||
"unhandled_exception_count",
|
||||
"operational_prompt_count",
|
||||
"manual_continue_count",
|
||||
"validated_open_defect_count",
|
||||
"same_fingerprint_failure_count",
|
||||
"wait_cycle_count",
|
||||
}
|
||||
)
|
||||
_THRESHOLDS = frozenset(
|
||||
{
|
||||
"max_duration_p95_ms",
|
||||
"repeated_failure_threshold",
|
||||
"deadlock_seconds",
|
||||
"max_nonterminal_age_seconds",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_ref(value: object) -> str:
|
||||
ref = _text(value, "evidence_ref")
|
||||
lowered = ref.casefold()
|
||||
if (
|
||||
_SAFE_REF_RE.fullmatch(ref) is None
|
||||
or ".." in ref
|
||||
or ref.startswith(("/", "\\"))
|
||||
or "://" in ref
|
||||
or any(
|
||||
marker in lowered
|
||||
for marker in (
|
||||
"token=",
|
||||
"secret=",
|
||||
"password=",
|
||||
"cookie=",
|
||||
"authorization=",
|
||||
"private_key=",
|
||||
"bearer ",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("evidence_ref is not an allowed opaque reference")
|
||||
return ref
|
||||
|
||||
|
||||
def _aware(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, name: str, *, non_negative: bool = True) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be numeric")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or (non_negative and result < 0):
|
||||
raise ValueError(f"{name} must be finite and non-negative")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
metric: str
|
||||
value: float
|
||||
observed_at: datetime
|
||||
evidence_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.metric, "metric")
|
||||
_number(self.value, "sample value")
|
||||
_aware(self.observed_at, "observed_at")
|
||||
_safe_ref(self.evidence_ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DimensionInput:
|
||||
dimension: Dimension
|
||||
samples: tuple[Sample, ...]
|
||||
stamp: DomainStamp
|
||||
captured_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if not isinstance(self.samples, tuple):
|
||||
raise ValueError("samples must be a tuple")
|
||||
if any(not isinstance(sample, Sample) for sample in self.samples):
|
||||
raise ValueError("samples must contain Sample values")
|
||||
if not isinstance(self.stamp, DomainStamp):
|
||||
raise ValueError("stamp must be DomainStamp")
|
||||
captured = _aware(self.captured_at, "captured_at")
|
||||
expires = _aware(self.expires_at, "expires_at")
|
||||
if expires <= captured:
|
||||
raise ValueError("expires_at must be after captured_at")
|
||||
if any(sample.observed_at > captured for sample in self.samples):
|
||||
raise ValueError("sample cannot be observed after capture")
|
||||
|
||||
|
||||
def _canonical_datetime(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _snapshot_payload(
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"window_start": _canonical_datetime(window_start),
|
||||
"window_end": _canonical_datetime(window_end),
|
||||
"rules_revision": rules_revision,
|
||||
"dimensions": [
|
||||
{
|
||||
"dimension": item.dimension.value,
|
||||
"stamp": {
|
||||
"domain": item.stamp.domain,
|
||||
"object_id": item.stamp.object_id,
|
||||
"generation": item.stamp.generation,
|
||||
"revision": item.stamp.revision,
|
||||
"event_id": item.stamp.event_id,
|
||||
},
|
||||
"captured_at": _canonical_datetime(item.captured_at),
|
||||
"expires_at": _canonical_datetime(item.expires_at),
|
||||
"samples": [
|
||||
{
|
||||
"metric": sample.metric,
|
||||
"value": sample.value,
|
||||
"observed_at": _canonical_datetime(sample.observed_at),
|
||||
"evidence_ref": sample.evidence_ref,
|
||||
}
|
||||
for sample in sorted(
|
||||
item.samples,
|
||||
key=lambda value: (
|
||||
value.metric,
|
||||
value.observed_at,
|
||||
value.evidence_ref,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
for item in sorted(
|
||||
dimensions,
|
||||
key=lambda value: (
|
||||
value.dimension.value,
|
||||
value.stamp.domain,
|
||||
value.stamp.object_id,
|
||||
value.stamp.generation,
|
||||
value.stamp.revision,
|
||||
value.stamp.event_id,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: Mapping[str, object]) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditSnapshot:
|
||||
snapshot_id: str
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
dimensions: tuple[DimensionInput, ...]
|
||||
rules_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.snapshot_id)
|
||||
start = _aware(self.window_start, "window_start")
|
||||
end = _aware(self.window_end, "window_end")
|
||||
if end <= start:
|
||||
raise ValueError("audit window must be increasing")
|
||||
if not isinstance(self.dimensions, tuple):
|
||||
raise ValueError("dimensions must be a tuple")
|
||||
if any(not isinstance(item, DimensionInput) for item in self.dimensions):
|
||||
raise ValueError("dimensions must contain DimensionInput values")
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
expected = _digest(
|
||||
_snapshot_payload(
|
||||
window_start=start,
|
||||
window_end=end,
|
||||
dimensions=self.dimensions,
|
||||
rules_revision=self.rules_revision,
|
||||
)
|
||||
)
|
||||
if self.snapshot_id != expected:
|
||||
raise ValueError("snapshot_id does not match complete canonical inputs")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> "AuditSnapshot":
|
||||
payload = _snapshot_payload(
|
||||
window_start=_aware(window_start, "window_start"),
|
||||
window_end=_aware(window_end, "window_end"),
|
||||
dimensions=dimensions,
|
||||
rules_revision=_text(rules_revision, "rules_revision"),
|
||||
)
|
||||
return cls(
|
||||
snapshot_id=_digest(payload),
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=dimensions,
|
||||
rules_revision=rules_revision,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleLimits:
|
||||
rules_revision: str
|
||||
max_snapshot_skew_seconds: int
|
||||
threshold_by_metric: tuple[tuple[str, float], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if (
|
||||
isinstance(self.max_snapshot_skew_seconds, bool)
|
||||
or not isinstance(self.max_snapshot_skew_seconds, int)
|
||||
or self.max_snapshot_skew_seconds < 0
|
||||
):
|
||||
raise ValueError("max_snapshot_skew_seconds must be non-negative int")
|
||||
if not isinstance(self.threshold_by_metric, tuple):
|
||||
raise ValueError("threshold_by_metric must be a tuple")
|
||||
names: list[str] = []
|
||||
for item in self.threshold_by_metric:
|
||||
if not isinstance(item, tuple) or len(item) != 2:
|
||||
raise ValueError("threshold entries must be pairs")
|
||||
name = _text(item[0], "threshold metric")
|
||||
if name not in _THRESHOLDS:
|
||||
raise ValueError("unknown threshold metric")
|
||||
_number(item[1], "threshold")
|
||||
names.append(name)
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate threshold metric")
|
||||
|
||||
@property
|
||||
def thresholds(self) -> dict[str, float]:
|
||||
return {name: float(value) for name, value in self.threshold_by_metric}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
dimension: Dimension
|
||||
status: Literal["CLEAR", "FINDING", "UNKNOWN"]
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if self.status not in ("CLEAR", "FINDING", "UNKNOWN"):
|
||||
raise ValueError("invalid finding status")
|
||||
_text(self.reason, "reason")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_safe_ref(ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditReport:
|
||||
report_id: str
|
||||
snapshot_id: str
|
||||
rules_revision: str
|
||||
findings: tuple[Finding, ...]
|
||||
mutation_count: Literal[0]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.report_id)
|
||||
validate_digest(self.snapshot_id)
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if not isinstance(self.findings, tuple) or any(
|
||||
not isinstance(item, Finding) for item in self.findings
|
||||
):
|
||||
raise ValueError("findings must be a tuple of Finding")
|
||||
if type(self.mutation_count) is not int or self.mutation_count != 0:
|
||||
raise ValueError("ARO Phase A mutation_count must be exactly zero")
|
||||
|
||||
|
||||
def _unknown(dimension: Dimension, reason: str, refs: tuple[str, ...] = ()) -> Finding:
|
||||
return Finding(dimension, "UNKNOWN", reason, refs)
|
||||
|
||||
|
||||
def _metric_values(item: DimensionInput) -> tuple[dict[str, float] | None, tuple[str, ...]]:
|
||||
expected = _METRICS[item.dimension]
|
||||
if not item.samples:
|
||||
return None, ()
|
||||
names = [sample.metric for sample in item.samples]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if set(names) != expected or len(names) != len(set(names)):
|
||||
return None, refs
|
||||
values = {sample.metric: float(sample.value) for sample in item.samples}
|
||||
for name in _COUNT_METRICS & expected:
|
||||
if not values[name].is_integer():
|
||||
return None, refs
|
||||
return values, refs
|
||||
|
||||
|
||||
def _evaluate_dimension(
|
||||
item: DimensionInput, thresholds: Mapping[str, float]
|
||||
) -> Finding:
|
||||
values, refs = _metric_values(item)
|
||||
if values is None:
|
||||
return _unknown(item.dimension, "METRICS_MISSING_DUPLICATE_OR_UNKNOWN", refs)
|
||||
dimension = item.dimension
|
||||
if dimension == Dimension.FUNCTION:
|
||||
required = values["required_checks"]
|
||||
passed = values["passed_checks"]
|
||||
if required <= 0 or passed > required:
|
||||
return _unknown(dimension, "FUNCTION_COUNTS_INVALID", refs)
|
||||
status = "FINDING" if passed < required else "CLEAR"
|
||||
return Finding(dimension, status, "REQUIRED_CHECKS_INCOMPLETE" if status == "FINDING" else "ALL_REQUIRED_CHECKS_PASSED", refs)
|
||||
if dimension == Dimension.PERFORMANCE:
|
||||
threshold = thresholds.get("max_duration_p95_ms")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["duration_p95_ms"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "P95_DURATION_EXCEEDED" if status == "FINDING" else "P95_DURATION_WITHIN_LIMIT", refs)
|
||||
if dimension == Dimension.EXCEPTION:
|
||||
status = "FINDING" if values["unhandled_exception_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "UNHANDLED_EXCEPTIONS_PRESENT" if status == "FINDING" else "NO_UNHANDLED_EXCEPTIONS", refs)
|
||||
if dimension == Dimension.USAGE:
|
||||
status = "FINDING" if any(value > 0 for value in values.values()) else "CLEAR"
|
||||
return Finding(dimension, status, "OPERATIONAL_PROMPT_OR_CONTINUE_PRESENT" if status == "FINDING" else "NO_OPERATIONAL_PROMPT_FRICTION", refs)
|
||||
if dimension == Dimension.CODE_DEFECT:
|
||||
status = "FINDING" if values["validated_open_defect_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "VALIDATED_OPEN_DEFECTS_PRESENT" if status == "FINDING" else "NO_VALIDATED_OPEN_DEFECTS", refs)
|
||||
if dimension == Dimension.HISTORY:
|
||||
threshold = thresholds.get("repeated_failure_threshold")
|
||||
if threshold is None or threshold <= 0 or not threshold.is_integer():
|
||||
return _unknown(dimension, "THRESHOLD_MISSING_OR_INVALID", refs)
|
||||
status = "FINDING" if values["same_fingerprint_failure_count"] >= threshold else "CLEAR"
|
||||
return Finding(dimension, status, "FAILURE_FINGERPRINT_REPEATED" if status == "FINDING" else "FAILURE_FINGERPRINT_BELOW_LIMIT", refs)
|
||||
if dimension == Dimension.DEADLOCK:
|
||||
threshold = thresholds.get("deadlock_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if (
|
||||
values["wait_cycle_count"] > 0
|
||||
and values["oldest_no_progress_seconds"] > threshold
|
||||
) else "CLEAR"
|
||||
return Finding(dimension, status, "WAIT_CYCLE_WITHOUT_PROGRESS" if status == "FINDING" else "NO_STALE_WAIT_CYCLE", refs)
|
||||
if dimension == Dimension.LONG_RUNNING:
|
||||
threshold = thresholds.get("max_nonterminal_age_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["oldest_nonterminal_age_seconds"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "NONTERMINAL_AGE_EXCEEDED" if status == "FINDING" else "NONTERMINAL_AGE_WITHIN_LIMIT", refs)
|
||||
raise AssertionError("unhandled dimension")
|
||||
|
||||
|
||||
def evaluate_snapshot(
|
||||
snapshot: AuditSnapshot, *, limits: RuleLimits, now: datetime
|
||||
) -> AuditReport:
|
||||
"""Evaluate all eight dimensions; missing or inconsistent evidence is UNKNOWN."""
|
||||
if not isinstance(snapshot, AuditSnapshot) or not isinstance(limits, RuleLimits):
|
||||
raise TypeError("snapshot and limits must be typed values")
|
||||
now = _aware(now, "now")
|
||||
grouped: dict[Dimension, list[DimensionInput]] = {dimension: [] for dimension in _DIMENSIONS}
|
||||
for item in snapshot.dimensions:
|
||||
grouped[item.dimension].append(item)
|
||||
|
||||
global_reason: str | None = None
|
||||
if snapshot.rules_revision != limits.rules_revision:
|
||||
global_reason = "RULES_REVISION_UNKNOWN"
|
||||
elif snapshot.window_end > now:
|
||||
global_reason = "AUDIT_WINDOW_NOT_CLOSED"
|
||||
else:
|
||||
observed = [sample.observed_at for item in snapshot.dimensions for sample in item.samples]
|
||||
if observed and (max(observed) - min(observed)).total_seconds() > limits.max_snapshot_skew_seconds:
|
||||
global_reason = "CROSS_DOMAIN_SNAPSHOT_SKEW"
|
||||
|
||||
findings: list[Finding] = []
|
||||
for dimension in _DIMENSIONS:
|
||||
items = grouped[dimension]
|
||||
if global_reason is not None:
|
||||
refs = tuple(
|
||||
dict.fromkeys(sample.evidence_ref for item in items for sample in item.samples)
|
||||
)
|
||||
findings.append(_unknown(dimension, global_reason, refs))
|
||||
continue
|
||||
if len(items) != 1:
|
||||
findings.append(
|
||||
_unknown(
|
||||
dimension,
|
||||
"DIMENSION_MISSING" if not items else "DIMENSION_CONFLICT",
|
||||
)
|
||||
)
|
||||
continue
|
||||
item = items[0]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if item.captured_at > now or now >= item.expires_at:
|
||||
findings.append(_unknown(dimension, "DIMENSION_STALE_OR_FUTURE", refs))
|
||||
continue
|
||||
if any(
|
||||
sample.observed_at < snapshot.window_start
|
||||
or sample.observed_at > snapshot.window_end
|
||||
for sample in item.samples
|
||||
):
|
||||
findings.append(_unknown(dimension, "SAMPLE_OUTSIDE_WINDOW", refs))
|
||||
continue
|
||||
findings.append(_evaluate_dimension(item, limits.thresholds))
|
||||
|
||||
report_payload = {
|
||||
"window_start": _canonical_datetime(snapshot.window_start),
|
||||
"window_end": _canonical_datetime(snapshot.window_end),
|
||||
"snapshot_id": snapshot.snapshot_id,
|
||||
"rules_revision": limits.rules_revision,
|
||||
}
|
||||
return AuditReport(
|
||||
report_id=_digest(report_payload),
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
rules_revision=limits.rules_revision,
|
||||
findings=tuple(findings),
|
||||
mutation_count=0,
|
||||
)
|
||||
|
||||
|
||||
class ReadonlyAuditSource(Protocol):
|
||||
def collect(
|
||||
self, *, window_start: datetime, window_end: datetime
|
||||
) -> AuditSnapshot: ...
|
||||
|
||||
|
||||
class AuditReportSink(Protocol):
|
||||
def put_if_absent(self, report: AuditReport) -> str: ...
|
||||
|
||||
|
||||
def _report_bytes(report: AuditReport) -> bytes:
|
||||
payload = {
|
||||
"schema": "aios.aro-readonly-report.v1",
|
||||
"report_id": report.report_id,
|
||||
"snapshot_id": report.snapshot_id,
|
||||
"rules_revision": report.rules_revision,
|
||||
"mutation_count": report.mutation_count,
|
||||
"findings": [
|
||||
{
|
||||
"dimension": finding.dimension.value,
|
||||
"status": finding.status,
|
||||
"reason": finding.reason,
|
||||
"evidence_refs": list(finding.evidence_refs),
|
||||
}
|
||||
for finding in report.findings
|
||||
],
|
||||
}
|
||||
return (
|
||||
json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class FilesystemAuditReportSink:
|
||||
"""Write one immutable, deterministic report under a host-fixed root."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not isinstance(root, Path) or not root.is_absolute() or ".." in root.parts:
|
||||
raise ValueError("audit root must be an absolute fixed path")
|
||||
self.root = root
|
||||
self._prepare_root()
|
||||
|
||||
def _prepare_root(self) -> None:
|
||||
existing = self.root
|
||||
while not existing.exists() and existing != existing.parent:
|
||||
existing = existing.parent
|
||||
cursor = existing
|
||||
while True:
|
||||
if cursor.is_symlink():
|
||||
raise ValueError("audit root ancestry must not contain symlinks")
|
||||
if cursor == cursor.parent:
|
||||
break
|
||||
cursor = cursor.parent
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("audit root must be a real directory")
|
||||
if self.root.resolve(strict=True) != self.root.absolute():
|
||||
raise ValueError("audit root must not traverse reparse points or symlinks")
|
||||
|
||||
def put_if_absent(self, report: AuditReport) -> str:
|
||||
if not isinstance(report, AuditReport):
|
||||
raise TypeError("report must be AuditReport")
|
||||
self._prepare_root()
|
||||
validate_digest(report.report_id)
|
||||
body = _report_bytes(report)
|
||||
target = self.root / f"{report.report_id}.json"
|
||||
if target.parent != self.root or target.is_symlink():
|
||||
raise ValueError("REPORT_PATH_INVALID")
|
||||
if target.exists():
|
||||
if not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
return f"aro:{report.report_id}"
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{report.report_id}.", suffix=".tmp", dir=self.root
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(file_descriptor, "wb") as handle:
|
||||
handle.write(body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
os.link(temporary, target)
|
||||
except FileExistsError:
|
||||
if target.is_symlink() or not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
if target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"aro:{report.report_id}"
|
||||
|
||||
|
||||
class AroReadonlyTick:
|
||||
"""Default-off in-process tick over the most recently closed UTC window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ReadonlyAuditSource,
|
||||
sink: AuditReportSink,
|
||||
limits: RuleLimits,
|
||||
interval_seconds: int,
|
||||
clock: Callable[[], datetime],
|
||||
*,
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
if not hasattr(source, "collect") or not callable(source.collect):
|
||||
raise ValueError("source must implement read-only collect")
|
||||
if not hasattr(sink, "put_if_absent") or not callable(sink.put_if_absent):
|
||||
raise ValueError("sink must implement put_if_absent")
|
||||
if not isinstance(limits, RuleLimits):
|
||||
raise ValueError("limits must be RuleLimits")
|
||||
if isinstance(interval_seconds, bool) or not isinstance(interval_seconds, int) or interval_seconds <= 0:
|
||||
raise ValueError("interval_seconds must be positive int")
|
||||
if not callable(clock) or type(enabled) is not bool:
|
||||
raise ValueError("clock must be callable and enabled must be bool")
|
||||
self.source = source
|
||||
self.sink = sink
|
||||
self.limits = limits
|
||||
self.interval_seconds = interval_seconds
|
||||
self.clock = clock
|
||||
self.enabled = enabled
|
||||
self._last_window_end: datetime | None = None
|
||||
|
||||
def run_due(self) -> str | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
now = _aware(self.clock(), "clock result").astimezone(timezone.utc)
|
||||
end_timestamp = int(now.timestamp()) // self.interval_seconds * self.interval_seconds
|
||||
window_end = datetime.fromtimestamp(end_timestamp, tz=timezone.utc)
|
||||
window_start = window_end - timedelta(seconds=self.interval_seconds)
|
||||
if self._last_window_end == window_end:
|
||||
return None
|
||||
try:
|
||||
snapshot = self.source.collect(
|
||||
window_start=window_start, window_end=window_end
|
||||
)
|
||||
if (
|
||||
not isinstance(snapshot, AuditSnapshot)
|
||||
or snapshot.window_start != window_start
|
||||
or snapshot.window_end != window_end
|
||||
):
|
||||
raise ValueError("source returned a snapshot for the wrong window")
|
||||
except Exception:
|
||||
snapshot = AuditSnapshot.create(
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=(),
|
||||
rules_revision=self.limits.rules_revision,
|
||||
)
|
||||
report = evaluate_snapshot(snapshot, limits=self.limits, now=now)
|
||||
try:
|
||||
reference = self.sink.put_if_absent(report)
|
||||
except Exception:
|
||||
return None
|
||||
self._last_window_end = window_end
|
||||
return reference
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AroReadonlyTick",
|
||||
"AuditReport",
|
||||
"AuditReportSink",
|
||||
"AuditSnapshot",
|
||||
"Dimension",
|
||||
"DimensionInput",
|
||||
"FilesystemAuditReportSink",
|
||||
"Finding",
|
||||
"ReadonlyAuditSource",
|
||||
"RuleLimits",
|
||||
"Sample",
|
||||
"evaluate_snapshot",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Narrow production seam from a validated C6 plan to existing acquisition."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from .authorization_planner import (
|
||||
AuthorizationPlan,
|
||||
AuthorizationPlanInput,
|
||||
CapabilityNamespace,
|
||||
GitManifestRegistry,
|
||||
RuntimeObservation,
|
||||
StaticCapabilityManifest,
|
||||
VerifiedRootSnapshot,
|
||||
VerifiedRootSnapshotReader,
|
||||
authorization_input_digest,
|
||||
authorization_scope_digest,
|
||||
build_authorization_plan,
|
||||
verified_root_snapshot_digest,
|
||||
)
|
||||
from .governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
RecoveryAuthorization,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeObservationReader(Protocol):
|
||||
"""Host-owned fresh runtime authority used at writer acquisition."""
|
||||
|
||||
def read_runtime_observation(
|
||||
self,
|
||||
*,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
manifest: StaticCapabilityManifest,
|
||||
root: VerifiedRootSnapshot | None,
|
||||
now: float,
|
||||
) -> RuntimeObservation | None: ...
|
||||
|
||||
|
||||
def acquire_authorized_writer(
|
||||
*,
|
||||
automation: GovernedWriterAutomation,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
plan: AuthorizationPlan,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
root_reader: VerifiedRootSnapshotReader,
|
||||
runtime_reader: RuntimeObservationReader,
|
||||
registry: GitManifestRegistry,
|
||||
expected_registry_oid: str,
|
||||
expected_manifest_sha256: str,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
) -> GovernedWriterSession:
|
||||
"""Re-read existing verified Root immediately before `acquire_verified`.
|
||||
|
||||
This function deliberately has no signer, claim, lease, or direct
|
||||
`automation.acquire` path. The existing automation remains the sole
|
||||
composition root for effects.
|
||||
"""
|
||||
supplied_now = time.time() if now is None else now
|
||||
if isinstance(supplied_now, bool):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
try:
|
||||
current = float(supplied_now)
|
||||
except (TypeError, ValueError):
|
||||
raise AuthorizationDenied("TIME_INVALID") from None
|
||||
if not math.isfinite(current):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
input_digest = authorization_input_digest(plan_input)
|
||||
scope_digest = authorization_scope_digest(plan_input.scope)
|
||||
if (plan_input.root_approval_ref != authorization_ref
|
||||
or plan.input_digest != input_digest
|
||||
or plan.scope_digest != scope_digest):
|
||||
raise AuthorizationDenied("PLAN_INPUT_DRIFT")
|
||||
if (request.subject != plan_input.actor_subject or request.task_id != plan_input.task_id
|
||||
or request.repository != plan_input.scope.repository
|
||||
or request.target_branch != plan_input.scope.target_branch
|
||||
or request.base_oid != plan_input.scope.source_oid
|
||||
or request.risk != plan_input.scope.risk_level
|
||||
or request.file_allowlist != plan_input.scope.file_allowlist
|
||||
or request.plan_digest != input_digest):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_PLAN_DRIFT")
|
||||
if any(ref.namespace != CapabilityNamespace.AGENT_TASK for ref in plan_input.required_capabilities):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_NAMESPACE_INVALID")
|
||||
requested_capabilities = frozenset(ref.token for ref in plan_input.required_capabilities)
|
||||
if request.required_capabilities != requested_capabilities:
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_DRIFT")
|
||||
# This is a second, explicit frozen-runtime read. The existing
|
||||
# acquire_verified path repeats it before effects as its own invariant.
|
||||
automation.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
manifest = registry.read_manifest(
|
||||
expected_registry_oid=expected_registry_oid,
|
||||
expected_manifest_sha256=expected_manifest_sha256,
|
||||
)
|
||||
if manifest.manifest_id != request.manifest_id or manifest.declaration_digest != plan.policy_digest:
|
||||
raise AuthorizationDenied("POLICY_MANIFEST_DRIFT")
|
||||
root = root_reader.read_verified_root(
|
||||
authorization_ref=authorization_ref,
|
||||
root_request_sha256=plan_input.root_request_sha256,
|
||||
now=current,
|
||||
)
|
||||
if root is None or root.status != "VALID" or root.approval_ref != authorization_ref:
|
||||
raise AuthorizationDenied("ROOT_REVOKED_OR_UNAVAILABLE")
|
||||
if (root.root_request_sha256 != plan_input.root_request_sha256
|
||||
or root.subject != plan_input.actor_subject
|
||||
or root.audience != plan_input.audience
|
||||
or root.mission_id != plan_input.mission_id
|
||||
or root.scope != plan_input.scope):
|
||||
raise AuthorizationDenied("ROOT_SCOPE_DRIFT")
|
||||
if verified_root_snapshot_digest(root) != plan.root_snapshot_digest:
|
||||
raise AuthorizationDenied("ROOT_SNAPSHOT_DRIFT")
|
||||
if not (root.not_before <= current and root.observed_at <= current < root.snapshot_expires_at <= root.expires_at):
|
||||
raise AuthorizationDenied("ROOT_EXPIRED")
|
||||
reader = getattr(runtime_reader, "read_runtime_observation", None)
|
||||
if not callable(reader):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
runtime = reader(
|
||||
plan_input=plan_input,
|
||||
manifest=manifest,
|
||||
root=root,
|
||||
now=current,
|
||||
)
|
||||
except Exception:
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE") from None
|
||||
if not isinstance(runtime, RuntimeObservation):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
fresh_input = replace(plan_input, now=current)
|
||||
fresh_plan = build_authorization_plan(
|
||||
request=fresh_input,
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
root=root,
|
||||
)
|
||||
if fresh_plan.next_action != "EXECUTE_GOVERNED_PLAN":
|
||||
raise AuthorizationDenied(
|
||||
fresh_plan.reason_code or "authorization plan is not executable"
|
||||
)
|
||||
if (
|
||||
plan.policy_digest != fresh_plan.policy_digest
|
||||
or plan.required_authorization != fresh_plan.required_authorization
|
||||
or plan.scope_digest != fresh_plan.scope_digest
|
||||
or plan.root_snapshot_digest != fresh_plan.root_snapshot_digest
|
||||
):
|
||||
raise AuthorizationDenied("AUTHORIZATION_PLAN_DRIFT")
|
||||
# `acquire_verified` repeats the existing Owner authority verification
|
||||
# before its own effect path. Do not substitute a locally reconstructed
|
||||
# policy or call its test-only `acquire` method here.
|
||||
return automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=current,
|
||||
)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Read-only C6 authorization planning over existing authority boundaries."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Literal, Mapping, Protocol
|
||||
|
||||
from aios_capability_contract import normalize_required_capabilities
|
||||
from .governed_writer_automation import RISK_ORDER
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_REGISTRY_PATH = "governance/capabilities/registry.v1.json"
|
||||
|
||||
|
||||
class RegistryReadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CapabilityNamespace(str, Enum):
|
||||
AGENT_TASK = "agent-task"
|
||||
TASK_PLANE_WILDCARD = "task-plane-wildcard"
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _OID.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _digest(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _SHA.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _finite(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(float(value)):
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def authorization_scope_digest(scope: "ScopeSpec") -> str:
|
||||
return hashlib.sha256(_canonical({
|
||||
"repository": scope.repository, "target_branch": scope.target_branch,
|
||||
"source_oid": scope.source_oid, "file_allowlist": list(scope.file_allowlist),
|
||||
"budget_units": scope.budget_units, "risk_level": scope.risk_level,
|
||||
})).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRef:
|
||||
namespace: CapabilityNamespace
|
||||
token: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("CAPABILITY_NAMESPACE_INVALID")
|
||||
_text(self.token, "capability")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeSpec:
|
||||
repository: str
|
||||
target_branch: str
|
||||
source_oid: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
budget_units: int
|
||||
risk_level: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository"); _text(self.target_branch, "target_branch")
|
||||
_oid(self.source_oid, "source_oid"); _text(self.risk_level, "risk_level")
|
||||
if type(self.budget_units) is not int or self.budget_units < 0:
|
||||
raise ValueError("budget_units_INVALID")
|
||||
if not self.file_allowlist or len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
for path in self.file_allowlist:
|
||||
if not isinstance(path, str) or not path or path != PurePosixPath(path).as_posix() or PurePosixPath(path).is_absolute() or ".." in PurePosixPath(path).parts:
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeObservation:
|
||||
observation_id: str; observed_at: float; expires_at: float; source_revision: str
|
||||
actor_subject: str; audience: str; operation_id: str; scope_digest: str
|
||||
manifest_revision: str; manifest_digest: str; executor_installed: bool | None
|
||||
executor_callable: bool | None; authorization_live: bool | None
|
||||
preconditions_pass: bool | None; revocation_epoch: int | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("observation_id", "source_revision", "actor_subject", "audience", "operation_id", "manifest_revision"):
|
||||
_text(getattr(self, name), name)
|
||||
_finite(self.observed_at, "observed_at"); _finite(self.expires_at, "expires_at")
|
||||
if self.expires_at <= self.observed_at: raise ValueError("OBSERVATION_EXPIRY_INVALID")
|
||||
_digest(self.scope_digest, "scope_digest"); _digest(self.manifest_digest, "manifest_digest")
|
||||
for name in ("executor_installed", "executor_callable", "authorization_live", "preconditions_pass"):
|
||||
if getattr(self, name) is not None and type(getattr(self, name)) is not bool: raise ValueError(f"{name}_INVALID")
|
||||
if self.revocation_epoch is not None and (type(self.revocation_epoch) is not int or self.revocation_epoch < 0): raise ValueError("revocation_epoch_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedRootSnapshot:
|
||||
approval_ref: str; root_request_sha256: str; subject: str; audience: str; mission_id: str
|
||||
scope: ScopeSpec; capabilities: tuple[CapabilityRef, ...]; nonce: str; not_before: float
|
||||
expires_at: float; revocation_epoch: int; receipt_ref: str; observed_at: float
|
||||
snapshot_expires_at: float; status: Literal["VALID", "REVOKED"]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("approval_ref", "subject", "audience", "mission_id", "nonce", "receipt_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256")
|
||||
for name in ("not_before", "expires_at", "observed_at", "snapshot_expires_at"): _finite(getattr(self, name), name)
|
||||
if not self.not_before <= self.snapshot_expires_at <= self.expires_at: raise ValueError("ROOT_TIME_INVALID")
|
||||
if type(self.revocation_epoch) is not int or self.revocation_epoch < 0: raise ValueError("revocation_epoch_INVALID")
|
||||
if self.status not in ("VALID", "REVOKED"): raise ValueError("ROOT_STATUS_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticCapabilityManifest:
|
||||
manifest_id: str; revision: str; capabilities: tuple[CapabilityRef, ...]
|
||||
executor_refs: tuple[str, ...]; source_oid: str; declaration_digest: str
|
||||
allowed_scopes: tuple[ScopeSpec, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.manifest_id, "manifest_id"); _text(self.revision, "revision")
|
||||
_oid(self.source_oid, "source_oid"); _digest(self.declaration_digest, "declaration_digest")
|
||||
if len(set(self.capabilities)) != len(self.capabilities): raise ValueError("CAPABILITY_DUPLICATE")
|
||||
for ref in self.executor_refs: _text(ref, "executor_ref")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlanInput:
|
||||
operation_id: str; actor_subject: str; audience: str; mission_id: str; task_id: str
|
||||
scope: ScopeSpec; required_capabilities: tuple[CapabilityRef, ...]
|
||||
root_approval_ref: str; root_request_sha256: str; now: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("operation_id", "actor_subject", "audience", "mission_id", "task_id", "root_approval_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256"); _finite(self.now, "now")
|
||||
if not self.required_capabilities: raise ValueError("REQUIRED_CAPABILITIES_EMPTY")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlan:
|
||||
input_digest: str; scope_digest: str; policy_digest: str; required_authorization: str
|
||||
root_snapshot_digest: str | None
|
||||
reason_code: str | None; next_action: Literal["EXECUTE_GOVERNED_PLAN", "WAITING_ROOT_AUTHORIZATION", "AUTHORIZATION_REQUIRED", "CAPABILITY_UNAVAILABLE", "PRECONDITION_FAILED", "UNKNOWN"]
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
class GitManifestRegistry(Protocol):
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest: ...
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None: ...
|
||||
|
||||
|
||||
class VerifiedRootSnapshotReader(Protocol):
|
||||
def read_verified_root(self, *, authorization_ref: str, root_request_sha256: str, now: float) -> VerifiedRootSnapshot | None: ...
|
||||
|
||||
|
||||
def verified_root_snapshot_digest(root: VerifiedRootSnapshot | None) -> str | None:
|
||||
return None if root is None else hashlib.sha256(_canonical(asdict(root))).hexdigest()
|
||||
|
||||
|
||||
def authorization_input_digest(request: AuthorizationPlanInput) -> str:
|
||||
return hashlib.sha256(_canonical(asdict(request))).hexdigest()
|
||||
|
||||
|
||||
def _plan(request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, action: str, reason: str | None, refs: tuple[str, ...], root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
return AuthorizationPlan(
|
||||
input_digest=authorization_input_digest(request), scope_digest=authorization_scope_digest(request.scope),
|
||||
policy_digest=manifest.declaration_digest, required_authorization=request.root_approval_ref,
|
||||
root_snapshot_digest=verified_root_snapshot_digest(root),
|
||||
reason_code=reason, next_action=action, evidence_refs=refs,
|
||||
)
|
||||
|
||||
|
||||
def _capabilities_cover(granted: tuple[CapabilityRef, ...], required: tuple[CapabilityRef, ...]) -> tuple[bool, str | None]:
|
||||
for capability in required:
|
||||
if capability.namespace == CapabilityNamespace.AGENT_TASK:
|
||||
if "*" in capability.token:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
normalized = normalize_required_capabilities([capability.token])
|
||||
if any(value.startswith("exact:") for value in normalized): return False, "CAPABILITY_UNKNOWN"
|
||||
same_namespace = [item.token for item in granted if item.namespace == CapabilityNamespace.AGENT_TASK]
|
||||
if not same_namespace and granted:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
effective_grants = normalize_required_capabilities(same_namespace)
|
||||
if any(value.startswith("exact:") for value in effective_grants):
|
||||
effective_grants = frozenset(value for value in effective_grants if not value.startswith("exact:"))
|
||||
if not normalized.issubset(effective_grants): return False, "CAPABILITY_UNAVAILABLE"
|
||||
elif capability.namespace == CapabilityNamespace.TASK_PLANE_WILDCARD:
|
||||
if not any(item.namespace == capability.namespace and item.token == capability.token for item in granted): return False, "CAPABILITY_UNAVAILABLE"
|
||||
return True, None
|
||||
|
||||
|
||||
def _scope_covers(parent: ScopeSpec, child: ScopeSpec) -> bool:
|
||||
# Use the same risk vocabulary as the authority that will issue the lease.
|
||||
rank = RISK_ORDER
|
||||
return (parent.repository == child.repository and parent.target_branch == child.target_branch and parent.source_oid == child.source_oid
|
||||
and set(child.file_allowlist).issubset(parent.file_allowlist) and child.budget_units <= parent.budget_units
|
||||
and rank.get(child.risk_level.lower()) is not None
|
||||
and rank.get(parent.risk_level.lower(), 0) >= rank[child.risk_level.lower()])
|
||||
|
||||
|
||||
def build_authorization_plan(*, request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, runtime: RuntimeObservation, root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
refs = () if root is None else (root.receipt_ref,)
|
||||
if manifest.source_oid != request.scope.source_oid or not any(_scope_covers(scope, request.scope) for scope in manifest.allowed_scopes):
|
||||
return _plan(request, manifest, "UNKNOWN", "REGISTRY_DRIFT", refs, root)
|
||||
allowed, reason = _capabilities_cover(manifest.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "CAPABILITY_UNAVAILABLE", reason, refs, root)
|
||||
if root is None: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_MISSING", refs, root)
|
||||
if root.status == "REVOKED": return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_REVOKED", refs, root)
|
||||
if root.approval_ref != request.root_approval_ref or root.root_request_sha256 != request.root_request_sha256: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_DIGEST_MISMATCH", refs, root)
|
||||
if root.subject != request.actor_subject or root.audience != request.audience: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SUBJECT_AUDIENCE_MISMATCH", refs, root)
|
||||
if root.mission_id != request.mission_id or not _scope_covers(root.scope, request.scope): return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SCOPE_EXCEEDED", refs, root)
|
||||
allowed, reason = _capabilities_cover(root.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", reason, refs, root)
|
||||
if not (root.observed_at <= request.now < root.snapshot_expires_at <= root.expires_at) or request.now < root.not_before: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_EXPIRED", refs, root)
|
||||
if runtime.expires_at <= request.now or runtime.observed_at > request.now: return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if runtime.revocation_epoch is None or runtime.revocation_epoch != root.revocation_epoch: return _plan(request, manifest, "UNKNOWN", "EPOCH_REVOKED", refs, root)
|
||||
if (runtime.actor_subject != request.actor_subject or runtime.audience != request.audience or runtime.operation_id != request.operation_id
|
||||
or runtime.scope_digest != authorization_scope_digest(request.scope) or runtime.manifest_revision != manifest.revision
|
||||
or runtime.manifest_digest != manifest.declaration_digest): return _plan(request, manifest, "UNKNOWN", "PLAN_INPUT_DRIFT", refs, root)
|
||||
if any(value is None for value in (runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if not all((runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "PRECONDITION_FAILED", "PRECONDITION_FAILED", refs, root)
|
||||
return _plan(request, manifest, "EXECUTE_GOVERNED_PLAN", None, refs, root)
|
||||
|
||||
|
||||
class GitManifestRegistryReader:
|
||||
"""Fixed-path, immutable Git blob reader; intentionally has no write API."""
|
||||
def __init__(self, *, configured_repo_root: Path, manifest_id: str) -> None:
|
||||
self.root = Path(configured_repo_root); self.manifest_id = _text(manifest_id, "manifest_id")
|
||||
self._last_registry: Mapping[str, Any] | None = None
|
||||
|
||||
def _blob(self, oid: str) -> bytes:
|
||||
_oid(oid, "expected_registry_oid")
|
||||
if self.root.is_symlink() or not self.root.is_dir(): raise RegistryReadError("REGISTRY_ROOT_INVALID")
|
||||
result = subprocess.run(["git", "-C", str(self.root), "show", f"{oid}:{_REGISTRY_PATH}"], capture_output=True, timeout=15)
|
||||
if result.returncode: raise RegistryReadError("REGISTRY_UNAVAILABLE")
|
||||
return result.stdout
|
||||
|
||||
def _registry(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> Mapping[str, Any]:
|
||||
raw = self._blob(expected_registry_oid)
|
||||
if hashlib.sha256(raw).hexdigest() != _digest(expected_manifest_sha256, "expected_manifest_sha256"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try: value = json.loads(raw)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "records", "supersessions"} or value["schema"] != "aios.capability-registry.v1": raise RegistryReadError("REGISTRY_INVALID")
|
||||
return value
|
||||
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest:
|
||||
registry = self._registry(expected_registry_oid=expected_registry_oid, expected_manifest_sha256=expected_manifest_sha256)
|
||||
records = registry["records"]
|
||||
if not isinstance(records, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
supersessions = registry["supersessions"]
|
||||
if not isinstance(supersessions, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
record_index: dict[tuple[str, str], Mapping[str, Any]] = {}
|
||||
for record in records:
|
||||
if not isinstance(record, Mapping) or not isinstance(record.get("manifest_id"), str) or not isinstance(record.get("revision"), str):
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
key = (record["manifest_id"], record["revision"])
|
||||
if key in record_index: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record_index[key] = record
|
||||
replaced: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
for item in supersessions:
|
||||
if not isinstance(item, Mapping) or set(item) != {"manifest_id", "prior_revision", "new_revision", "evidence_ref"}:
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
try:
|
||||
manifest_id = _text(item["manifest_id"], "manifest_id")
|
||||
prior = _text(item["prior_revision"], "prior_revision")
|
||||
newer = _text(item["new_revision"], "new_revision")
|
||||
_text(item["evidence_ref"], "evidence_ref")
|
||||
except ValueError as exc:
|
||||
raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
prior_key, new_key = (manifest_id, prior), (manifest_id, newer)
|
||||
if prior_key not in record_index or new_key not in record_index or prior_key == new_key or prior_key in replaced:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
replaced[prior_key] = new_key
|
||||
candidates = [record for record in records if isinstance(record, Mapping) and record.get("manifest_id") == self.manifest_id]
|
||||
if not candidates: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
current = [record for record in candidates if (self.manifest_id, record.get("revision")) not in replaced]
|
||||
if len(current) != 1: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record = current[0]; required = {"manifest_id", "revision", "capabilities", "executor_refs", "source_oid", "declaration_digest", "allowed_scopes"}
|
||||
if set(record) != required: raise RegistryReadError("REGISTRY_INVALID")
|
||||
unsigned = {key: value for key, value in record.items() if key != "declaration_digest"}
|
||||
if hashlib.sha256(_canonical(unsigned)).hexdigest() != _digest(record["declaration_digest"], "declaration_digest"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try:
|
||||
manifest = StaticCapabilityManifest(str(record["manifest_id"]), str(record["revision"]), tuple(CapabilityRef(CapabilityNamespace(item["namespace"]), item["token"]) for item in record["capabilities"]), tuple(record["executor_refs"]), str(record["source_oid"]), str(record["declaration_digest"]), tuple(ScopeSpec(**item) for item in record["allowed_scopes"]))
|
||||
except (KeyError, TypeError, ValueError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
self._last_registry = registry
|
||||
return manifest
|
||||
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None:
|
||||
_text(manifest_id, "manifest_id"); _text(revision, "revision")
|
||||
if self._last_registry is None:
|
||||
raise RegistryReadError("REGISTRY_READ_REQUIRES_PINNED_DIGEST")
|
||||
matches = [
|
||||
item["new_revision"] for item in self._last_registry["supersessions"]
|
||||
if item["manifest_id"] == manifest_id and item["prior_revision"] == revision
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
return matches[0] if matches else None
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Explicit C6 composition in the existing TaskPlane host, default disabled.
|
||||
|
||||
All authorities are host-owned dependencies. No request can register an
|
||||
executor, construct a worker identity, or turn an observation into authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from typing import Callable, Literal, Protocol
|
||||
from aios_capability_contract import has_capabilities as formal_has_capabilities, normalize_required_capabilities
|
||||
|
||||
from .aro_readonly import AroReadonlyTick
|
||||
from .authorization_planner import CapabilityNamespace, CapabilityRef, GitManifestRegistryReader, authorization_scope_digest
|
||||
from .auto_claim import AutoClaimCoordinator, DiscoveryResult
|
||||
from .errors import ForbiddenError
|
||||
from .operation_readback import OperationReaders, collect_closure, collect_observation
|
||||
from .operation_view import (ClosureObservation, OperationBinding, PreflightResult, ClosureResult,
|
||||
binding_digest, derive_closure, derive_preflight, reconcile_effect, validate_digest, validate_oid)
|
||||
from .schedulability import MemoryProofRepository, SchedulabilityService
|
||||
from .schedulability_store_adapter import (TaskPlaneSchedulabilityAuthority,
|
||||
TaskPlaneClaimReceiptV3, ScopedLeaseController, validate_task_plane_receipt)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedWorker:
|
||||
worker_id: str
|
||||
actor_identity: str
|
||||
authority_receipt_ref: str
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.worker_id, self.actor_identity, self.authority_receipt_ref)):
|
||||
raise ValueError("authenticated worker receipt required")
|
||||
if not isinstance(self.expires_at, datetime) or self.expires_at.tzinfo is None:
|
||||
raise ValueError("worker expiry must be timezone aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationCapabilityBinding:
|
||||
operation_id: str
|
||||
namespace: CapabilityNamespace
|
||||
capability_id: str
|
||||
registry_oid: str
|
||||
manifest_id: str
|
||||
manifest_revision: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_oid(self.registry_oid)
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("explicit capability namespace required")
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.operation_id, self.capability_id, self.manifest_id, self.manifest_revision)):
|
||||
raise ValueError("operation capability identity required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableExecutionIntent:
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
claim_id: str
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_digest(self.binding_digest)
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.effect_key, self.claim_id, self.receipt_ref)):
|
||||
raise ValueError("exact durable execution intent required")
|
||||
|
||||
|
||||
class GovernedSourcePatchPilot(Protocol):
|
||||
"""Existing governed writer/executor adapter, never a general shell port.
|
||||
|
||||
Implementation must revalidate Root, revocation, scope, generation and
|
||||
WriterLease/CAS at write linearization, and persist intent before effect.
|
||||
Readback uses the same immutable effect key through OperationReaders.
|
||||
"""
|
||||
def prepare_intent(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3) -> DurableExecutionIntent: ...
|
||||
def execute(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
def reconcile(self, *, binding: OperationBinding,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6Bindings:
|
||||
claim_authority: TaskPlaneSchedulabilityAuthority
|
||||
registry_reader: GitManifestRegistryReader
|
||||
registry_oid: str
|
||||
registry_digest: str
|
||||
operation_capabilities: tuple[OperationCapabilityBinding, ...]
|
||||
operation_readers: OperationReaders
|
||||
worker_provider: Callable[[], AuthenticatedWorker | None]
|
||||
operation_provider: Callable[[str, AuthenticatedWorker], OperationBinding]
|
||||
pilot: GovernedSourcePatchPilot | None = None
|
||||
aro_tick: AroReadonlyTick | None = None
|
||||
enabled: bool = False
|
||||
request_worker_provider: Callable[[str, str], AuthenticatedWorker | None] | None = None
|
||||
# Readers are injected from the host's existing durable intent and worker
|
||||
# authority. A provider error is UNKNOWN, never an absent record.
|
||||
intent_reader: Callable[[OperationBinding], DurableExecutionIntent | None] | None = None
|
||||
pending_intent_provider: Callable[
|
||||
[], tuple[tuple[OperationBinding, DurableExecutionIntent], ...]
|
||||
] | None = None
|
||||
pending_intent_completion: Callable[
|
||||
[OperationBinding, DurableExecutionIntent, ClosureObservation], None
|
||||
] | None = None
|
||||
worker_authority_verifier: Callable[[AuthenticatedWorker, OperationBinding], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6TickResult:
|
||||
status: Literal["DISABLED", "NO_CANDIDATES", "CLAIMED", "OBSERVED", "BLOCKED", "UNKNOWN"]
|
||||
reason_code: str
|
||||
discovery: DiscoveryResult | None = None
|
||||
claim: TaskPlaneClaimReceiptV3 | None = None
|
||||
preflight: PreflightResult | None = None
|
||||
closure: ClosureResult | None = None
|
||||
audit_report_ref: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class C6Runtime:
|
||||
def __init__(self, service, bindings: C6Bindings, *, clock=None):
|
||||
if not isinstance(bindings, C6Bindings) or type(bindings.enabled) is not bool:
|
||||
raise ValueError("explicit C6 bindings required")
|
||||
if not isinstance(bindings.claim_authority, TaskPlaneSchedulabilityAuthority) or bindings.claim_authority.store is not service.store:
|
||||
raise ValueError("C6 must share the host's existing TaskPlane authority")
|
||||
if not callable(bindings.worker_provider) or not callable(bindings.operation_provider):
|
||||
raise ValueError("host authenticated worker and operation providers required")
|
||||
if bindings.enabled and not service.policy.execution_enabled:
|
||||
raise ValueError("execution policy and explicit trust must be enabled first")
|
||||
if bindings.pilot is not None and not all(callable(getattr(bindings.pilot, method, None)) for method in ("prepare_intent", "execute", "reconcile")):
|
||||
raise ValueError("governed source patch pilot required")
|
||||
if bindings.enabled and bindings.pilot is not None and not all(
|
||||
callable(value)
|
||||
for value in (
|
||||
bindings.intent_reader,
|
||||
bindings.pending_intent_provider,
|
||||
bindings.pending_intent_completion,
|
||||
bindings.worker_authority_verifier,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"enabled C6 pilot requires intent, recovery, and worker authority providers"
|
||||
)
|
||||
validate_oid(bindings.registry_oid); validate_digest(bindings.registry_digest)
|
||||
if not isinstance(bindings.operation_capabilities, tuple) or not bindings.operation_capabilities:
|
||||
raise ValueError("immutable operation capability map required")
|
||||
self.service, self.bindings = service, bindings
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
self._pending: OperationBinding | None = None
|
||||
self._validate_map()
|
||||
self.schedulability = SchedulabilityService(bindings.claim_authority,
|
||||
MemoryProofRepository(), clock=lambda: self.clock().timestamp(),
|
||||
capability_matcher=bindings.claim_authority.capability_matcher)
|
||||
self.coordinator = AutoClaimCoordinator(self.schedulability)
|
||||
self.lease_controller = ScopedLeaseController(service.store, bindings.claim_authority.binding)
|
||||
|
||||
def _validate_map(self):
|
||||
manifest = self.bindings.registry_reader.read_manifest(
|
||||
expected_registry_oid=self.bindings.registry_oid,
|
||||
expected_manifest_sha256=self.bindings.registry_digest)
|
||||
seen = set()
|
||||
for item in self.bindings.operation_capabilities:
|
||||
key = (item.operation_id, item.namespace)
|
||||
if key in seen or item.registry_oid != self.bindings.registry_oid or item.manifest_id != manifest.manifest_id or item.manifest_revision != manifest.revision:
|
||||
raise ValueError("operation capability map revision conflict")
|
||||
if CapabilityRef(item.namespace, item.capability_id) not in manifest.capabilities:
|
||||
raise ValueError("operation capability not declared in authoritative manifest")
|
||||
if item.namespace == CapabilityNamespace.AGENT_TASK and any(
|
||||
value.startswith("exact:") for value in normalize_required_capabilities([item.capability_id])):
|
||||
raise ValueError("unknown formal operation capability")
|
||||
seen.add(key)
|
||||
return manifest
|
||||
|
||||
def _worker(self, worker=None):
|
||||
worker = self.bindings.worker_provider() if worker is None else worker
|
||||
if not isinstance(worker, AuthenticatedWorker) or worker.expires_at <= self.clock():
|
||||
raise ForbiddenError("fresh authenticated worker required")
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if registered.identity != worker.actor_identity or registered.disabled:
|
||||
raise ForbiddenError("registered worker identity mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity)
|
||||
return worker
|
||||
|
||||
def _prepare(self, task_id, worker):
|
||||
manifest = self._validate_map()
|
||||
binding = self.bindings.operation_provider(task_id, worker)
|
||||
if not isinstance(binding, OperationBinding) or binding.task_id != task_id or binding.actor_id != worker.actor_identity or binding.source_oid != manifest.source_oid:
|
||||
raise ForbiddenError("operation identity mismatch")
|
||||
if binding.operation_id != "governed_source_patch_apply":
|
||||
raise ForbiddenError("operation outside C6 pilot")
|
||||
task = self.service.store.get_task(task_id)
|
||||
metadata = task.task_plan.metadata
|
||||
if (metadata.get("c6_operation_binding_digest") != binding_digest(binding)
|
||||
or metadata.get("generation") != binding.generation
|
||||
or binding.policy_digest != manifest.declaration_digest
|
||||
or binding.scope_digest not in {authorization_scope_digest(scope) for scope in manifest.allowed_scopes}):
|
||||
raise ForbiddenError("operation scope or immutable TaskPlan binding mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity, task=task)
|
||||
required = task.task_plan.required_capabilities
|
||||
if binding.capability_namespace == CapabilityNamespace.AGENT_TASK.value:
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if (not formal_has_capabilities(registered.capabilities, [binding.capability_id])
|
||||
or not normalize_required_capabilities([binding.capability_id]).issubset(
|
||||
normalize_required_capabilities(required))):
|
||||
raise ForbiddenError("formal capability cannot use wildcard worker authority")
|
||||
elif binding.capability_id not in required:
|
||||
raise ForbiddenError("operation capability absent from TaskPlan")
|
||||
verifier = self.bindings.worker_authority_verifier
|
||||
if verifier is None or verifier(worker, binding) is not True:
|
||||
raise ForbiddenError("worker receipt not bound to operation authority")
|
||||
if not any(item.operation_id == binding.operation_id and item.namespace.value == binding.capability_namespace and item.capability_id == binding.capability_id for item in self.bindings.operation_capabilities):
|
||||
raise ForbiddenError("operation capability namespace mismatch")
|
||||
preflight = derive_preflight(collect_observation(binding, self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return binding, preflight
|
||||
|
||||
def _intent(self, binding):
|
||||
reader = self.bindings.intent_reader
|
||||
if reader is None:
|
||||
raise ForbiddenError("durable execution intent reader required")
|
||||
intent = reader(binding)
|
||||
if intent is not None and (not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key):
|
||||
raise ForbiddenError("durable execution intent binding mismatch")
|
||||
return intent
|
||||
|
||||
def _durable_pending(self):
|
||||
provider = self.bindings.pending_intent_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("durable pending intent provider required")
|
||||
values = provider()
|
||||
if not isinstance(values, tuple) or len(values) > 16:
|
||||
raise ForbiddenError("bounded durable pending intent enumeration required")
|
||||
result = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if not isinstance(value, tuple) or len(value) != 2:
|
||||
raise ForbiddenError("durable pending intent entry is invalid")
|
||||
binding, intent = value
|
||||
if (
|
||||
not isinstance(binding, OperationBinding)
|
||||
or not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or binding.effect_key in seen
|
||||
):
|
||||
raise ForbiddenError("durable pending intent binding mismatch")
|
||||
seen.add(binding.effect_key)
|
||||
result.append((binding, intent))
|
||||
return tuple(result)
|
||||
|
||||
def _reconcile_pending(self, binding, intent, worker):
|
||||
current, _preflight = self._prepare(binding.task_id, worker)
|
||||
if current != binding or self._intent(binding) != intent:
|
||||
raise ForbiddenError("durable pending authority changed")
|
||||
assert self.bindings.pilot is not None
|
||||
try:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
observation = collect_closure(
|
||||
binding, self.bindings.operation_readers, now=self.clock()
|
||||
)
|
||||
return observation, derive_closure(binding, observation, now=self.clock())
|
||||
|
||||
def renew_claim(self, *, task_id, claim_id, actor_identity, receipt, lease_seconds):
|
||||
if self._closed or not self.bindings.enabled or not isinstance(receipt, dict):
|
||||
raise ForbiddenError("C6 v3 claim receipt required")
|
||||
if receipt.get("task_id") != task_id or receipt.get("claim_id") != claim_id:
|
||||
raise ForbiddenError("C6 claim receipt identity mismatch")
|
||||
provider = self.bindings.request_worker_provider
|
||||
worker_id = receipt.get("worker_id")
|
||||
candidate = provider(worker_id, actor_identity) if provider is not None else None
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker required")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("request worker identity mismatch")
|
||||
self._prepare(task_id, worker)
|
||||
return self.lease_controller.renew(receipt, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
|
||||
def claim_task(self, *, task_id: str, worker_id: str, actor_identity: str, lease_seconds: int = 30):
|
||||
"""Manual and automatic host claims share this exact authority seam."""
|
||||
if self._closed or not self.bindings.enabled or self.bindings.pilot is None:
|
||||
raise ForbiddenError("C6 claim binding disabled or unavailable")
|
||||
provider = self.bindings.request_worker_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("authenticated request worker provider required")
|
||||
candidate = provider(worker_id, actor_identity)
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker unavailable")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("worker provider binding mismatch")
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
return self._claim_prepared(binding, preflight, worker, lease_seconds=lease_seconds)
|
||||
|
||||
def _claim_prepared(self, binding, preflight, worker, *, lease_seconds=30):
|
||||
task_id, worker_id, actor_identity = binding.task_id, worker.worker_id, worker.actor_identity
|
||||
if preflight.state != "READY":
|
||||
raise ForbiddenError("C6 preflight: " + preflight.reason_code)
|
||||
if self._intent(binding) is not None:
|
||||
raise ForbiddenError("existing execution intent requires readback")
|
||||
closure = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
if reconcile_effect(closure.effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY":
|
||||
raise ForbiddenError("C6 effect must be authoritatively absent before claim")
|
||||
proof = self.schedulability.preflight(task_id=task_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, persist=True)
|
||||
if proof.observation.scope_digest != binding.scope_digest:
|
||||
raise ForbiddenError("claim authority scope differs from operation scope")
|
||||
result = self.coordinator.claim(proof_id=proof.proof_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
parsed = validate_task_plane_receipt(result, proof=proof)
|
||||
return parsed
|
||||
|
||||
def tick(self) -> C6TickResult:
|
||||
if self._closed or not self.bindings.enabled:
|
||||
return C6TickResult("DISABLED", "C6_DISABLED")
|
||||
if not self._lock.acquire(blocking=False):
|
||||
return C6TickResult("BLOCKED", "LOCAL_TICK_IN_PROGRESS")
|
||||
try:
|
||||
audit = self.bindings.aro_tick.run_due() if self.bindings.aro_tick else None
|
||||
if self.bindings.pilot is None:
|
||||
return C6TickResult("BLOCKED", "EXECUTION_ENTRYPOINT_ABSENT", audit_report_ref=audit)
|
||||
if self.bindings.pending_intent_provider is None:
|
||||
return C6TickResult(
|
||||
"BLOCKED", "DURABLE_PENDING_INTENT_PROVIDER_ABSENT",
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
# Restart recovery is ledger-first. It does not depend on the Task
|
||||
# remaining discoverable and it never calls execute/resubmits.
|
||||
pending = self._durable_pending()
|
||||
if pending:
|
||||
worker = self._worker()
|
||||
closures = []
|
||||
for binding, intent in pending:
|
||||
observation, closed = self._reconcile_pending(binding, intent, worker)
|
||||
closures.append(closed)
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
binding, intent, observation
|
||||
)
|
||||
all_succeeded = all(item.outcome == "SUCCEEDED" for item in closures)
|
||||
return C6TickResult(
|
||||
"OBSERVED" if all_succeeded else "UNKNOWN",
|
||||
"DURABLE_PENDING_INTENTS_RECONCILED"
|
||||
if all_succeeded
|
||||
else "DURABLE_PENDING_INTENT_READBACK",
|
||||
closure=closures[-1],
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
if self._pending is not None:
|
||||
# ACK loss is sticky. Reconciliation is a distinct, pure
|
||||
# owner seam: it may observe/reconcile the exact durable action
|
||||
# but may never acquire a new WriterLease or resend the commit.
|
||||
intent = self._intent(self._pending)
|
||||
if intent is None:
|
||||
return C6TickResult("UNKNOWN", "PENDING_INTENT_UNAVAILABLE",
|
||||
audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
observation, closure = self._reconcile_pending(
|
||||
self._pending, intent, worker
|
||||
)
|
||||
if closure.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
self._pending, intent, observation
|
||||
)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closure.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"PENDING_EFFECT_READBACK", closure=closure, audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
discovered = self.coordinator.discover(worker_id=worker.worker_id, actor_identity=worker.actor_identity)
|
||||
if discovered.proof is None:
|
||||
return C6TickResult("NO_CANDIDATES" if discovered.candidate_count == 0 else "BLOCKED",
|
||||
discovered.status, discovery=discovered, audit_report_ref=audit)
|
||||
task_id = discovered.proof.task_id
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
if self._intent(binding) is not None:
|
||||
self._pending = binding
|
||||
intent = self._intent(binding)
|
||||
try:
|
||||
if intent is not None:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
closed = derive_closure(binding, collect_closure(binding,
|
||||
self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"DURABLE_INTENT_READBACK", discovered, closure=closed, audit_report_ref=audit)
|
||||
if preflight.state != "READY":
|
||||
return C6TickResult("BLOCKED", preflight.reason_code, discovered, preflight=preflight, audit_report_ref=audit)
|
||||
claim = self._claim_prepared(binding, preflight, worker)
|
||||
# Re-read every precondition immediately before the effect. The
|
||||
# executor independently enforces its linearization-point fences.
|
||||
binding_after, refreshed = self._prepare(task_id, worker)
|
||||
self._worker(worker)
|
||||
if binding_after != binding or refreshed.state != "READY" or self._closed:
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_BINDING_CHANGED", discovered, claim, refreshed)
|
||||
try:
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
binding_after, refreshed = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (binding_after != binding or refreshed.state != "READY" or self._closed
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"
|
||||
or self._intent(binding) is not None):
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_AUTHORITY_CHANGED", discovered, claim, refreshed)
|
||||
self._pending = binding
|
||||
intent = self.bindings.pilot.prepare_intent(binding=binding, claim=claim)
|
||||
observed_intent = self._intent(binding)
|
||||
if (not isinstance(intent, DurableExecutionIntent) or intent != observed_intent
|
||||
or intent.claim_id != claim.claim_id):
|
||||
raise ForbiddenError("execution intent not durably observed")
|
||||
# Intent persistence may take time: acquire a new guard and
|
||||
# read authority/effect again before calling the governed writer.
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
after, ready = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (after != binding or ready.state != "READY" or self._closed
|
||||
or self._intent(binding) != intent
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"):
|
||||
raise ForbiddenError("pre-effect authority changed during intent persistence")
|
||||
self.bindings.pilot.execute(binding=binding, claim=claim, intent=intent)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "EXECUTION_OUTCOME_UNKNOWN", discovered, claim, refreshed)
|
||||
observed = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
closed = derive_closure(binding, observed, now=self.clock())
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(binding, intent, observed)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"THREE_LAYER_CLOSURE" if closed.outcome == "SUCCEEDED" else "CLOSURE_INCOMPLETE",
|
||||
discovered, claim, refreshed, closed, audit)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "AUTHORITY_OR_OBSERVATION_UNAVAILABLE")
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def close(self):
|
||||
# Local tick lifecycle only. Authority leases are never implicitly
|
||||
# released or renewed by closing an observer/coordinator.
|
||||
self._closed = True
|
||||
|
||||
|
||||
def bind_c6(service, bindings: C6Bindings, *, clock=None) -> C6Runtime:
|
||||
if getattr(service, "c6_runtime", None) is not None:
|
||||
raise ValueError("C6 runtime already bound")
|
||||
runtime = C6Runtime(service, bindings, clock=clock)
|
||||
service.c6_runtime = runtime
|
||||
return runtime
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Pure six-value C6 decision explanation policy.
|
||||
|
||||
The result is advice over already-read facts. It is not an authorization,
|
||||
does not map implicitly to legacy NextAction, and owns no write capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
|
||||
RootState = Literal["VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"]
|
||||
|
||||
|
||||
class DecisionKind(str, Enum):
|
||||
AUTO_FIX = "AUTO_FIX"
|
||||
PLAN_ONLY = "PLAN_ONLY"
|
||||
OWNER_APPROVAL_REQUIRED = "OWNER_APPROVAL_REQUIRED"
|
||||
HARD_BLOCK = "HARD_BLOCK"
|
||||
WAITING_ROOT_AUTHORIZATION = "WAITING_ROOT_AUTHORIZATION"
|
||||
BLOCKED_WITH_EVIDENCE = "BLOCKED_WITH_EVIDENCE"
|
||||
|
||||
|
||||
def _evidence_refs(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in value:
|
||||
if not isinstance(ref, str) or not ref or ref != ref.strip() or "\x00" in ref:
|
||||
raise ValueError("invalid evidence reference")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionInput:
|
||||
root_state: RootState
|
||||
facts_complete: bool
|
||||
scope_covered: bool
|
||||
policy_denied: bool
|
||||
proposal_only: bool
|
||||
executor_ready: bool
|
||||
aro_phase_a: bool
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.root_state not in ("VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"):
|
||||
raise ValueError("invalid root_state")
|
||||
for name in (
|
||||
"facts_complete",
|
||||
"scope_covered",
|
||||
"policy_denied",
|
||||
"proposal_only",
|
||||
"executor_ready",
|
||||
"aro_phase_a",
|
||||
):
|
||||
if type(getattr(self, name)) is not bool:
|
||||
raise ValueError(f"{name} must be bool")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
kind: DecisionKind
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, DecisionKind):
|
||||
raise ValueError("kind must be DecisionKind")
|
||||
if not isinstance(self.reason, str) or not self.reason:
|
||||
raise ValueError("reason must be non-empty")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
def decide(value: DecisionInput) -> Decision:
|
||||
if not isinstance(value, DecisionInput):
|
||||
raise TypeError("value must be DecisionInput")
|
||||
if value.policy_denied or value.root_state == "REVOKED":
|
||||
return Decision(
|
||||
DecisionKind.HARD_BLOCK,
|
||||
"POLICY_DENIED_OR_ROOT_REVOKED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.facts_complete or value.root_state == "UNKNOWN":
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"CRITICAL_FACTS_INCOMPLETE",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.root_state in ("MISSING", "EXPIRED"):
|
||||
return Decision(
|
||||
DecisionKind.WAITING_ROOT_AUTHORIZATION,
|
||||
"ROOT_AUTHORIZATION_MISSING_OR_EXPIRED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.scope_covered:
|
||||
return Decision(
|
||||
DecisionKind.OWNER_APPROVAL_REQUIRED,
|
||||
"SCOPE_OR_BUDGET_REQUIRES_OWNER_DECISION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.proposal_only or value.aro_phase_a:
|
||||
return Decision(
|
||||
DecisionKind.PLAN_ONLY,
|
||||
"NON_EXECUTABLE_PLAN_OR_READONLY_ARO",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.executor_ready:
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"GOVERNED_EXECUTOR_NOT_READY",
|
||||
value.evidence_refs,
|
||||
)
|
||||
return Decision(
|
||||
DecisionKind.AUTO_FIX,
|
||||
"EXISTING_AUTHORIZED_SCOPE_ELIGIBLE_FOR_GOVERNED_EXECUTION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Decision", "DecisionInput", "DecisionKind", "decide"]
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from .models import EvidenceEnvelope
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
@@ -17,8 +18,44 @@ class EvidenceService:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
|
||||
return self.store.add_evidence(task_id, kind, payload, worker_identity)
|
||||
def add(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict,
|
||||
worker_identity: str,
|
||||
*,
|
||||
envelope: EvidenceEnvelope | None = None,
|
||||
claim_id: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> str:
|
||||
return self.store.add_evidence(
|
||||
task_id,
|
||||
kind,
|
||||
payload,
|
||||
worker_identity,
|
||||
envelope=envelope,
|
||||
claim_id=claim_id,
|
||||
evidence_id=evidence_id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
def list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_evidence(task_id)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Authenticated Task Plane evidence envelope adapter.
|
||||
|
||||
The adapter deliberately receives key material through an external resolver.
|
||||
It does not create, persist, or select a production trust root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Callable
|
||||
|
||||
from .models import EvidenceEnvelope, canonical_json, compute_sha256_hex
|
||||
|
||||
|
||||
HMAC_SHA256_V1 = "hmac-sha256-v1"
|
||||
_SIGNATURE_DOMAIN = b"aios.task-plane.evidence-envelope.v1"
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _signature_payload(envelope: EvidenceEnvelope) -> bytes:
|
||||
return (
|
||||
_SIGNATURE_DOMAIN
|
||||
+ b"\0"
|
||||
+ canonical_json(envelope.digest_payload()).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
class HmacEvidenceVerifier:
|
||||
"""Verify full evidence envelopes using an externally managed HMAC key."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_identity: str,
|
||||
key_id: str,
|
||||
key_resolver: Callable[[str], bytes],
|
||||
max_age_seconds: int = 300,
|
||||
now_fn: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not verifier_identity or not key_id:
|
||||
raise ValueError("verifier_identity and key_id are required")
|
||||
if not callable(key_resolver):
|
||||
raise ValueError("key_resolver is required")
|
||||
if (
|
||||
isinstance(max_age_seconds, bool)
|
||||
or not isinstance(max_age_seconds, int)
|
||||
or max_age_seconds <= 0
|
||||
):
|
||||
raise ValueError("max_age_seconds must be a positive integer")
|
||||
self.verifier_identity = verifier_identity
|
||||
self.key_id = key_id
|
||||
self._key_resolver = key_resolver
|
||||
self.max_age_seconds = max_age_seconds
|
||||
self._now_fn = now_fn or time.time
|
||||
|
||||
def __call__(self, envelope: EvidenceEnvelope) -> bool:
|
||||
try:
|
||||
now = float(self._now_fn())
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (now, envelope.created_at, envelope.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(envelope.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
not finite_timestamps
|
||||
or envelope.expires_at <= envelope.created_at
|
||||
or envelope.signature_algorithm != HMAC_SHA256_V1
|
||||
or envelope.signing_key_id != self.key_id
|
||||
or envelope.verifier_identity != self.verifier_identity
|
||||
or envelope.authority_domain != self.verifier_identity
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
or not isinstance(envelope.signature, str)
|
||||
or not _HEX64.fullmatch(envelope.signature)
|
||||
or envelope.created_at > now
|
||||
or envelope.expires_at <= now
|
||||
or envelope.expires_at - envelope.created_at > self.max_age_seconds
|
||||
or envelope.evidence_digest != expected_digest
|
||||
):
|
||||
return False
|
||||
try:
|
||||
key = self._key_resolver(self.key_id)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
return False
|
||||
expected = hmac.new(
|
||||
bytes(key), _signature_payload(envelope), hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(envelope.signature, expected)
|
||||
|
||||
|
||||
def sign_evidence_envelope(
|
||||
envelope: EvidenceEnvelope,
|
||||
*,
|
||||
key_id: str,
|
||||
key: bytes,
|
||||
verifier_identity: str,
|
||||
) -> EvidenceEnvelope:
|
||||
"""Return a signed envelope for an already authorized producer.
|
||||
|
||||
Production code should obtain ``key`` from its existing credential provider;
|
||||
this helper intentionally performs no key generation or storage.
|
||||
"""
|
||||
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
raise ValueError("key is required")
|
||||
if not key_id or not verifier_identity:
|
||||
raise ValueError("key_id and verifier_identity are required")
|
||||
if envelope.authority_domain != verifier_identity:
|
||||
raise ValueError("envelope authority_domain must match verifier_identity")
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
if envelope.expires_at <= envelope.created_at:
|
||||
raise ValueError("evidence expiry must follow creation")
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ValueError("evidence sequence must be a positive integer")
|
||||
unsigned = replace(
|
||||
envelope,
|
||||
signature=None,
|
||||
signature_algorithm=HMAC_SHA256_V1,
|
||||
signing_key_id=key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
evidence_digest="",
|
||||
)
|
||||
unsigned = replace(
|
||||
unsigned,
|
||||
evidence_digest=compute_sha256_hex(unsigned.digest_payload()),
|
||||
)
|
||||
signature = hmac.new(
|
||||
bytes(key), _signature_payload(unsigned), hashlib.sha256
|
||||
).hexdigest()
|
||||
return replace(unsigned, signature=signature)
|
||||
@@ -11,7 +11,7 @@ from contextlib import closing
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any, Callable, Iterable, Literal, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA_RUNTIME_MANIFEST = "aios.runtime-manifest.v1"
|
||||
@@ -57,6 +57,13 @@ class ConcurrentWriterDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class MutationOwnershipError(GovernanceControlError):
|
||||
code = "MUTATION_TICKET_NOT_OWNED"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class InvalidStateTransition(GovernanceControlError):
|
||||
pass
|
||||
|
||||
@@ -631,6 +638,7 @@ class BlockerClassification(str, Enum):
|
||||
TRANSIENT = "TRANSIENT"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
SELF_ACTIONABLE = "SELF_ACTIONABLE"
|
||||
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
|
||||
|
||||
|
||||
class ProducerKind(str, Enum):
|
||||
@@ -648,6 +656,70 @@ class NextAction(str, Enum):
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
REPAIR_MECHANISM = "REPAIR_MECHANISM"
|
||||
DENY_EFFECT = "DENY_EFFECT"
|
||||
|
||||
|
||||
class DecisionEffectIntent(str, Enum):
|
||||
"""Whether this decision can lead to a new side effect.
|
||||
|
||||
UNSPECIFIED retains the advisory behavior of legacy callers. Production
|
||||
callers that may cause a write must pass SIDE_EFFECT and authoritative
|
||||
reader results; this value is not an authorization token.
|
||||
"""
|
||||
|
||||
UNSPECIFIED = "UNSPECIFIED"
|
||||
READ_ONLY = "READ_ONLY"
|
||||
SIDE_EFFECT = "SIDE_EFFECT"
|
||||
|
||||
|
||||
class AuthorityLifecycleState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
REVOKED = "REVOKED"
|
||||
CLOSED = "CLOSED"
|
||||
CANCELLED = "CANCELLED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ExactScopeState(str, Enum):
|
||||
IN_SCOPE = "IN_SCOPE"
|
||||
OUT_OF_SCOPE = "OUT_OF_SCOPE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class OwnerBoundaryReason(str, Enum):
|
||||
GOAL_CHANGE = "GOAL_CHANGE"
|
||||
TRUST_BOUNDARY_CHANGE = "TRUST_BOUNDARY_CHANGE"
|
||||
IRREVERSIBLE_OPERATION = "IRREVERSIBLE_OPERATION"
|
||||
BUSINESS_RISK_BUDGET_EXCEEDED = "BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityDecisionContext:
|
||||
"""Read-only projection of current authority facts used for routing.
|
||||
|
||||
The context must be populated from authority readers. It carries no write
|
||||
capability and cannot replace the executor's final authority/fencing check.
|
||||
"""
|
||||
|
||||
lifecycle_state: AuthorityLifecycleState
|
||||
scope_state: ExactScopeState
|
||||
owner_boundary_reason: OwnerBoundaryReason | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.lifecycle_state, AuthorityLifecycleState):
|
||||
raise ValueError("lifecycle_state must be AuthorityLifecycleState")
|
||||
if not isinstance(self.scope_state, ExactScopeState):
|
||||
raise ValueError("scope_state must be ExactScopeState")
|
||||
if self.owner_boundary_reason is not None and not isinstance(
|
||||
self.owner_boundary_reason, OwnerBoundaryReason
|
||||
):
|
||||
raise ValueError("owner_boundary_reason must be OwnerBoundaryReason")
|
||||
if not isinstance(self.evidence_refs, tuple) or any(
|
||||
not isinstance(ref, str) or not ref or ref != ref.strip() or "\x00" in ref
|
||||
for ref in self.evidence_refs
|
||||
):
|
||||
raise ValueError("invalid authority evidence reference")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -671,12 +743,12 @@ DEFAULT_PRODUCER_TABLE: Mapping[str, ProducerBinding] = {
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"GENERATION_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"TARGET_BRANCH_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"FILE_ALLOWLIST_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"ACCEPTANCE_REVISION_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"FILE_ALLOWLIST_MISSING": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "release-contract-builder", "POPULATE_FILE_ALLOWLIST_WITHIN_SCOPE"),
|
||||
"ACCEPTANCE_REVISION_MISSING": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "acceptance-authority", "PROVIDE_ACCEPTANCE_REVISION"),
|
||||
"RECOVERY_RESULT_UNKNOWN": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "RECONCILE_BEFORE_RETRY"),
|
||||
"CAPABILITY_OUT_OF_POLICY": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "admission-policy-authority", "ALIGN_POLICY_AND_EPOCH"),
|
||||
"EXECUTION_DIGEST_INVALID": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "candidate-plan-builder", "REGENERATE_CANONICAL_PLAN"),
|
||||
"TASK_EXPIRED_OWNER_RECOVERY_REQUIRED": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "agent-task-terminal-recovery", "AUTHORIZE_TERMINAL_RECOVERY_OR_SUCCESSOR"),
|
||||
"TASK_EXPIRED_OWNER_RECOVERY_REQUIRED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "task-mission-authority", "DERIVE_IN_SCOPE_SUCCESSOR"),
|
||||
"EPOCH_EXPIRED": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "trusted-grant-signer-adapter", "JIT_REISSUE_EPOCH_IF_SCOPE_UNCHANGED"),
|
||||
}
|
||||
|
||||
@@ -693,6 +765,16 @@ class DecisionInput:
|
||||
descendant_statuses: tuple[str, ...] = ()
|
||||
unchanged_cycles: int = 0
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
effect_intent: DecisionEffectIntent = DecisionEffectIntent.UNSPECIFIED
|
||||
authority_context: AuthorityDecisionContext | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.effect_intent, DecisionEffectIntent):
|
||||
raise ValueError("effect_intent must be DecisionEffectIntent")
|
||||
if self.authority_context is not None and not isinstance(
|
||||
self.authority_context, AuthorityDecisionContext
|
||||
):
|
||||
raise ValueError("authority_context must be AuthorityDecisionContext")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -706,6 +788,7 @@ class GovernanceDecision:
|
||||
producer_id: str | None
|
||||
action_required: str | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
owner_boundary_reason: OwnerBoundaryReason | None = None
|
||||
schema: str = SCHEMA_DECISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -713,12 +796,20 @@ class GovernanceDecision:
|
||||
result["next_valid_action"] = self.next_valid_action.value
|
||||
result["classification"] = None if self.classification is None else self.classification.value
|
||||
result["producer_kind"] = None if self.producer_kind is None else self.producer_kind.value
|
||||
result["owner_boundary_reason"] = (
|
||||
None if self.owner_boundary_reason is None else self.owner_boundary_reason.value
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class AutonomousDecisionEngine:
|
||||
RECOVERY_STATUSES = frozenset({"FAILED_RECOVERABLE", "EXPIRED", "RECOVERY_REQUIRED"})
|
||||
BLOCKED_STATUSES = frozenset({"BLOCKED", "WAIT_DEPENDENCY", "TERMINAL_UNPROVEN"})
|
||||
CONTEXTUAL_SCOPE_BLOCKERS = frozenset({
|
||||
"FILE_ALLOWLIST_MISSING",
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
"TASK_EXPIRED_OWNER_RECOVERY_REQUIRED",
|
||||
})
|
||||
|
||||
def __init__(self, producer_table: Mapping[str, ProducerBinding] | None = None):
|
||||
self.producer_table = dict(DEFAULT_PRODUCER_TABLE)
|
||||
@@ -733,16 +824,88 @@ class AutonomousDecisionEngine:
|
||||
|
||||
def decide(self, inp: DecisionInput) -> GovernanceDecision:
|
||||
blockers = tuple(dict.fromkeys(inp.blocker_codes))
|
||||
context = inp.authority_context
|
||||
side_effect = inp.effect_intent == DecisionEffectIntent.SIDE_EFFECT
|
||||
|
||||
if side_effect and context is not None and context.lifecycle_state in {
|
||||
AuthorityLifecycleState.REVOKED,
|
||||
AuthorityLifecycleState.CLOSED,
|
||||
AuthorityLifecycleState.CANCELLED,
|
||||
}:
|
||||
return self._decision(
|
||||
inp,
|
||||
f"AUTHORITY_{context.lifecycle_state.value}",
|
||||
ProducerBinding(
|
||||
BlockerClassification.AUTHORIZATION_DENIED,
|
||||
ProducerKind.AUTHORITY_SIDE,
|
||||
"current-authority-reader",
|
||||
"DENY_NEW_EFFECT",
|
||||
),
|
||||
NextAction.DENY_EFFECT,
|
||||
)
|
||||
|
||||
explicit_boundary_reason = (
|
||||
context.owner_boundary_reason
|
||||
if side_effect and context is not None
|
||||
else None
|
||||
)
|
||||
scope_boundary = (
|
||||
side_effect
|
||||
and context is not None
|
||||
and context.lifecycle_state == AuthorityLifecycleState.ACTIVE
|
||||
and context.scope_state == ExactScopeState.OUT_OF_SCOPE
|
||||
)
|
||||
if inp.owner_boundary_required or explicit_boundary_reason is not None or scope_boundary:
|
||||
return self._owner_boundary_decision(
|
||||
inp,
|
||||
explicit_boundary_reason
|
||||
if explicit_boundary_reason is not None
|
||||
else OwnerBoundaryReason.GOAL_CHANGE
|
||||
if scope_boundary
|
||||
else None,
|
||||
)
|
||||
|
||||
if "RECOVERY_RESULT_UNKNOWN" in blockers:
|
||||
blocker = "RECOVERY_RESULT_UNKNOWN"
|
||||
return self._decision(
|
||||
inp, blocker, self._binding(blocker), NextAction.RECOVER_EXISTING_CHILD
|
||||
)
|
||||
|
||||
contextual_blocker = next(
|
||||
(blocker for blocker in blockers if blocker in self.CONTEXTUAL_SCOPE_BLOCKERS),
|
||||
None,
|
||||
)
|
||||
if contextual_blocker is not None:
|
||||
if side_effect:
|
||||
if context is None or context.lifecycle_state == AuthorityLifecycleState.UNKNOWN:
|
||||
return self._authority_facts_unresolved(inp, contextual_blocker)
|
||||
if context.scope_state == ExactScopeState.UNKNOWN:
|
||||
return self._authority_facts_unresolved(inp, contextual_blocker)
|
||||
if context.scope_state == ExactScopeState.OUT_OF_SCOPE:
|
||||
return self._owner_boundary_decision(inp, OwnerBoundaryReason.GOAL_CHANGE)
|
||||
binding = self._binding(contextual_blocker)
|
||||
next_action = (
|
||||
NextAction.REPAIR_MECHANISM
|
||||
if binding.classification == BlockerClassification.MECHANISM_DEFECT
|
||||
else NextAction.RECOVER_EXISTING_CHILD
|
||||
if binding.classification == BlockerClassification.SELF_ACTIONABLE
|
||||
else NextAction.WAIT_DEPENDENCY
|
||||
)
|
||||
return self._decision(inp, contextual_blocker, binding, next_action)
|
||||
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.MECHANISM_DEFECT:
|
||||
return self._decision(inp, blocker, binding, NextAction.REPAIR_MECHANISM)
|
||||
if inp.owner_boundary_required:
|
||||
blocker = blockers[0] if blockers else "OWNER_BOUNDARY"
|
||||
binding = self._binding(blocker) if blockers else ProducerBinding(
|
||||
BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner", "OWNER_DECISION_REQUIRED"
|
||||
)
|
||||
return self._decision(inp, blocker, binding, NextAction.OWNER_BOUNDARY)
|
||||
|
||||
if side_effect:
|
||||
if context is None or context.lifecycle_state == AuthorityLifecycleState.UNKNOWN:
|
||||
return self._authority_facts_unresolved(inp)
|
||||
if context.scope_state == ExactScopeState.UNKNOWN:
|
||||
return self._authority_facts_unresolved(inp)
|
||||
if context.scope_state == ExactScopeState.OUT_OF_SCOPE:
|
||||
return self._owner_boundary_decision(inp, OwnerBoundaryReason.GOAL_CHANGE)
|
||||
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.OWNER_BOUNDARY:
|
||||
@@ -766,13 +929,14 @@ class AutonomousDecisionEngine:
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="admission-authority",
|
||||
action_required="MAKE_ADMISSION_READY",evidence_refs=inp.evidence_refs,
|
||||
action_required="MAKE_ADMISSION_READY",evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
if inp.acceptance_complete:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.ROOT_COMPLETE,
|
||||
owner_boundary=False,blocker_code=None,classification=None,
|
||||
producer_kind=None,producer_id=None,action_required=None,evidence_refs=inp.evidence_refs,
|
||||
producer_kind=None,producer_id=None,action_required=None,
|
||||
evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
statuses = {str(x).upper() for x in inp.descendant_statuses}
|
||||
if statuses & self.RECOVERY_STATUSES:
|
||||
@@ -781,7 +945,7 @@ class AutonomousDecisionEngine:
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="agent-recovery-executor",
|
||||
action_required="RECOVER_EXISTING_CHILD",evidence_refs=inp.evidence_refs,
|
||||
action_required="RECOVER_EXISTING_CHILD",evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
if statuses & self.BLOCKED_STATUSES:
|
||||
return GovernanceDecision(
|
||||
@@ -789,14 +953,14 @@ class AutonomousDecisionEngine:
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="dependency-authority",
|
||||
action_required="UNBLOCK_DEPENDENCY",evidence_refs=inp.evidence_refs,
|
||||
action_required="UNBLOCK_DEPENDENCY",evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.CREATE_OR_RESUME_NEXT_CHILD,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="autonomy-controller",
|
||||
action_required="CREATE_OR_RESUME_NEXT_CHILD",evidence_refs=inp.evidence_refs,
|
||||
action_required="CREATE_OR_RESUME_NEXT_CHILD",evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
|
||||
def _decision(self, inp: DecisionInput, blocker: str, binding: ProducerBinding, next_action: NextAction) -> GovernanceDecision:
|
||||
@@ -805,15 +969,129 @@ class AutonomousDecisionEngine:
|
||||
owner_boundary=binding.classification == BlockerClassification.OWNER_BOUNDARY,
|
||||
blocker_code=blocker,classification=binding.classification,
|
||||
producer_kind=binding.producer_kind,producer_id=binding.producer_id,
|
||||
action_required=binding.action_required,evidence_refs=inp.evidence_refs,
|
||||
action_required=binding.action_required,
|
||||
evidence_refs=self._evidence_refs(inp),
|
||||
)
|
||||
|
||||
def _owner_boundary_decision(
|
||||
self,
|
||||
inp: DecisionInput,
|
||||
reason: OwnerBoundaryReason | None,
|
||||
) -> GovernanceDecision:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,
|
||||
next_valid_action=NextAction.OWNER_BOUNDARY,
|
||||
owner_boundary=True,
|
||||
blocker_code="OWNER_BOUNDARY" if reason is None else reason.value,
|
||||
classification=BlockerClassification.OWNER_BOUNDARY,
|
||||
producer_kind=ProducerKind.OWNER,
|
||||
producer_id="owner",
|
||||
action_required="OWNER_DECISION_REQUIRED",
|
||||
evidence_refs=self._evidence_refs(inp),
|
||||
owner_boundary_reason=reason,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_refs(inp: DecisionInput) -> tuple[str, ...]:
|
||||
context_refs = () if inp.authority_context is None else inp.authority_context.evidence_refs
|
||||
return tuple(dict.fromkeys((*inp.evidence_refs, *context_refs)))
|
||||
|
||||
def _authority_facts_unresolved(
|
||||
self,
|
||||
inp: DecisionInput,
|
||||
source_blocker: str | None = None,
|
||||
) -> GovernanceDecision:
|
||||
return self._decision(
|
||||
inp,
|
||||
"AUTHORITY_FACTS_UNRESOLVED" if source_blocker is None else source_blocker,
|
||||
ProducerBinding(
|
||||
BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
ProducerKind.AUTHORITY_SIDE,
|
||||
"current-authority-reader",
|
||||
"RESOLVE_CURRENT_AUTHORITY_AND_EXACT_SCOPE",
|
||||
),
|
||||
NextAction.WAIT_DEPENDENCY,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MutationScope:
|
||||
"""A host-validated repository and branch used to partition local locks."""
|
||||
repository: str
|
||||
branch: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.repository, str)
|
||||
or not isinstance(self.branch, str)
|
||||
or not self.repository
|
||||
or not self.branch
|
||||
or "\x00" in self.repository
|
||||
or "\x00" in self.branch
|
||||
):
|
||||
raise ValueError("mutation scope must contain repository and branch")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScopedMutationLock:
|
||||
lock: Any
|
||||
references: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OwnedMutation:
|
||||
ticket: ActionTicket
|
||||
owner_thread_id: int
|
||||
lock_key: tuple[str, str]
|
||||
lock: Any
|
||||
nonce: object
|
||||
state: Literal["ACTIVE", "FINISHING"] = "ACTIVE"
|
||||
|
||||
|
||||
class MutationGuard:
|
||||
def __init__(self, manifest_store: ManifestStore, action_ledger: ActionLedger):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self._writer_lock = threading.Lock()
|
||||
self._metadata_lock = threading.Lock()
|
||||
self._scope_locks: dict[tuple[str, str], _ScopedMutationLock] = {}
|
||||
self._owned_mutations: dict[int, _OwnedMutation] = {}
|
||||
|
||||
@staticmethod
|
||||
def _lock_key(lock_scope: MutationScope | None) -> tuple[str, str]:
|
||||
# The omitted C5 parameter retains one process-wide compatibility lock.
|
||||
if lock_scope is None:
|
||||
return ("", "")
|
||||
if not isinstance(lock_scope, MutationScope):
|
||||
raise TypeError("lock_scope must be a MutationScope")
|
||||
return (lock_scope.repository, lock_scope.branch)
|
||||
|
||||
def _acquire_scope_lock(self, lock_key: tuple[str, str]) -> Any:
|
||||
with self._metadata_lock:
|
||||
entry = self._scope_locks.get(lock_key)
|
||||
if entry is None:
|
||||
entry = _ScopedMutationLock(lock=threading.Lock())
|
||||
self._scope_locks[lock_key] = entry
|
||||
entry.references += 1
|
||||
lock = entry.lock
|
||||
if lock.acquire(blocking=False):
|
||||
return lock
|
||||
with self._metadata_lock:
|
||||
self._drop_lock_reference_locked(lock_key, lock)
|
||||
raise ConcurrentWriterDenied("another governed mutation is in progress")
|
||||
|
||||
def _drop_lock_reference_locked(self, lock_key: tuple[str, str], lock: Any) -> None:
|
||||
entry = self._scope_locks.get(lock_key)
|
||||
if entry is None or entry.lock is not lock or entry.references <= 0:
|
||||
raise GovernanceControlError("mutation lock bookkeeping violated")
|
||||
entry.references -= 1
|
||||
if entry.references == 0:
|
||||
del self._scope_locks[lock_key]
|
||||
|
||||
def _release_unowned_scope_lock(self, lock_key: tuple[str, str], lock: Any) -> None:
|
||||
# This call can only release the lock acquired by the current begin.
|
||||
lock.release()
|
||||
with self._metadata_lock:
|
||||
self._drop_lock_reference_locked(lock_key, lock)
|
||||
|
||||
def begin_mutation(
|
||||
self,
|
||||
@@ -828,44 +1106,79 @@ class MutationGuard:
|
||||
precondition_hash: str,
|
||||
expected_readback: Mapping[str, Any] | None = None,
|
||||
rollback_ref: str | None = None,
|
||||
lock_scope: MutationScope | None = None,
|
||||
) -> ActionTicket:
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if not self._writer_lock.acquire(blocking=False):
|
||||
raise ConcurrentWriterDenied("another governed mutation is in progress")
|
||||
lock_key = self._lock_key(lock_scope)
|
||||
lock = self._acquire_scope_lock(lock_key)
|
||||
released = False
|
||||
registered = False
|
||||
try:
|
||||
ticket = self.action_ledger.begin(
|
||||
idempotency_key=idempotency_key,manifest_id=manifest_id,phase=phase,
|
||||
kind=kind,target=target,precondition_hash=precondition_hash,
|
||||
expected_readback=expected_readback,rollback_ref=rollback_ref,
|
||||
)
|
||||
if ticket.reused:
|
||||
self._writer_lock.release()
|
||||
if ticket.reused or ticket.requires_reconcile:
|
||||
self._release_unowned_scope_lock(lock_key, lock)
|
||||
released = True
|
||||
return ticket
|
||||
with self._metadata_lock:
|
||||
self._owned_mutations[id(ticket)] = _OwnedMutation(
|
||||
ticket=ticket,
|
||||
owner_thread_id=threading.get_ident(),
|
||||
lock_key=lock_key,
|
||||
lock=lock,
|
||||
nonce=object(),
|
||||
)
|
||||
registered = True
|
||||
return ticket
|
||||
except Exception:
|
||||
self._writer_lock.release()
|
||||
if not registered and not released:
|
||||
self._release_unowned_scope_lock(lock_key, lock)
|
||||
raise
|
||||
|
||||
def finish_success(self, ticket: ActionTicket, *, result: Mapping[str, Any], evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_succeeded(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
self._finish_owned(ticket, self.action_ledger.mark_succeeded, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def finish_failed(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
self._finish_owned(ticket, self.action_ledger.mark_failed, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def finish_effect_unknown(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish_owned(ticket, self.action_ledger.mark_effect_unknown, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def _finish_owned(
|
||||
self,
|
||||
ticket: ActionTicket,
|
||||
finish: Callable[..., None],
|
||||
*,
|
||||
result: Mapping[str, Any] | None,
|
||||
evidence_refs: Sequence[str],
|
||||
) -> None:
|
||||
with self._metadata_lock:
|
||||
owned = self._owned_mutations.get(id(ticket))
|
||||
if (
|
||||
owned is None
|
||||
or owned.ticket is not ticket
|
||||
or owned.owner_thread_id != threading.get_ident()
|
||||
or owned.state != "ACTIVE"
|
||||
):
|
||||
raise MutationOwnershipError()
|
||||
owned.state = "FINISHING"
|
||||
try:
|
||||
self.action_ledger.mark_effect_unknown(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finish(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
with self._metadata_lock:
|
||||
current = self._owned_mutations.get(id(ticket))
|
||||
if (
|
||||
current is owned
|
||||
and current.ticket is ticket
|
||||
and current.nonce is owned.nonce
|
||||
):
|
||||
del self._owned_mutations[id(ticket)]
|
||||
current.lock.release()
|
||||
self._drop_lock_reference_locked(current.lock_key, current.lock)
|
||||
|
||||
@@ -46,6 +46,7 @@ class GovernedWriterState(str, Enum):
|
||||
RISK_ORDER = {
|
||||
"low": 10,
|
||||
"medium": 20,
|
||||
"engineering": 25,
|
||||
"high": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
"""Fail-closed host adapter for the C6 governed source-patch pilot.
|
||||
|
||||
The adapter owns no worker registration, authority database, effect ledger, or
|
||||
Git mutation implementation. Those are host-owned. It only joins an exact C6
|
||||
operation binding and v3 TaskPlane claim to an existing durable external
|
||||
transaction owner.
|
||||
|
||||
The transaction owner is a security boundary. Its ``execute_prepared`` method
|
||||
must durably record the send phase before an external call, call
|
||||
``before_external_call`` while holding its own lease/fence, acquire and persist
|
||||
the real WriterLease, enforce expected-OID CAS at the write linearization point,
|
||||
and leave an ambiguous call in EFFECT_UNKNOWN. A callback by itself is not an
|
||||
atomic revocation or CAS fence.
|
||||
|
||||
``RootActionConsumer`` exposes an exact-key facade only after the host installs
|
||||
a trusted operation-to-review binding provider. C6 callers cannot synthesize
|
||||
review_id, mission_id, root_task_id, root_revision, plan digest, or execution
|
||||
key. The facade reuses the consumer's existing review, fence, and effect rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
|
||||
from .c6_composition import DurableExecutionIntent
|
||||
from .operation_view import ClosureObservation, OperationBinding, binding_digest, validate_oid
|
||||
from .patch_contract import ExecutablePatchV1, parse_executable_patch
|
||||
from .schedulability_store_adapter import TaskPlaneClaimReceiptV3
|
||||
|
||||
|
||||
_ACTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,191}$")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
HOST_PATCH_AUTHORITY_PURPOSE = "aios.c6.host-patch-authority.current.v1"
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE = (
|
||||
"aios.c6.host-patch-reconcile-authority.current.v1"
|
||||
)
|
||||
HOST_PATCH_AUTHORITY_SCHEMA = "aios.c6.host-patch-authority.v1"
|
||||
_AUTHORITY_RECORD_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authorization_ref",
|
||||
"authority_generation",
|
||||
"task_generation",
|
||||
"claim_id",
|
||||
"claim_fence",
|
||||
"expected_branch_oid",
|
||||
"binding_digest",
|
||||
"lifecycle_state",
|
||||
"scope_state",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_json_bytes(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
ActionState = Literal[
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
]
|
||||
|
||||
|
||||
class HostPatchIntegrationError(RuntimeError):
|
||||
"""The host binding is incomplete, stale, or contradictory."""
|
||||
|
||||
|
||||
class HostPatchEffectUnknown(HostPatchIntegrationError):
|
||||
"""The same immutable action must be reconciled, never resubmitted."""
|
||||
|
||||
|
||||
class HostPatchConfigurationError(HostPatchIntegrationError):
|
||||
"""No production-safe exact-key transaction owner is configured."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchArtifact:
|
||||
document: Mapping[str, object]
|
||||
patch_bytes: bytes
|
||||
allowed_files: frozenset[str]
|
||||
commit_message: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.document, Mapping):
|
||||
raise ValueError("patch document provider must return a mapping")
|
||||
if not isinstance(self.patch_bytes, bytes) or not self.patch_bytes:
|
||||
raise ValueError("patch provider must return non-empty bytes")
|
||||
if not isinstance(self.allowed_files, frozenset) or not self.allowed_files:
|
||||
raise ValueError("patch provider must return a frozen non-empty allowlist")
|
||||
if (
|
||||
not isinstance(self.commit_message, str)
|
||||
or not self.commit_message.strip()
|
||||
or self.commit_message != self.commit_message.strip()
|
||||
):
|
||||
raise ValueError("canonical commit message required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAuthority:
|
||||
"""Fresh host readback before a transaction-owner call.
|
||||
|
||||
``claim_fence`` is the TaskPlane receipt revision. It is deliberately
|
||||
separate from the WriterLease fence returned by the external transaction.
|
||||
"""
|
||||
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
authority_generation: int
|
||||
task_generation: int
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref.strip()
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id.strip()
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id.strip()
|
||||
):
|
||||
raise ValueError("host authority identity required")
|
||||
if (
|
||||
not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
):
|
||||
raise ValueError("verified host authority key fingerprint required")
|
||||
if (
|
||||
not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
):
|
||||
raise ValueError("verified host authority record digest required")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid host authority {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if (
|
||||
isinstance(self.expires_at, bool)
|
||||
or not isinstance(self.expires_at, (int, float))
|
||||
or not math.isfinite(float(self.expires_at))
|
||||
):
|
||||
raise ValueError("invalid host authority expiry")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAction:
|
||||
"""Readback owned by the existing external transaction boundary."""
|
||||
|
||||
action_id: str
|
||||
state: ActionState
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
task_generation: int
|
||||
authority_generation: int
|
||||
expected_branch_oid: str
|
||||
claim_receipt: Mapping[str, object]
|
||||
writer_lease_id: str | None = None
|
||||
writer_lease_fence: int | None = None
|
||||
result_oid: str | None = None
|
||||
receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.action_id, str) or _ACTION_ID.fullmatch(self.action_id) is None:
|
||||
raise ValueError("canonical immutable action_id required")
|
||||
if self.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
}:
|
||||
raise ValueError("invalid host patch action state")
|
||||
if not isinstance(self.binding_digest, str) or _DIGEST.fullmatch(self.binding_digest) is None:
|
||||
raise ValueError("action binding digest required")
|
||||
if not isinstance(self.effect_key, str) or not self.effect_key:
|
||||
raise ValueError("action effect key required")
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id
|
||||
or not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
or not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id
|
||||
or not isinstance(self.claim_receipt, Mapping)
|
||||
):
|
||||
raise ValueError("action authority/claim identity required")
|
||||
for name in ("claim_fence", "task_generation", "authority_generation"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid action {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if self.writer_lease_fence is not None and (
|
||||
isinstance(self.writer_lease_fence, bool)
|
||||
or not isinstance(self.writer_lease_fence, int)
|
||||
or self.writer_lease_fence < 0
|
||||
):
|
||||
raise ValueError("invalid WriterLease fence")
|
||||
if self.result_oid is not None:
|
||||
validate_oid(self.result_oid)
|
||||
if self.state == "COMMITTED":
|
||||
if (
|
||||
not self.writer_lease_id
|
||||
or self.writer_lease_fence is None
|
||||
or self.result_oid is None
|
||||
or not self.receipt_ref
|
||||
):
|
||||
raise ValueError("committed action lacks WriterLease/OID/receipt binding")
|
||||
|
||||
|
||||
class SignedHostPatchAuthorityReader:
|
||||
"""Build host authority only from a verified, purpose-bound current record.
|
||||
|
||||
The underlying reader enforces a registered key, signature, short lifetime,
|
||||
exact query hash, and a fresh nonce. This remains a freshness challenge;
|
||||
the root-action transaction owner still supplies the atomic fence and CAS.
|
||||
|
||||
Host configuration must register both effect and reconcile purposes to the
|
||||
same authority key id/raw-key fingerprint. Reconcile has a distinct signed
|
||||
query and grants observation only, while prepared identity remains the same
|
||||
stable authorization semantics across the two purposes.
|
||||
"""
|
||||
|
||||
def __init__(self, current_records: VerifiedCurrentRecordReader) -> None:
|
||||
if not isinstance(current_records, VerifiedCurrentRecordReader):
|
||||
raise TypeError("current_records must be VerifiedCurrentRecordReader")
|
||||
purposes = (
|
||||
HOST_PATCH_AUTHORITY_PURPOSE,
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE,
|
||||
)
|
||||
try:
|
||||
key_ids = [current_records.purpose_key_ids[purpose] for purpose in purposes]
|
||||
fingerprints = [
|
||||
current_records.purpose_key_fingerprints[purpose]
|
||||
for purpose in purposes
|
||||
]
|
||||
except KeyError as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile authority purposes must both be registered"
|
||||
) from exc
|
||||
if (
|
||||
any(len(ids) != 1 for ids in key_ids)
|
||||
or key_ids[0] != key_ids[1]
|
||||
or set(fingerprints[0]) != set(key_ids[0])
|
||||
or fingerprints[0] != fingerprints[1]
|
||||
):
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile purposes require one identical key/fingerprint"
|
||||
)
|
||||
self._records = current_records
|
||||
|
||||
@staticmethod
|
||||
def _query(
|
||||
binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema": (
|
||||
"aios.c6.host-patch-reconcile-authority-query.v1"
|
||||
if reconcile
|
||||
else "aios.c6.host-patch-authority-query.v1"
|
||||
),
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"claim": dict(claim),
|
||||
}
|
||||
|
||||
def _read(
|
||||
self, binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> HostPatchAuthority:
|
||||
purpose = (
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE
|
||||
if reconcile
|
||||
else HOST_PATCH_AUTHORITY_PURPOSE
|
||||
)
|
||||
verified = self._records.read(
|
||||
purpose, self._query(binding, claim, reconcile=reconcile)
|
||||
)
|
||||
record = verified.record
|
||||
if frozenset(record) != _AUTHORITY_RECORD_FIELDS:
|
||||
missing = sorted(_AUTHORITY_RECORD_FIELDS - frozenset(record))
|
||||
extra = sorted(frozenset(record) - _AUTHORITY_RECORD_FIELDS)
|
||||
raise HostPatchIntegrationError(
|
||||
f"host authority record fields are not exact; missing={missing!r} extra={extra!r}"
|
||||
)
|
||||
if (
|
||||
record["schema"] != HOST_PATCH_AUTHORITY_SCHEMA
|
||||
or record["lifecycle_state"] != "ACTIVE"
|
||||
or record["scope_state"] != "IN_SCOPE"
|
||||
or record["binding_digest"] != binding_digest(binding)
|
||||
or record["task_generation"] != binding.generation
|
||||
or record["claim_id"] != claim.get("claim_id")
|
||||
or record["claim_fence"] != claim.get("receipt_revision")
|
||||
or record["expected_branch_oid"] != binding.source_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority binding mismatch")
|
||||
if any(
|
||||
not isinstance(record[name], str) or not record[name].strip()
|
||||
for name in (
|
||||
"authorization_ref", "claim_id", "expected_branch_oid",
|
||||
"binding_digest", "lifecycle_state", "scope_state",
|
||||
)
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority text fields are invalid")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
if (
|
||||
isinstance(record[name], bool)
|
||||
or not isinstance(record[name], int)
|
||||
or record[name] < 0
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
f"signed host authority {name} is invalid"
|
||||
)
|
||||
if (
|
||||
isinstance(record["expires_at"], bool)
|
||||
or not isinstance(record["expires_at"], (int, float))
|
||||
or not math.isfinite(float(record["expires_at"]))
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority expiry is invalid")
|
||||
try:
|
||||
envelope_expiry = datetime.fromisoformat(
|
||||
verified.expires_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc).timestamp()
|
||||
authority = HostPatchAuthority(
|
||||
authorization_ref=record["authorization_ref"],
|
||||
authority_key_id=verified.key_id,
|
||||
authority_key_fingerprint=verified.key_fingerprint,
|
||||
# The signed envelope and its short observation expiry change
|
||||
# across fresh reads. Prepared identity binds only the stable
|
||||
# authorization semantics; freshness is independently checked
|
||||
# on every read.
|
||||
authority_record_sha256=hashlib.sha256(
|
||||
_canonical_json_bytes(
|
||||
{key: value for key, value in record.items() if key != "expires_at"}
|
||||
)
|
||||
).hexdigest(),
|
||||
authority_generation=record["authority_generation"], # type: ignore[arg-type]
|
||||
task_generation=record["task_generation"], # type: ignore[arg-type]
|
||||
claim_id=record["claim_id"],
|
||||
claim_fence=record["claim_fence"], # type: ignore[arg-type]
|
||||
expected_branch_oid=record["expected_branch_oid"],
|
||||
expires_at=min(float(record["expires_at"]), envelope_expiry),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HostPatchIntegrationError("signed host authority record is invalid") from exc
|
||||
if (
|
||||
not reconcile
|
||||
and authority.expires_at
|
||||
> float(claim.get("lease_until", authority.expires_at))
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority outlives the TaskPlane claim")
|
||||
return authority
|
||||
|
||||
def __call__(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(claim, TaskPlaneClaimReceiptV3):
|
||||
raise TypeError("v3 TaskPlane claim receipt required")
|
||||
return self._read(binding, claim.to_dict(), reconcile=False)
|
||||
|
||||
def read_for_action(
|
||||
self, binding: OperationBinding, action: HostPatchAction
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise TypeError("host patch action required")
|
||||
return self._read(binding, action.claim_receipt, reconcile=True)
|
||||
|
||||
|
||||
def signed_host_patch_authority_provider(
|
||||
current_records: VerifiedCurrentRecordReader,
|
||||
) -> SignedHostPatchAuthorityReader:
|
||||
"""Factory used by host composition without weakening reader type checks."""
|
||||
|
||||
return SignedHostPatchAuthorityReader(current_records)
|
||||
|
||||
|
||||
class HostPatchTransactionOwner(Protocol):
|
||||
"""Exact-key facade implemented by an existing durable transaction owner."""
|
||||
|
||||
def read_exact(self, effect_key: str) -> HostPatchAction | None: ...
|
||||
|
||||
def iter_pending_exact(
|
||||
self, *, limit: int
|
||||
) -> tuple[tuple[OperationBinding, HostPatchAction], ...]: ...
|
||||
|
||||
def mark_exact_closed(
|
||||
self, *, binding: OperationBinding, action_id: str,
|
||||
observation: ClosureObservation
|
||||
) -> None: ...
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
authority: HostPatchAuthority,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
|
||||
_EXACT_OWNER_METHODS = (
|
||||
"read_exact", "iter_pending_exact", "mark_exact_closed", "prepare_exact", "reconcile_exact",
|
||||
"execute_prepared"
|
||||
)
|
||||
|
||||
|
||||
def require_exact_root_action_owner(service: object) -> HostPatchTransactionOwner:
|
||||
"""Return an exact-key root-action facade or fail before enabling C6.
|
||||
|
||||
``TaskPlaneService.root_actions`` currently exposes ``run_once`` only, so a
|
||||
stock service is rejected. A production composition may install a facade
|
||||
backed by the same RootActionConsumer/PublicBridgeRootActionExecutor store;
|
||||
it must not create another effect ledger.
|
||||
"""
|
||||
|
||||
owner = getattr(service, "root_actions", None)
|
||||
missing = [name for name in _EXACT_OWNER_METHODS if not callable(getattr(owner, name, None))]
|
||||
if missing:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner unavailable: " + ",".join(missing)
|
||||
)
|
||||
if getattr(owner, "store", None) is not getattr(service, "store", None):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner must reuse the host TaskPlane store"
|
||||
)
|
||||
configured = getattr(owner, "assert_exact_owner_configured", None)
|
||||
if not callable(configured):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner configuration assertion unavailable"
|
||||
)
|
||||
try:
|
||||
configured()
|
||||
except Exception as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner is not configured"
|
||||
) from exc
|
||||
return owner # type: ignore[return-value]
|
||||
|
||||
|
||||
class HostPatchExecutor:
|
||||
"""Concrete ``GovernedSourcePatchPilot`` over host-owned formal providers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
artifact_provider: Callable[[OperationBinding], HostPatchArtifact],
|
||||
authority_provider: Callable[
|
||||
[OperationBinding, TaskPlaneClaimReceiptV3], HostPatchAuthority
|
||||
],
|
||||
transaction_owner: HostPatchTransactionOwner,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not callable(artifact_provider) or not callable(authority_provider):
|
||||
raise HostPatchConfigurationError("artifact and authority providers required")
|
||||
if not callable(getattr(authority_provider, "read_for_action", None)):
|
||||
raise HostPatchConfigurationError(
|
||||
"authority provider must support signed restart reconciliation"
|
||||
)
|
||||
if any(not callable(getattr(transaction_owner, name, None)) for name in _EXACT_OWNER_METHODS):
|
||||
raise HostPatchConfigurationError("exact external transaction owner required")
|
||||
self._artifacts = artifact_provider
|
||||
self._authority_provider = authority_provider
|
||||
self._transactions = transaction_owner
|
||||
self._clock = clock or time.time
|
||||
|
||||
def _artifact(self, binding: OperationBinding) -> tuple[HostPatchArtifact, ExecutablePatchV1]:
|
||||
artifact = self._artifacts(binding)
|
||||
if not isinstance(artifact, HostPatchArtifact):
|
||||
raise HostPatchIntegrationError("invalid host patch artifact")
|
||||
contract = parse_executable_patch(
|
||||
artifact.document,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
allowed_files=artifact.allowed_files,
|
||||
expected_binding=binding,
|
||||
)
|
||||
return artifact, contract
|
||||
|
||||
def _authority(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
value = self._authority_provider(binding, claim)
|
||||
if not isinstance(value, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid host authority readback")
|
||||
if (
|
||||
value.claim_id != claim.claim_id
|
||||
or value.claim_fence != claim.receipt_revision
|
||||
or value.task_generation != binding.generation
|
||||
or value.expected_branch_oid != binding.source_oid
|
||||
or value.expires_at <= float(self._clock())
|
||||
):
|
||||
raise HostPatchIntegrationError("stale or mismatched host authority")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _validate_action(
|
||||
action: HostPatchAction,
|
||||
binding: OperationBinding,
|
||||
*,
|
||||
claim_id: str | None = None,
|
||||
claim_fence: int | None = None,
|
||||
authority: HostPatchAuthority | None = None,
|
||||
) -> HostPatchAction:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise HostPatchIntegrationError("transaction owner returned invalid action")
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or action.task_generation != binding.generation
|
||||
or action.expected_branch_oid != binding.source_oid
|
||||
or (claim_id is not None and action.claim_id != claim_id)
|
||||
or (claim_fence is not None and action.claim_fence != claim_fence)
|
||||
):
|
||||
raise HostPatchIntegrationError("external action binding mismatch")
|
||||
if authority is not None and (
|
||||
action.authorization_ref != authority.authorization_ref
|
||||
or action.authority_key_id != authority.authority_key_id
|
||||
or action.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or action.authority_record_sha256 != authority.authority_record_sha256
|
||||
or action.authority_generation != authority.authority_generation
|
||||
or action.claim_id != authority.claim_id
|
||||
or action.claim_fence != authority.claim_fence
|
||||
or action.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("external action authority changed")
|
||||
if action.state == "FAILED_FINAL":
|
||||
raise HostPatchIntegrationError("external action failed final")
|
||||
return action
|
||||
|
||||
def read_intent(self, binding: OperationBinding) -> DurableExecutionIntent | None:
|
||||
"""C6 ``intent_reader`` wiring backed only by the transaction owner."""
|
||||
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
return None
|
||||
action = self._validate_action(action, binding)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, action.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def pending_intents(
|
||||
self, *, limit: int = 16
|
||||
) -> tuple[tuple[OperationBinding, DurableExecutionIntent], ...]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise HostPatchConfigurationError("bounded pending intent limit required")
|
||||
result: list[tuple[OperationBinding, DurableExecutionIntent]] = []
|
||||
for pending_binding, action in self._transactions.iter_pending_exact(limit=limit):
|
||||
checked = self._validate_action(action, pending_binding)
|
||||
if checked.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
raise HostPatchIntegrationError("transaction owner enumerated failed action")
|
||||
result.append(
|
||||
(
|
||||
pending_binding,
|
||||
DurableExecutionIntent(
|
||||
binding_digest(pending_binding),
|
||||
pending_binding.effect_key,
|
||||
checked.claim_id,
|
||||
checked.action_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
def mark_intent_closed(
|
||||
self, binding: OperationBinding, intent: DurableExecutionIntent,
|
||||
observation: ClosureObservation,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or not isinstance(observation, ClosureObservation)
|
||||
):
|
||||
raise HostPatchIntegrationError("invalid durable closure acknowledgement")
|
||||
self._transactions.mark_exact_closed(
|
||||
binding=binding, action_id=intent.receipt_ref, observation=observation
|
||||
)
|
||||
|
||||
def prepare_intent(
|
||||
self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> DurableExecutionIntent:
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.prepare_exact(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
)
|
||||
action = self._validate_action(
|
||||
action,
|
||||
binding,
|
||||
claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision,
|
||||
authority=authority,
|
||||
)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, claim.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or intent.claim_id != claim.claim_id
|
||||
):
|
||||
raise HostPatchIntegrationError("execution intent binding mismatch")
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(
|
||||
action, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = self._authority(binding, claim)
|
||||
if (
|
||||
fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority changed before external call")
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(
|
||||
observed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state == "COMMITTED":
|
||||
return
|
||||
if observed.state in {"IN_PROGRESS", "EFFECT_UNKNOWN"}:
|
||||
raise HostPatchEffectUnknown("external effect requires exact-key reconciliation")
|
||||
if observed.state != "PREPARED":
|
||||
raise HostPatchIntegrationError("external action is not executable")
|
||||
|
||||
completed = self._transactions.execute_prepared(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
completed = self._validate_action(
|
||||
completed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if completed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if completed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external transaction did not return committed readback")
|
||||
|
||||
def reconcile(
|
||||
self, *, binding: OperationBinding, intent: DurableExecutionIntent
|
||||
) -> None:
|
||||
"""Reconcile a durable intent without acquiring a lease or resending a commit."""
|
||||
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
):
|
||||
raise HostPatchIntegrationError("reconciliation intent binding mismatch")
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(action, binding, claim_id=intent.claim_id)
|
||||
reader = self._authority_provider
|
||||
authority = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if not isinstance(authority, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid reconciliation authority readback")
|
||||
action = self._validate_action(action, binding, authority=authority)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if (
|
||||
not isinstance(fresh, HostPatchAuthority)
|
||||
or fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
"host authority changed before reconciliation call"
|
||||
)
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(observed, binding, authority=authority)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external effect remains unresolved")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HostPatchAction",
|
||||
"HostPatchArtifact",
|
||||
"HostPatchAuthority",
|
||||
"HostPatchConfigurationError",
|
||||
"HostPatchEffectUnknown",
|
||||
"HostPatchExecutor",
|
||||
"HostPatchIntegrationError",
|
||||
"HostPatchTransactionOwner",
|
||||
"HOST_PATCH_AUTHORITY_PURPOSE",
|
||||
"HOST_PATCH_AUTHORITY_SCHEMA",
|
||||
"SignedHostPatchAuthorityReader",
|
||||
"require_exact_root_action_owner",
|
||||
"signed_host_patch_authority_provider",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Authenticated host readback for independent verification and acceptance.
|
||||
|
||||
The host supplies separate, registered signing identities and current-record
|
||||
transports. This adapter never creates receipts or treats an ``authenticated``
|
||||
payload flag as evidence. Missing providers remain UNKNOWN through OperationReaders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
from .operation_view import OperationBinding, binding_digest, validate_digest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReceiptAuthorityPolicy:
|
||||
verifier_id: str
|
||||
verifier_key_id: str
|
||||
acceptor_id: str
|
||||
acceptor_key_id: str
|
||||
acceptance_revision: str
|
||||
verifier_key_fingerprint: str
|
||||
acceptor_key_fingerprint: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in asdict(self).items():
|
||||
if not isinstance(value, str) or not value or value.strip() != value or "\x00" in value:
|
||||
raise ValueError(f"invalid {name}")
|
||||
if self.verifier_id == self.acceptor_id or self.verifier_key_id == self.acceptor_key_id:
|
||||
raise ValueError("verification and acceptance must use independent identities and keys")
|
||||
validate_digest(self.verifier_key_fingerprint)
|
||||
validate_digest(self.acceptor_key_fingerprint)
|
||||
if self.verifier_key_fingerprint == self.acceptor_key_fingerprint:
|
||||
raise ValueError("verification and acceptance require independent public keys")
|
||||
|
||||
|
||||
class HostOperationReceiptReaders:
|
||||
"""Bind verified transport signatures to host-pinned role identities.
|
||||
|
||||
Policies are trusted host configuration, never request or receipt fields.
|
||||
Receipt/result linkage and freshness are also checked by collect_closure /
|
||||
derive_closure against the separately observed Git effect.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_reader: VerifiedCurrentRecordReader,
|
||||
acceptance_reader: VerifiedCurrentRecordReader,
|
||||
policy: ReceiptAuthorityPolicy,
|
||||
) -> None:
|
||||
self._verifier = verifier_reader
|
||||
self._acceptance = acceptance_reader
|
||||
self._policy = policy
|
||||
|
||||
def _read(self, binding: OperationBinding, *, acceptance: bool) -> dict[str, object]:
|
||||
policy = self._policy
|
||||
identity = policy.acceptor_id if acceptance else policy.verifier_id
|
||||
key_id = policy.acceptor_key_id if acceptance else policy.verifier_key_id
|
||||
if binding.actor_id in (policy.verifier_id, policy.acceptor_id):
|
||||
raise ValueError("executor cannot verify or accept its own operation")
|
||||
kind = "acceptance" if acceptance else "verification"
|
||||
query = {
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"expected_identity": identity,
|
||||
"expected_key_id": key_id,
|
||||
"expected_key_fingerprint": policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint,
|
||||
}
|
||||
if acceptance:
|
||||
query["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
reader = self._acceptance if acceptance else self._verifier
|
||||
verified = reader.read(f"operation-{kind}", query)
|
||||
if verified.key_id != key_id:
|
||||
raise ValueError("receipt signer does not match the pinned role key")
|
||||
fingerprint = policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint
|
||||
if verified.key_fingerprint != fingerprint:
|
||||
raise ValueError("receipt signer public key fingerprint mismatch")
|
||||
document = verified.record
|
||||
if not isinstance(document, Mapping) or set(document) != {"schema", kind}:
|
||||
raise ValueError("invalid signed receipt document")
|
||||
schema = f"aios.operation-{kind}-observation.v1"
|
||||
if document["schema"] != schema:
|
||||
raise ValueError("invalid receipt schema")
|
||||
observation = document[kind]
|
||||
common = {"binding_digest", "receipt_ref", "result_artifact_digest", "outcome", "observed_at", "expires_at"}
|
||||
fields = common | (
|
||||
{"acceptor_id", "acceptance_revision", "verifier_receipt_ref"}
|
||||
if acceptance else {"verifier_id", "verification_receipt_ref"}
|
||||
)
|
||||
if not isinstance(observation, Mapping) or set(observation) != fields:
|
||||
raise ValueError("invalid signed receipt observation")
|
||||
if observation["binding_digest"] != binding_digest(binding):
|
||||
raise ValueError("receipt binding mismatch")
|
||||
if observation["acceptor_id" if acceptance else "verifier_id"] != identity:
|
||||
raise ValueError("receipt identity mismatch")
|
||||
if acceptance and observation["acceptance_revision"] != policy.acceptance_revision:
|
||||
raise ValueError("acceptance revision mismatch")
|
||||
result: dict[str, object] = {
|
||||
"schema": schema,
|
||||
kind: {**observation, "authenticated": True},
|
||||
}
|
||||
if acceptance:
|
||||
result["expected_acceptor_id"] = policy.acceptor_id
|
||||
result["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
return result
|
||||
|
||||
def verifier(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=False)
|
||||
|
||||
def acceptance(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=True)
|
||||
@@ -29,6 +29,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
@@ -37,7 +38,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
@@ -63,6 +64,17 @@ class LiveProtocolUnavailable(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class IssuerResponseError(LiveProtocolError):
|
||||
"""Safe, structured issuer failure metadata for isolated diagnostics."""
|
||||
|
||||
def __init__(self, message: str, *, status: int, error_code: str | None,
|
||||
operation: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.operation = operation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResult:
|
||||
status: int
|
||||
@@ -205,11 +217,14 @@ class LiveClaim:
|
||||
fencing_generation: int | None
|
||||
lease_expires_at: float | None
|
||||
task_revision: int | None
|
||||
public_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterLease:
|
||||
writer_lease_id: str
|
||||
# /v2/writer-lease is a validated binding projection. The durable branch
|
||||
# lease is created only by the continuation executor during commit.
|
||||
writer_lease_id: str | None
|
||||
task_id: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
@@ -229,13 +244,16 @@ class LiveWriterSession:
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
writer_lease_id: str
|
||||
writer_lease_id: str | None
|
||||
continuation_key: str
|
||||
principal_issue_schema: str
|
||||
agent_token_handle: str = field(repr=False)
|
||||
lease_handle: str = field(repr=False)
|
||||
task_lease_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
writer_lease_binding: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
public = {
|
||||
"authorization_id": self.authorization_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
@@ -243,10 +261,11 @@ class LiveWriterSession:
|
||||
"job_id": self.job_id,
|
||||
"branch_mode": self.branch_mode,
|
||||
"expected_branch_oid": self.expected_branch_oid,
|
||||
"writer_lease_id": self.writer_lease_id,
|
||||
"continuation_key": self.continuation_key,
|
||||
"principal_issue_schema": self.principal_issue_schema,
|
||||
"writer_lease_binding": dict(self.writer_lease_binding),
|
||||
}
|
||||
return public
|
||||
|
||||
|
||||
def _parse_epoch(value: Any) -> float | None:
|
||||
@@ -420,6 +439,10 @@ class LiveAgentTaskClient:
|
||||
if result.body.get("receipt_revision") is not None
|
||||
else None
|
||||
),
|
||||
public_receipt={k: result.body[k] for k in (
|
||||
"task_id", "status", "claim_id", "lease_id", "fencing_generation",
|
||||
"receipt_revision", "lease_expires_at", "attempt", "revision",
|
||||
) if k in result.body},
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
@@ -456,6 +479,49 @@ class LiveAgentTaskClient:
|
||||
)
|
||||
return task
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_handle: str,
|
||||
idempotency_key: str,
|
||||
result_value: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 160:
|
||||
raise LiveProtocolError("task result idempotency key invalid")
|
||||
if not isinstance(result_value, Mapping):
|
||||
raise LiveProtocolError("task result mapping required")
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/result",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": idempotency_key,
|
||||
"result": dict(result_value),
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task result conflict")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task result HTTP {result.status}")
|
||||
if (
|
||||
result.body.get("task_id") != task_id
|
||||
or result.body.get("status") != "VERIFYING"
|
||||
or result.body.get("claimed_by") != agent_id
|
||||
or result.body.get("result") != dict(result_value)
|
||||
):
|
||||
raise LiveProtocolConflict("task result readback mismatch")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveTaskPrincipalClient:
|
||||
def __init__(
|
||||
@@ -468,6 +534,7 @@ class LiveTaskPrincipalClient:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store
|
||||
self.public_receipts: list[dict[str, Any]] = []
|
||||
|
||||
def issue_assertion(
|
||||
self,
|
||||
@@ -499,6 +566,11 @@ class LiveTaskPrincipalClient:
|
||||
last = result
|
||||
assertion = result.body.get("assertion")
|
||||
if result.status in (200, 201) and isinstance(assertion, str) and assertion:
|
||||
self.public_receipts.append({
|
||||
"task_id": task_id, "operation": operation, "request_id": request_id,
|
||||
"assertion_sha256": hashlib.sha256(assertion.encode()).hexdigest(),
|
||||
"issuer_response": {k: result.body[k] for k in ("jti", "expires_at", "replayed") if k in result.body},
|
||||
})
|
||||
return assertion
|
||||
if not (
|
||||
attempt == 0
|
||||
@@ -508,8 +580,14 @@ class LiveTaskPrincipalClient:
|
||||
break
|
||||
time.sleep(1)
|
||||
status = 0 if last is None else last.status
|
||||
raise LiveProtocolError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}"
|
||||
raw_code = None if last is None else last.body.get("error")
|
||||
error_code = (
|
||||
raw_code if isinstance(raw_code, str)
|
||||
and re.fullmatch(r"(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}", raw_code) else None
|
||||
)
|
||||
raise IssuerResponseError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}",
|
||||
status=status, error_code=error_code, operation=operation,
|
||||
)
|
||||
|
||||
|
||||
@@ -608,23 +686,35 @@ class LiveBridgeClient:
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"writer lease HTTP {result.status}")
|
||||
body = result.body
|
||||
if body.get("schema") != "aios.task-writer-lease.v2":
|
||||
raise LiveProtocolError("writer lease schema mismatch")
|
||||
if body.get("task_id") != task_id:
|
||||
raise LiveProtocolError("writer lease task mismatch")
|
||||
if body.get("expected_branch_oid") not in (None, expected_branch_oid):
|
||||
raise LiveProtocolConflict("writer lease expected_branch_oid mismatch")
|
||||
writer_lease_id = body.get("writer_lease_id") or body.get("lease_id")
|
||||
if not isinstance(writer_lease_id, str) or not writer_lease_id:
|
||||
raise LiveProtocolError("writer lease response missing lease identifier")
|
||||
expected_binding = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
for field, expected in expected_binding.items():
|
||||
if body.get(field) != expected:
|
||||
raise LiveProtocolConflict(f"writer lease {field} mismatch")
|
||||
projected_branch_mode = body.get("branch_mode")
|
||||
allowed_branch_modes = {branch_mode}
|
||||
# CREATE_NEW is materialized by the Bridge before it returns the
|
||||
# descriptor, so the returned binding is CONTINUE_EXISTING.
|
||||
if branch_mode == "CREATE_NEW":
|
||||
allowed_branch_modes.add("CONTINUE_EXISTING")
|
||||
if projected_branch_mode not in allowed_branch_modes:
|
||||
raise LiveProtocolConflict("writer lease branch_mode mismatch")
|
||||
return LiveWriterLease(
|
||||
writer_lease_id=writer_lease_id,
|
||||
writer_lease_id=None,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
branch_mode=branch_mode,
|
||||
branch_mode=str(projected_branch_mode),
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=int(generation),
|
||||
expires_at=_parse_epoch(body.get("expires_at")),
|
||||
expires_at=None,
|
||||
continuation_key=continuation_key,
|
||||
raw=dict(body),
|
||||
)
|
||||
@@ -696,10 +786,16 @@ class LiveGovernedWriterExecutor:
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
authorization_ref: str,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
agent_id: str | None = None,
|
||||
scope_enrollment: Callable[..., Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.owner_policy = owner_policy
|
||||
self.authorization_ref = authorization_ref
|
||||
if scope_enrollment is not None and (not callable(scope_enrollment) or not agent_id):
|
||||
raise ValueError("scope enrollment requires a configured agent identity")
|
||||
self.agent_id = agent_id
|
||||
self.scope_enrollment = scope_enrollment
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secret_store = SecretHandleStore()
|
||||
self.agent = LiveAgentTaskClient(
|
||||
@@ -759,6 +855,7 @@ class LiveGovernedWriterExecutor:
|
||||
task_id=request.task_id,
|
||||
capabilities=sorted(request.required_capabilities),
|
||||
route=route,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
claim = self.agent.claim(
|
||||
task_id=request.task_id,
|
||||
@@ -778,6 +875,21 @@ class LiveGovernedWriterExecutor:
|
||||
"remaining governed task window too short after heartbeat"
|
||||
)
|
||||
|
||||
if self.scope_enrollment is not None:
|
||||
# The existing issuer and Bridge still verify every assertion.
|
||||
# Enrollment only happens after a real claim has established the
|
||||
# agent/attempt/lease binding; failures stop before any assertion.
|
||||
enrollment = self.scope_enrollment(
|
||||
request=request, job_id=job_id, agent_id=agent_id,
|
||||
claim=claim, task=running,
|
||||
)
|
||||
if (not isinstance(enrollment, Mapping)
|
||||
or enrollment.get("status") != "ENROLLED"
|
||||
or enrollment.get("task_id") != request.task_id
|
||||
or enrollment.get("agent_id") != agent_id
|
||||
or enrollment.get("job_id") != job_id):
|
||||
raise LiveProtocolConflict("verified principal scope enrollment required")
|
||||
|
||||
live_head = self.git.branch_head(request.target_branch)
|
||||
if live_head is None:
|
||||
branch_mode = "CREATE_NEW"
|
||||
@@ -815,7 +927,10 @@ class LiveGovernedWriterExecutor:
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
continuation_key=writer.continuation_key,
|
||||
principal_issue_schema=self.config.issue_schema,
|
||||
agent_token_handle=agent_token_handle,
|
||||
lease_handle=claim.lease_handle,
|
||||
task_lease_receipt=dict(claim.public_receipt),
|
||||
writer_lease_binding=dict(writer.raw),
|
||||
)
|
||||
|
||||
def commit_and_readback(
|
||||
@@ -857,3 +972,19 @@ class LiveGovernedWriterExecutor:
|
||||
"branch_head": new_head,
|
||||
"readback_sha256": readback,
|
||||
}
|
||||
|
||||
def submit_task_result(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
result: Mapping[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Mapping[str, Any]:
|
||||
return self.agent.submit_result(
|
||||
task_id=session.task_id,
|
||||
agent_id=session.agent_id,
|
||||
agent_token_handle=session.agent_token_handle,
|
||||
lease_handle=session.lease_handle,
|
||||
idempotency_key=idempotency_key,
|
||||
result_value=result,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -74,7 +75,13 @@ def _now_ts() -> float:
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
def compute_sha256_hex(value: Any) -> str:
|
||||
@@ -532,10 +539,16 @@ class EvidenceEnvelope:
|
||||
worker_identity: str
|
||||
payload: dict[str, Any]
|
||||
created_at: float
|
||||
expires_at: float
|
||||
previous_evidence_hash: str
|
||||
evidence_digest: str
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signing_key_id: str | None = None
|
||||
verifier_identity: str | None = None
|
||||
authority_domain: str | None = None
|
||||
sequence: int | None = None
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -551,7 +564,17 @@ class EvidenceEnvelope:
|
||||
attestation_ref: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> "EvidenceEnvelope":
|
||||
issued_at = float(_now_ts() if created_at is None else created_at)
|
||||
valid_until = float(issued_at + 300.0 if expires_at is None else expires_at)
|
||||
if not math.isfinite(issued_at) or not math.isfinite(valid_until):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
envelope = cls(
|
||||
schema_version=1,
|
||||
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
|
||||
@@ -560,11 +583,17 @@ class EvidenceEnvelope:
|
||||
kind=validate_type(kind, "evidence kind"),
|
||||
worker_identity=str(worker_identity),
|
||||
payload=redact_value(payload),
|
||||
created_at=float(created_at or _now_ts()),
|
||||
created_at=issued_at,
|
||||
expires_at=valid_until,
|
||||
previous_evidence_hash=str(previous_evidence_hash or ""),
|
||||
evidence_digest="",
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
return dataclass_replace(
|
||||
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
|
||||
@@ -580,8 +609,14 @@ class EvidenceEnvelope:
|
||||
"worker_identity": self.worker_identity,
|
||||
"payload": self.payload,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"previous_evidence_hash": self.previous_evidence_hash,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"signing_key_id": self.signing_key_id,
|
||||
"verifier_identity": self.verifier_identity,
|
||||
"authority_domain": self.authority_domain,
|
||||
"sequence": self.sequence,
|
||||
}
|
||||
|
||||
def verify(
|
||||
@@ -591,18 +626,40 @@ class EvidenceEnvelope:
|
||||
expected_claim_id: str,
|
||||
expected_previous_hash: str,
|
||||
attestation_verifier: Any = None,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
observed_at = float(_now_ts() if now is None else now)
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (observed_at, self.created_at, self.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(self.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
self.schema_version != 1
|
||||
not finite_timestamps
|
||||
or self.schema_version != 1
|
||||
or self.task_id != expected_task_id
|
||||
or self.claim_id != expected_claim_id
|
||||
or self.previous_evidence_hash != expected_previous_hash
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
or self.created_at > observed_at
|
||||
or self.expires_at <= observed_at
|
||||
or self.expires_at <= self.created_at
|
||||
or self.evidence_digest != expected_digest
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
# An unsigned envelope has no proof of origin and must never verify.
|
||||
if not (self.signature or self.attestation_ref):
|
||||
return False
|
||||
# Every proof form requires an injected verifier. A reference or
|
||||
# signature must never become trusted merely because it is present.
|
||||
if attestation_verifier is None:
|
||||
return False
|
||||
try:
|
||||
return bool(attestation_verifier(self))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -611,6 +668,41 @@ class EvidenceEnvelope:
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "EvidenceEnvelope":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("evidence envelope must be an object")
|
||||
try:
|
||||
envelope = cls(
|
||||
schema_version=int(value["schema_version"]),
|
||||
evidence_id=validate_identifier(value["evidence_id"], "evidence_id"),
|
||||
task_id=validate_identifier(value["task_id"], "task_id"),
|
||||
claim_id=validate_identifier(value["claim_id"], "claim_id"),
|
||||
kind=validate_type(value["kind"], "evidence kind"),
|
||||
worker_identity=str(value["worker_identity"]),
|
||||
payload=redact_value(value["payload"]),
|
||||
created_at=float(value["created_at"]),
|
||||
expires_at=float(value["expires_at"]),
|
||||
previous_evidence_hash=str(value["previous_evidence_hash"]),
|
||||
evidence_digest=str(value["evidence_digest"]),
|
||||
signature=value.get("signature"),
|
||||
attestation_ref=value.get("attestation_ref"),
|
||||
signature_algorithm=value.get("signature_algorithm"),
|
||||
signing_key_id=value.get("signing_key_id"),
|
||||
verifier_identity=value.get("verifier_identity"),
|
||||
authority_domain=value.get("authority_domain"),
|
||||
sequence=(
|
||||
None if value.get("sequence") is None else int(value["sequence"])
|
||||
),
|
||||
)
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
return envelope
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid evidence envelope") from exc
|
||||
|
||||
|
||||
class ProbeOutcome(StrEnum):
|
||||
NOT_APPLIED = "NOT_APPLIED"
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Strict adapters from existing authority readers into C6 observations.
|
||||
|
||||
Readers are injected by the trusted composition root. They are query-only:
|
||||
this module has no controller/reconcile, signer, store, or network factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .operation_view import (
|
||||
AcceptanceObservation,
|
||||
ClosureObservation,
|
||||
DomainStamp,
|
||||
EffectObservation,
|
||||
Fact,
|
||||
OperationBinding,
|
||||
OperationObservation,
|
||||
VerificationObservation,
|
||||
binding_digest,
|
||||
)
|
||||
|
||||
|
||||
Reader = Callable[[OperationBinding], Mapping[str, object]]
|
||||
|
||||
_STAMP_FIELDS = frozenset({"domain", "object_id", "generation", "revision", "event_id"})
|
||||
_FACT_FIELDS = frozenset(
|
||||
{"value", "stamp", "binding_digest", "observed_at", "expires_at", "evidence_refs"}
|
||||
)
|
||||
_DECLARATION_FIELDS = frozenset({"schema", "declared", "installed", "callable"})
|
||||
_TASK_FIELDS = frozenset({"schema", "preconditions"})
|
||||
_AUTHORIZATION_FIELDS = frozenset({"schema", "authorized"})
|
||||
_EFFECT_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"outcome",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"actor_id",
|
||||
"stamp",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"trust_status",
|
||||
}
|
||||
)
|
||||
_GITOPS_FIELDS = frozenset({"schema", "effect"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"verification_receipt_ref",
|
||||
}
|
||||
)
|
||||
_VERIFIER_FIELDS = frozenset({"schema", "verification"})
|
||||
_ACCEPTANCE_OBSERVATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_receipt_ref",
|
||||
"acceptance_revision",
|
||||
"acceptor_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
_ACCEPTANCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"acceptance",
|
||||
"expected_acceptor_id",
|
||||
"expected_acceptance_revision",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationReaders:
|
||||
declaration: Reader | None
|
||||
task: Reader | None
|
||||
authorization: Reader | None
|
||||
gitops: Reader | None
|
||||
verifier: Reader | None
|
||||
acceptance: Reader | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"declaration",
|
||||
"task",
|
||||
"authorization",
|
||||
"gitops",
|
||||
"verifier",
|
||||
"acceptance",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not callable(value):
|
||||
raise ValueError(f"{name} reader must be callable or None")
|
||||
|
||||
|
||||
def _exact_mapping(value: object, fields: frozenset[str], name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValueError(f"{name} has unknown or missing fields")
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} keys must be strings")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(value: object, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _text(value, name)
|
||||
|
||||
|
||||
def _datetime(value: object, name: str) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
value = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} is not an RFC3339 datetime") from exc
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(value: object, name: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an array")
|
||||
return tuple(_text(item, name) for item in value)
|
||||
|
||||
|
||||
def _stamp(value: object) -> DomainStamp | None:
|
||||
if value is None:
|
||||
return None
|
||||
raw = _exact_mapping(value, _STAMP_FIELDS, "stamp")
|
||||
return DomainStamp(
|
||||
domain=_text(raw["domain"], "domain"),
|
||||
object_id=_text(raw["object_id"], "object_id"),
|
||||
generation=raw["generation"], # type: ignore[arg-type]
|
||||
revision=raw["revision"], # type: ignore[arg-type]
|
||||
event_id=_text(raw["event_id"], "event_id"),
|
||||
)
|
||||
|
||||
|
||||
def _fact(value: object) -> Fact:
|
||||
raw = _exact_mapping(value, _FACT_FIELDS, "fact")
|
||||
return Fact(
|
||||
value=raw["value"], # type: ignore[arg-type]
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
evidence_refs=_tuple_of_text(raw["evidence_refs"], "evidence_refs"),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_fact(binding: OperationBinding, code: str) -> Fact:
|
||||
return Fact(
|
||||
value="UNKNOWN",
|
||||
stamp=None,
|
||||
binding_digest=binding_digest(binding),
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
evidence_refs=(f"operation-readback:{code}",),
|
||||
)
|
||||
|
||||
|
||||
def _fact_candidates(value: object) -> tuple[Fact, ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return (_fact(value),)
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
if not value:
|
||||
raise ValueError("fact candidate list is empty")
|
||||
return tuple(_fact(item) for item in value)
|
||||
raise ValueError("fact must be an object or non-empty candidate list")
|
||||
|
||||
|
||||
def _select_fact(
|
||||
value: object, binding: OperationBinding, *, expected_domain: str
|
||||
) -> Fact:
|
||||
candidates = _fact_candidates(value)
|
||||
digest = binding_digest(binding)
|
||||
if any(item.binding_digest != digest for item in candidates):
|
||||
return _unknown_fact(binding, "BINDING_MISMATCH")
|
||||
if any(item.stamp is None for item in candidates):
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return _unknown_fact(binding, "INVALID_CANDIDATE_SET")
|
||||
stamps = [item.stamp for item in candidates]
|
||||
assert all(stamp is not None for stamp in stamps)
|
||||
domains = {(stamp.domain, stamp.object_id) for stamp in stamps if stamp is not None}
|
||||
if len(domains) != 1 or next(iter(domains))[0] != expected_domain:
|
||||
return _unknown_fact(binding, "CROSS_DOMAIN_CANDIDATES")
|
||||
highest = max((stamp.generation, stamp.revision) for stamp in stamps if stamp is not None)
|
||||
current = [
|
||||
item
|
||||
for item in candidates
|
||||
if item.stamp is not None
|
||||
and (item.stamp.generation, item.stamp.revision) == highest
|
||||
]
|
||||
if len(current) > 1:
|
||||
first = current[0]
|
||||
# Event identity may differ for an idempotent duplicate. Every semantic
|
||||
# field must still agree at the same authority revision.
|
||||
semantic = (
|
||||
first.value,
|
||||
first.binding_digest,
|
||||
first.observed_at,
|
||||
first.expires_at,
|
||||
first.evidence_refs,
|
||||
)
|
||||
if any(
|
||||
(
|
||||
item.value,
|
||||
item.binding_digest,
|
||||
item.observed_at,
|
||||
item.expires_at,
|
||||
item.evidence_refs,
|
||||
)
|
||||
!= semantic
|
||||
for item in current[1:]
|
||||
):
|
||||
return _unknown_fact(binding, "SAME_REVISION_CONFLICT")
|
||||
return current[0]
|
||||
|
||||
|
||||
def _read(reader: Reader | None, binding: OperationBinding) -> Mapping[str, object] | None:
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
value = reader(binding)
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def collect_observation(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> OperationObservation:
|
||||
"""Query three authority seams and build a typed preflight observation."""
|
||||
_datetime(now, "now")
|
||||
declared = installed = callable_fact = _unknown_fact(binding, "DECLARATION_UNAVAILABLE")
|
||||
raw = _read(readers.declaration, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _DECLARATION_FIELDS, "declaration reader result")
|
||||
if doc["schema"] != "aios.operation-declaration-observation.v1":
|
||||
raise ValueError("unsupported declaration schema")
|
||||
declared = _select_fact(
|
||||
doc["declared"], binding, expected_domain="declaration"
|
||||
)
|
||||
installed = _select_fact(
|
||||
doc["installed"], binding, expected_domain="runtime"
|
||||
)
|
||||
callable_fact = _select_fact(
|
||||
doc["callable"], binding, expected_domain="runtime"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
declared = installed = callable_fact = _unknown_fact(
|
||||
binding, "INVALID_DECLARATION_OBSERVATION"
|
||||
)
|
||||
|
||||
preconditions = _unknown_fact(binding, "TASK_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.task, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _TASK_FIELDS, "task reader result")
|
||||
if doc["schema"] != "aios.operation-task-observation.v1":
|
||||
raise ValueError("unsupported task schema")
|
||||
preconditions = _select_fact(
|
||||
doc["preconditions"], binding, expected_domain="task"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
preconditions = _unknown_fact(binding, "INVALID_TASK_OBSERVATION")
|
||||
|
||||
authorized = _unknown_fact(binding, "AUTHORIZATION_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.authorization, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _AUTHORIZATION_FIELDS, "authorization reader result")
|
||||
if doc["schema"] != "aios.operation-authorization-observation.v1":
|
||||
raise ValueError("unsupported authorization schema")
|
||||
authorized = _select_fact(
|
||||
doc["authorized"], binding, expected_domain="authorization"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
authorized = _unknown_fact(binding, "INVALID_AUTHORIZATION_OBSERVATION")
|
||||
|
||||
return OperationObservation(
|
||||
binding=binding,
|
||||
declared=declared,
|
||||
installed=installed,
|
||||
callable=callable_fact,
|
||||
authorized=authorized,
|
||||
preconditions=preconditions,
|
||||
)
|
||||
|
||||
|
||||
def _unknown_effect(binding: OperationBinding) -> EffectObservation:
|
||||
return EffectObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
outcome="UNKNOWN",
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
actor_id=None,
|
||||
stamp=None,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
trust_status="UNTRUSTED",
|
||||
)
|
||||
|
||||
|
||||
def _effect(value: object) -> EffectObservation:
|
||||
raw = _exact_mapping(value, _EFFECT_FIELDS, "effect")
|
||||
return EffectObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
actor_id=_optional_text(raw["actor_id"], "actor_id"),
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
trust_status=raw["trust_status"], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _unknown_verification(binding: OperationBinding) -> VerificationObservation:
|
||||
return VerificationObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_id=None,
|
||||
outcome="UNAVAILABLE",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
verification_receipt_ref=None,
|
||||
)
|
||||
|
||||
|
||||
def _verification(value: object) -> VerificationObservation:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
return VerificationObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_id=_optional_text(raw["verifier_id"], "verifier_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
verification_receipt_ref=_optional_text(
|
||||
raw["verification_receipt_ref"], "verification_receipt_ref"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_acceptance(binding: OperationBinding) -> AcceptanceObservation:
|
||||
return AcceptanceObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_receipt_ref=None,
|
||||
acceptance_revision=None,
|
||||
acceptor_id=None,
|
||||
outcome="UNKNOWN",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _acceptance(value: object) -> AcceptanceObservation:
|
||||
raw = _exact_mapping(value, _ACCEPTANCE_OBSERVATION_FIELDS, "acceptance")
|
||||
return AcceptanceObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_receipt_ref=_optional_text(
|
||||
raw["verifier_receipt_ref"], "verifier_receipt_ref"
|
||||
),
|
||||
acceptance_revision=_optional_text(
|
||||
raw["acceptance_revision"], "acceptance_revision"
|
||||
),
|
||||
acceptor_id=_optional_text(raw["acceptor_id"], "acceptor_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
)
|
||||
|
||||
|
||||
def collect_closure(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureObservation:
|
||||
"""Read closure claims independently; absent adapters remain UNKNOWN."""
|
||||
_datetime(now, "now")
|
||||
effect = _unknown_effect(binding)
|
||||
raw = _read(readers.gitops, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _GITOPS_FIELDS, "gitops reader result")
|
||||
if doc["schema"] != "aios.operation-effect-observation.v1":
|
||||
raise ValueError("unsupported effect schema")
|
||||
effect = _effect(doc["effect"])
|
||||
except (TypeError, ValueError):
|
||||
effect = _unknown_effect(binding)
|
||||
|
||||
verification = _unknown_verification(binding)
|
||||
raw = _read(readers.verifier, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _VERIFIER_FIELDS, "verifier reader result")
|
||||
if doc["schema"] != "aios.operation-verification-observation.v1":
|
||||
raise ValueError("unsupported verifier schema")
|
||||
verification = _verification(doc["verification"])
|
||||
except (TypeError, ValueError):
|
||||
verification = _unknown_verification(binding)
|
||||
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
expected_acceptor_id = "unresolved-acceptor"
|
||||
expected_acceptance_revision = "unresolved-revision"
|
||||
raw = _read(readers.acceptance, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _ACCEPTANCE_FIELDS, "acceptance reader result")
|
||||
if doc["schema"] != "aios.operation-acceptance-observation.v1":
|
||||
raise ValueError("unsupported acceptance schema")
|
||||
acceptance = _acceptance(doc["acceptance"])
|
||||
expected_acceptor_id = _text(
|
||||
doc["expected_acceptor_id"], "expected_acceptor_id"
|
||||
)
|
||||
expected_acceptance_revision = _text(
|
||||
doc["expected_acceptance_revision"],
|
||||
"expected_acceptance_revision",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
|
||||
return ClosureObservation(
|
||||
effect=effect,
|
||||
verification=verification,
|
||||
acceptance=acceptance,
|
||||
expected_acceptor_id=expected_acceptor_id,
|
||||
expected_acceptance_revision=expected_acceptance_revision,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OperationReaders", "collect_closure", "collect_observation"]
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Read-only C6 operation observations and fail-closed reductions.
|
||||
|
||||
This module deliberately owns no persistence, signer, lease, or mutation port.
|
||||
Its digests are local to the C6 operation-binding schema and do not replace any
|
||||
existing domain encoder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
FactValue = Literal["TRUE", "FALSE", "UNKNOWN"]
|
||||
PreflightState = Literal[
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PRECONDITION_FAILED",
|
||||
"UNKNOWN",
|
||||
"READY",
|
||||
]
|
||||
EffectOutcome = Literal["COMMITTED", "NOT_COMMITTED", "UNKNOWN"]
|
||||
VerificationOutcome = Literal["PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"]
|
||||
AcceptanceOutcome = Literal["ACCEPTED", "REJECTED", "UNKNOWN"]
|
||||
ClosureOutcome = Literal["SUCCEEDED", "PARTIAL", "OUTCOME_UNKNOWN"]
|
||||
EffectReconciliation = Literal[
|
||||
"REBUILD_RECEIPT_REFERENCE",
|
||||
"REVALIDATE_BEFORE_RETRY",
|
||||
"OUTCOME_UNKNOWN",
|
||||
]
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_EFFECT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$")
|
||||
_CAPABILITY_NAMESPACES = frozenset({"agent-task", "task-plane-wildcard"})
|
||||
|
||||
|
||||
def _string(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{name} contains NUL")
|
||||
return value
|
||||
|
||||
|
||||
def _non_negative_int(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _aware_utc(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
if value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must have a UTC offset")
|
||||
return value
|
||||
|
||||
|
||||
def validate_oid(value: str) -> str:
|
||||
if not isinstance(value, str) or _OID_RE.fullmatch(value) is None:
|
||||
raise ValueError("OID must be 40 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_digest(value: str) -> str:
|
||||
if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None:
|
||||
raise ValueError("digest must be 64 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_effect_key(value: str) -> str:
|
||||
if not isinstance(value, str) or _EFFECT_KEY_RE.fullmatch(value) is None:
|
||||
raise ValueError("effect key has invalid length or characters")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationBinding:
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
repository: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
input_kind: str
|
||||
source_oid: str
|
||||
patch_digest: str
|
||||
target_branch: str
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"repository",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"input_kind",
|
||||
"target_branch",
|
||||
):
|
||||
_string(getattr(self, name), name)
|
||||
if self.capability_namespace not in _CAPABILITY_NAMESPACES:
|
||||
raise ValueError("unsupported capability namespace")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
validate_oid(self.source_oid)
|
||||
for name in ("patch_digest", "scope_digest", "policy_digest", "plan_digest"):
|
||||
validate_digest(getattr(self, name))
|
||||
validate_effect_key(self.effect_key)
|
||||
|
||||
|
||||
def binding_digest(binding: OperationBinding) -> str:
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("binding must be OperationBinding")
|
||||
payload = json.dumps(
|
||||
asdict(binding),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainStamp:
|
||||
domain: str
|
||||
object_id: str
|
||||
generation: int
|
||||
revision: int
|
||||
event_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.domain, "domain")
|
||||
_string(self.object_id, "object_id")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
_non_negative_int(self.revision, "revision")
|
||||
_string(self.event_id, "event_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fact:
|
||||
value: FactValue
|
||||
stamp: DomainStamp | None
|
||||
binding_digest: str
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.value not in ("TRUE", "FALSE", "UNKNOWN"):
|
||||
raise ValueError("invalid fact value")
|
||||
validate_digest(self.binding_digest)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_string(ref, "evidence_ref")
|
||||
if self.value != "UNKNOWN" and self.stamp is None:
|
||||
raise ValueError("known fact requires an authority stamp")
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("fact timestamps must both be present or absent")
|
||||
if self.observed_at is None:
|
||||
if self.value != "UNKNOWN":
|
||||
raise ValueError("known fact requires timestamps")
|
||||
return
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationObservation:
|
||||
binding: OperationBinding
|
||||
declared: Fact
|
||||
installed: Fact
|
||||
callable: Fact
|
||||
authorized: Fact
|
||||
preconditions: Fact
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightResult:
|
||||
state: PreflightState
|
||||
next_action: str
|
||||
reason_code: str
|
||||
retryable: bool
|
||||
binding_digest: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
def _now(value: datetime) -> datetime:
|
||||
return _aware_utc(value, "now")
|
||||
|
||||
|
||||
def _fact_invalid_reason(fact: Fact, expected_digest: str, now: datetime) -> str | None:
|
||||
if fact.binding_digest != expected_digest:
|
||||
return "BINDING_MISMATCH"
|
||||
if fact.value == "UNKNOWN":
|
||||
return "OBSERVATION_UNKNOWN"
|
||||
if fact.stamp is None or fact.observed_at is None or fact.expires_at is None:
|
||||
return "INVALID_OBSERVATION"
|
||||
if fact.observed_at > now:
|
||||
return "FUTURE_OBSERVATION"
|
||||
if now >= fact.expires_at:
|
||||
return "STALE_OBSERVATION"
|
||||
return None
|
||||
|
||||
|
||||
def derive_preflight(
|
||||
observation: OperationObservation, *, now: datetime
|
||||
) -> PreflightResult:
|
||||
"""Reduce fresh authority observations without invoking any external port."""
|
||||
now = _now(now)
|
||||
if not isinstance(observation, OperationObservation):
|
||||
raise TypeError("observation must be OperationObservation")
|
||||
digest = binding_digest(observation.binding)
|
||||
evidence: list[str] = []
|
||||
frontier = (
|
||||
(
|
||||
"declared",
|
||||
observation.declared,
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"ENGINEERING_CHANGE_REQUIRED",
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
),
|
||||
(
|
||||
"installed",
|
||||
observation.installed,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_INSTALLED",
|
||||
),
|
||||
(
|
||||
"callable",
|
||||
observation.callable,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_CALLABLE",
|
||||
),
|
||||
(
|
||||
"authorized",
|
||||
observation.authorized,
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PREPARE_EXACT_AUTH_REQUEST",
|
||||
"EXACT_AUTHORIZATION_MISSING",
|
||||
),
|
||||
(
|
||||
"preconditions",
|
||||
observation.preconditions,
|
||||
"PRECONDITION_FAILED",
|
||||
"REPAIR_PRECONDITION",
|
||||
"PRECONDITION_FAILED",
|
||||
),
|
||||
)
|
||||
for name, fact, false_state, false_action, false_reason in frontier:
|
||||
evidence.extend(fact.evidence_refs)
|
||||
invalid_reason = _fact_invalid_reason(fact, digest, now)
|
||||
if invalid_reason is not None:
|
||||
return PreflightResult(
|
||||
state="UNKNOWN",
|
||||
next_action="OBSERVE_OR_RECONCILE",
|
||||
reason_code=f"{name.upper()}_{invalid_reason}",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
if fact.value == "FALSE":
|
||||
return PreflightResult(
|
||||
state=false_state, # type: ignore[arg-type]
|
||||
next_action=false_action,
|
||||
reason_code=false_reason,
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
return PreflightResult(
|
||||
state="READY",
|
||||
next_action="EXECUTE_GOVERNED_PLAN",
|
||||
reason_code="ALL_REQUIRED_FACTS_FRESH_AND_TRUE",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectObservation:
|
||||
binding_digest: str
|
||||
outcome: EffectOutcome
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
actor_id: str | None
|
||||
stamp: DomainStamp | None
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
trust_status: Literal["TRUSTED", "UNTRUSTED", "SHADOW"] = "TRUSTED"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("COMMITTED", "NOT_COMMITTED", "UNKNOWN"):
|
||||
raise ValueError("invalid effect outcome")
|
||||
if self.trust_status not in ("TRUSTED", "UNTRUSTED", "SHADOW"):
|
||||
raise ValueError("invalid effect trust status")
|
||||
for value, name in ((self.receipt_ref, "receipt_ref"), (self.actor_id, "actor_id")):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("effect timestamps must both be present or absent")
|
||||
if self.observed_at is not None:
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_id: str | None
|
||||
outcome: VerificationOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
verification_receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"):
|
||||
raise ValueError("invalid verification outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_id, "verifier_id"),
|
||||
(self.verification_receipt_ref, "verification_receipt_ref"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "verification")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_receipt_ref: str | None
|
||||
acceptance_revision: str | None
|
||||
acceptor_id: str | None
|
||||
outcome: AcceptanceOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("ACCEPTED", "REJECTED", "UNKNOWN"):
|
||||
raise ValueError("invalid acceptance outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_receipt_ref, "verifier_receipt_ref"),
|
||||
(self.acceptance_revision, "acceptance_revision"),
|
||||
(self.acceptor_id, "acceptor_id"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "acceptance")
|
||||
|
||||
|
||||
def _validate_optional_times(
|
||||
observed_at: datetime | None, expires_at: datetime | None, prefix: str
|
||||
) -> None:
|
||||
if (observed_at is None) != (expires_at is None):
|
||||
raise ValueError(f"{prefix} timestamps must both be present or absent")
|
||||
if observed_at is None:
|
||||
return
|
||||
observed = _aware_utc(observed_at, "observed_at")
|
||||
expires = _aware_utc(expires_at, "expires_at")
|
||||
if expires <= observed:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureObservation:
|
||||
effect: EffectObservation
|
||||
verification: VerificationObservation
|
||||
acceptance: AcceptanceObservation
|
||||
expected_acceptor_id: str
|
||||
expected_acceptance_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.expected_acceptor_id, "expected_acceptor_id")
|
||||
_string(self.expected_acceptance_revision, "expected_acceptance_revision")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureResult:
|
||||
execution_recorded: bool
|
||||
independently_verified: bool
|
||||
business_accepted: bool
|
||||
outcome: ClosureOutcome
|
||||
reason_code: str
|
||||
|
||||
|
||||
def _fresh(
|
||||
observed_at: datetime | None, expires_at: datetime | None, now: datetime
|
||||
) -> bool:
|
||||
return (
|
||||
observed_at is not None
|
||||
and expires_at is not None
|
||||
and observed_at <= now < expires_at
|
||||
)
|
||||
|
||||
|
||||
def reconcile_effect(
|
||||
effect: EffectObservation, *, binding: OperationBinding, now: datetime
|
||||
) -> EffectReconciliation:
|
||||
"""Choose a safe recovery route; this function never retries the effect."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
if (
|
||||
effect.binding_digest != digest
|
||||
or effect.stamp is None
|
||||
or effect.trust_status != "TRUSTED"
|
||||
or not _fresh(effect.observed_at, effect.expires_at, now)
|
||||
):
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "COMMITTED":
|
||||
if (
|
||||
effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
):
|
||||
return "REBUILD_RECEIPT_REFERENCE"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "NOT_COMMITTED":
|
||||
# stamp + fresh trusted authority receipt is the deterministic negative
|
||||
# observation. Revalidation is still required before any governed retry.
|
||||
if effect.receipt_ref is not None and effect.result_artifact_digest is None:
|
||||
return "REVALIDATE_BEFORE_RETRY"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
|
||||
|
||||
def derive_closure(
|
||||
binding: OperationBinding,
|
||||
observation: ClosureObservation,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureResult:
|
||||
"""Reduce execution, verification, and acceptance as three distinct claims."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
execution_recorded = (
|
||||
effect.binding_digest == digest
|
||||
and effect.outcome == "COMMITTED"
|
||||
and effect.trust_status == "TRUSTED"
|
||||
and effect.stamp is not None
|
||||
and effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
and _fresh(effect.observed_at, effect.expires_at, now)
|
||||
)
|
||||
if not execution_recorded:
|
||||
outcome: ClosureOutcome = (
|
||||
"OUTCOME_UNKNOWN" if effect.outcome == "UNKNOWN" else "PARTIAL"
|
||||
)
|
||||
return ClosureResult(False, False, False, outcome, "EXECUTION_NOT_RECORDED")
|
||||
|
||||
verification = observation.verification
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref or verification.receipt_ref
|
||||
)
|
||||
independently_verified = (
|
||||
verification.binding_digest == digest
|
||||
and verification.receipt_ref == effect.receipt_ref
|
||||
and verification.result_artifact_digest == effect.result_artifact_digest
|
||||
and verification.verifier_id is not None
|
||||
and verification.verifier_id != effect.actor_id
|
||||
and verification.outcome == "PASS"
|
||||
and verification.authenticated is True
|
||||
and verifier_receipt_ref is not None
|
||||
and _fresh(verification.observed_at, verification.expires_at, now)
|
||||
)
|
||||
if not independently_verified:
|
||||
return ClosureResult(True, False, False, "PARTIAL", "INDEPENDENT_VERIFICATION_MISSING")
|
||||
|
||||
acceptance = observation.acceptance
|
||||
business_accepted = (
|
||||
acceptance.binding_digest == digest
|
||||
and acceptance.receipt_ref == effect.receipt_ref
|
||||
and acceptance.result_artifact_digest == effect.result_artifact_digest
|
||||
and acceptance.verifier_receipt_ref == verifier_receipt_ref
|
||||
and acceptance.acceptance_revision
|
||||
== observation.expected_acceptance_revision
|
||||
and acceptance.acceptor_id == observation.expected_acceptor_id
|
||||
and acceptance.outcome == "ACCEPTED"
|
||||
and acceptance.authenticated is True
|
||||
and _fresh(acceptance.observed_at, acceptance.expires_at, now)
|
||||
)
|
||||
if not business_accepted:
|
||||
return ClosureResult(True, True, False, "PARTIAL", "AUTHORIZED_ACCEPTANCE_MISSING")
|
||||
return ClosureResult(True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AcceptanceObservation",
|
||||
"ClosureObservation",
|
||||
"ClosureResult",
|
||||
"DomainStamp",
|
||||
"EffectObservation",
|
||||
"Fact",
|
||||
"OperationBinding",
|
||||
"OperationObservation",
|
||||
"PreflightResult",
|
||||
"VerificationObservation",
|
||||
"binding_digest",
|
||||
"derive_closure",
|
||||
"derive_preflight",
|
||||
"reconcile_effect",
|
||||
"validate_digest",
|
||||
"validate_effect_key",
|
||||
"validate_oid",
|
||||
]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Strict, pure validation for the C6 executable source-patch contract.
|
||||
|
||||
Validation here proves contract and unified-diff shape only. The governed
|
||||
executor remains responsible for secure checkout containment, isolated apply,
|
||||
result-file hashing, live authorization, leases/fences, and CAS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from .operation_view import (
|
||||
OperationBinding,
|
||||
validate_digest,
|
||||
validate_effect_key,
|
||||
validate_oid,
|
||||
)
|
||||
|
||||
|
||||
_TOP_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"input_kind",
|
||||
"type",
|
||||
"target",
|
||||
"before_hash",
|
||||
"after_hash",
|
||||
"operations",
|
||||
"rollback",
|
||||
"verification",
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"capability_namespace",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"generation",
|
||||
"executor_ref",
|
||||
"repository",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"patch_digest",
|
||||
"file_digests",
|
||||
"scope_digest",
|
||||
"policy_digest",
|
||||
"plan_digest",
|
||||
"effect_key",
|
||||
"rollback_artifact_digest",
|
||||
"verification_contract_revision",
|
||||
"acceptance_contract_revision",
|
||||
}
|
||||
)
|
||||
_TARGET_FIELDS = frozenset({"repository", "branch"})
|
||||
_OPERATION_FIELDS = frozenset({"path", "kind", "before_hash", "after_hash"})
|
||||
_ROLLBACK_FIELDS = frozenset({"artifact_digest", "expected_after_hash"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{"revision", "acceptance_revision", "required_checks"}
|
||||
)
|
||||
_PROTECTED_PATHS = frozenset({".git", ".gitmodules"})
|
||||
_DIFF_HEADER_RE = re.compile(r"^diff --git a/([^\s]+) b/([^\s]+)$")
|
||||
|
||||
|
||||
def _exact_mapping(value: object, fields: frozenset[str], name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValueError(f"{name} has unknown or missing fields")
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} keys must be strings")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _path(value: object) -> str:
|
||||
path = _text(value, "path")
|
||||
if "\\" in path or ":" in path or path.startswith(("/", "//")):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
parsed = PurePosixPath(path)
|
||||
if path != parsed.as_posix() or any(part in ("", ".", "..") for part in parsed.parts):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
if any(part.casefold() == ".git" for part in parsed.parts) or path.casefold() in _PROTECTED_PATHS:
|
||||
raise ValueError("protected Git metadata is outside patch scope")
|
||||
return path
|
||||
|
||||
|
||||
def _content_hash(value: object, name: str, *, literal: str | None = None) -> str:
|
||||
text = _text(value, name)
|
||||
if literal is not None and text == literal:
|
||||
return text
|
||||
return validate_digest(text)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchTarget:
|
||||
repository: str
|
||||
branch: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository")
|
||||
_text(self.branch, "branch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchFileOperation:
|
||||
path: str
|
||||
kind: Literal["ADD", "MODIFY", "DELETE"]
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_path(self.path)
|
||||
if self.kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
if self.kind == "ADD":
|
||||
if self.before_hash != "ABSENT":
|
||||
raise ValueError("ADD before_hash must be ABSENT")
|
||||
validate_digest(self.after_hash)
|
||||
elif self.kind == "DELETE":
|
||||
validate_digest(self.before_hash)
|
||||
if self.after_hash != "DELETED":
|
||||
raise ValueError("DELETE after_hash must be DELETED")
|
||||
else:
|
||||
validate_digest(self.before_hash)
|
||||
validate_digest(self.after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollbackContract:
|
||||
artifact_digest: str
|
||||
expected_after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.artifact_digest)
|
||||
validate_digest(self.expected_after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationContract:
|
||||
revision: str
|
||||
acceptance_revision: str
|
||||
required_checks: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.revision, "verification revision")
|
||||
_text(self.acceptance_revision, "acceptance revision")
|
||||
if not isinstance(self.required_checks, tuple) or not self.required_checks:
|
||||
raise ValueError("required_checks must be a non-empty tuple")
|
||||
if len(set(self.required_checks)) != len(self.required_checks):
|
||||
raise ValueError("required_checks contains duplicates")
|
||||
for check in self.required_checks:
|
||||
_text(check, "required check")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutablePatchV1:
|
||||
schema: Literal["aios.executable-patch.v1"]
|
||||
input_kind: Literal["EXECUTABLE_PATCH"]
|
||||
type: Literal["git_patch"]
|
||||
target: PatchTarget
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
operations: tuple[PatchFileOperation, ...]
|
||||
rollback: RollbackContract
|
||||
verification: VerificationContract
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
executor_ref: Literal["governed_source_patch_apply"]
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
patch_digest: str
|
||||
file_digests: tuple[tuple[str, str], ...]
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
rollback_artifact_digest: str
|
||||
verification_contract_revision: str
|
||||
acceptance_contract_revision: str
|
||||
|
||||
|
||||
def _aggregate_hash(
|
||||
operations: tuple[PatchFileOperation, ...], attribute: Literal["before_hash", "after_hash"]
|
||||
) -> str:
|
||||
values = [
|
||||
{"path": item.path, "hash": getattr(item, attribute)}
|
||||
for item in sorted(operations, key=lambda operation: operation.path)
|
||||
]
|
||||
encoded = json.dumps(
|
||||
values,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _parse_target(value: object) -> PatchTarget:
|
||||
raw = _exact_mapping(value, _TARGET_FIELDS, "target")
|
||||
return PatchTarget(
|
||||
repository=_text(raw["repository"], "target.repository"),
|
||||
branch=_text(raw["branch"], "target.branch"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_operation(value: object) -> PatchFileOperation:
|
||||
raw = _exact_mapping(value, _OPERATION_FIELDS, "operation")
|
||||
kind = raw["kind"]
|
||||
if kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
before = _content_hash(
|
||||
raw["before_hash"],
|
||||
"before_hash",
|
||||
literal="ABSENT" if kind == "ADD" else None,
|
||||
)
|
||||
after = _content_hash(
|
||||
raw["after_hash"],
|
||||
"after_hash",
|
||||
literal="DELETED" if kind == "DELETE" else None,
|
||||
)
|
||||
return PatchFileOperation(
|
||||
path=_path(raw["path"]),
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
before_hash=before,
|
||||
after_hash=after,
|
||||
)
|
||||
|
||||
|
||||
def _parse_operations(value: object) -> tuple[PatchFileOperation, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)) or not value:
|
||||
raise ValueError("operations must be a non-empty array")
|
||||
operations = tuple(_parse_operation(item) for item in value)
|
||||
paths = [item.path for item in operations]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("operation paths must be unique, including case-folding")
|
||||
return operations
|
||||
|
||||
|
||||
def _parse_rollback(value: object) -> RollbackContract:
|
||||
raw = _exact_mapping(value, _ROLLBACK_FIELDS, "rollback")
|
||||
return RollbackContract(
|
||||
artifact_digest=validate_digest(_text(raw["artifact_digest"], "artifact_digest")),
|
||||
expected_after_hash=validate_digest(
|
||||
_text(raw["expected_after_hash"], "expected_after_hash")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_verification(value: object) -> VerificationContract:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
checks = raw["required_checks"]
|
||||
if not isinstance(checks, (list, tuple)) or isinstance(checks, (str, bytes)):
|
||||
raise ValueError("required_checks must be an array")
|
||||
return VerificationContract(
|
||||
revision=_text(raw["revision"], "verification revision"),
|
||||
acceptance_revision=_text(
|
||||
raw["acceptance_revision"], "acceptance revision"
|
||||
),
|
||||
required_checks=tuple(_text(item, "required check") for item in checks),
|
||||
)
|
||||
|
||||
|
||||
def _parse_file_digests(value: object) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError("file_digests must be an array of pairs")
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
||||
raise ValueError("file_digests entries must be path/digest pairs")
|
||||
path = _path(item[0])
|
||||
digest = _content_hash(item[1], "file digest", literal="DELETED")
|
||||
pairs.append((path, digest))
|
||||
paths = [path for path, _ in pairs]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("file_digests paths must be unique")
|
||||
return tuple(sorted(pairs))
|
||||
|
||||
|
||||
def _strip_patch_path(line: str, prefix: str) -> str:
|
||||
value = line[len(prefix) :]
|
||||
if "\t" in value:
|
||||
raise ValueError("timestamped unified-diff paths are unsupported")
|
||||
if value == "/dev/null":
|
||||
return value
|
||||
expected = "a/" if prefix == "--- " else "b/"
|
||||
if not value.startswith(expected):
|
||||
raise ValueError("diff path has unexpected prefix")
|
||||
return _path(value[2:])
|
||||
|
||||
|
||||
def _actual_diff_operations(patch_bytes: bytes) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(patch_bytes, bytes) or not patch_bytes:
|
||||
raise ValueError("patch_bytes must be non-empty bytes")
|
||||
if b"\x00" in patch_bytes:
|
||||
raise ValueError("binary patches are unsupported")
|
||||
try:
|
||||
text = patch_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("patch must be UTF-8 text") from exc
|
||||
lines = text.splitlines()
|
||||
starts = [index for index, line in enumerate(lines) if line.startswith("diff --git ")]
|
||||
if not starts or any(line for line in lines[: starts[0]]):
|
||||
raise ValueError("patch must consist only of git unified-diff blocks")
|
||||
starts.append(len(lines))
|
||||
actual: list[tuple[str, str]] = []
|
||||
for position in range(len(starts) - 1):
|
||||
block = lines[starts[position] : starts[position + 1]]
|
||||
match = _DIFF_HEADER_RE.fullmatch(block[0])
|
||||
if match is None:
|
||||
raise ValueError("quoted, whitespace, or malformed diff paths are unsupported")
|
||||
old_header_path = _path(match.group(1))
|
||||
new_header_path = _path(match.group(2))
|
||||
forbidden = (
|
||||
"GIT binary patch",
|
||||
"Binary files ",
|
||||
"rename from ",
|
||||
"rename to ",
|
||||
"copy from ",
|
||||
"copy to ",
|
||||
"similarity index ",
|
||||
"dissimilarity index ",
|
||||
"old mode ",
|
||||
"new mode ",
|
||||
)
|
||||
if any(line.startswith(forbidden) for line in block[1:]):
|
||||
raise ValueError("rename, copy, binary, or mode-only patches are unsupported")
|
||||
mode_lines = [
|
||||
line
|
||||
for line in block[1:]
|
||||
if line.startswith(("new file mode ", "deleted file mode "))
|
||||
]
|
||||
if any(not line.endswith("100644") for line in mode_lines):
|
||||
raise ValueError("symlink, executable, and submodule modes are unsupported")
|
||||
old_lines = [line for line in block[1:] if line.startswith("--- ")]
|
||||
new_lines = [line for line in block[1:] if line.startswith("+++ ")]
|
||||
if len(old_lines) != 1 or len(new_lines) != 1:
|
||||
raise ValueError("each diff block requires one old and new path")
|
||||
old_path = _strip_patch_path(old_lines[0], "--- ")
|
||||
new_path = _strip_patch_path(new_lines[0], "+++ ")
|
||||
if not any(line.startswith("@@ ") for line in block[1:]):
|
||||
raise ValueError("mode-only and empty diff blocks are unsupported")
|
||||
if old_path == "/dev/null":
|
||||
if new_path != new_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("ADD paths do not agree")
|
||||
if mode_lines != ["new file mode 100644"]:
|
||||
raise ValueError("ADD requires regular-text new file mode")
|
||||
actual.append((new_path, "ADD"))
|
||||
elif new_path == "/dev/null":
|
||||
if old_path != old_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("DELETE paths do not agree")
|
||||
if mode_lines != ["deleted file mode 100644"]:
|
||||
raise ValueError("DELETE requires regular-text deleted file mode")
|
||||
actual.append((old_path, "DELETE"))
|
||||
else:
|
||||
if mode_lines or len({old_path, new_path, old_header_path, new_header_path}) != 1:
|
||||
raise ValueError("rename or mode-changing MODIFY is unsupported")
|
||||
actual.append((old_path, "MODIFY"))
|
||||
paths = [path for path, _ in actual]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("diff paths must be unique, including case-folding")
|
||||
return tuple(sorted(actual))
|
||||
|
||||
|
||||
def validate_patch_shape(patch_bytes: bytes, contract: ExecutablePatchV1) -> None:
|
||||
actual = _actual_diff_operations(patch_bytes)
|
||||
declared = tuple(sorted((item.path, item.kind) for item in contract.operations))
|
||||
if actual != declared:
|
||||
raise ValueError("actual diff file set or operation kinds do not match contract")
|
||||
|
||||
|
||||
def parse_executable_patch(
|
||||
raw: Mapping[str, object],
|
||||
*,
|
||||
patch_bytes: bytes,
|
||||
allowed_files: frozenset[str],
|
||||
expected_binding: OperationBinding,
|
||||
) -> ExecutablePatchV1:
|
||||
"""Parse a complete FD-005 contract without applying or executing it."""
|
||||
document = _exact_mapping(raw, _TOP_FIELDS, "executable patch")
|
||||
if document["schema"] != "aios.executable-patch.v1":
|
||||
raise ValueError("unsupported executable patch schema")
|
||||
if document["input_kind"] != "EXECUTABLE_PATCH":
|
||||
raise ValueError("PATCH_PROPOSAL is not executable")
|
||||
if document["type"] != "git_patch":
|
||||
raise ValueError("unsupported executable patch type")
|
||||
if document["executor_ref"] != "governed_source_patch_apply":
|
||||
raise ValueError("executor_ref is not the governed pilot")
|
||||
if not isinstance(allowed_files, frozenset):
|
||||
raise ValueError("allowed_files must be a frozen manifest set")
|
||||
canonical_allowed = frozenset(_path(item) for item in allowed_files)
|
||||
if len(canonical_allowed) != len(allowed_files) or len(canonical_allowed) != len(
|
||||
{item.casefold() for item in canonical_allowed}
|
||||
):
|
||||
raise ValueError("allowed_files contains aliases or case-fold collisions")
|
||||
|
||||
target = _parse_target(document["target"])
|
||||
operations = _parse_operations(document["operations"])
|
||||
rollback = _parse_rollback(document["rollback"])
|
||||
verification = _parse_verification(document["verification"])
|
||||
file_digests = _parse_file_digests(document["file_digests"])
|
||||
before_hash = validate_digest(_text(document["before_hash"], "before_hash"))
|
||||
after_hash = validate_digest(_text(document["after_hash"], "after_hash"))
|
||||
patch_digest = validate_digest(_text(document["patch_digest"], "patch_digest"))
|
||||
|
||||
binding_fields = {
|
||||
"operation_id": expected_binding.operation_id,
|
||||
"actor_id": expected_binding.actor_id,
|
||||
"tenant_id": expected_binding.tenant_id,
|
||||
"capability_namespace": expected_binding.capability_namespace,
|
||||
"capability_id": expected_binding.capability_id,
|
||||
"task_id": expected_binding.task_id,
|
||||
"job_id": expected_binding.job_id,
|
||||
"generation": expected_binding.generation,
|
||||
"input_kind": expected_binding.input_kind,
|
||||
"repository": expected_binding.repository,
|
||||
"target_branch": expected_binding.target_branch,
|
||||
"patch_digest": expected_binding.patch_digest,
|
||||
"scope_digest": expected_binding.scope_digest,
|
||||
"policy_digest": expected_binding.policy_digest,
|
||||
"plan_digest": expected_binding.plan_digest,
|
||||
"effect_key": expected_binding.effect_key,
|
||||
}
|
||||
for name, expected in binding_fields.items():
|
||||
if document[name] != expected:
|
||||
raise ValueError(f"binding mismatch: {name}")
|
||||
generation = _integer(document["generation"], "generation")
|
||||
if generation != expected_binding.generation:
|
||||
raise ValueError("binding mismatch: generation")
|
||||
base_oid = validate_oid(_text(document["base_oid"], "base_oid"))
|
||||
if base_oid != expected_binding.source_oid:
|
||||
raise ValueError("binding mismatch: base_oid")
|
||||
if target.repository != expected_binding.repository or target.branch != expected_binding.target_branch:
|
||||
raise ValueError("target does not match top-level binding")
|
||||
actual_patch_digest = hashlib.sha256(patch_bytes).hexdigest()
|
||||
if patch_digest != actual_patch_digest:
|
||||
raise ValueError("patch_digest does not match raw patch bytes")
|
||||
if any(item.path not in canonical_allowed for item in operations):
|
||||
raise ValueError("operation path is outside allowed_files")
|
||||
expected_file_digests = tuple(sorted((item.path, item.after_hash) for item in operations))
|
||||
if file_digests != expected_file_digests:
|
||||
raise ValueError("file_digests do not match operation result hashes")
|
||||
if before_hash != _aggregate_hash(operations, "before_hash"):
|
||||
raise ValueError("before_hash does not match the operation manifest")
|
||||
if after_hash != _aggregate_hash(operations, "after_hash"):
|
||||
raise ValueError("after_hash does not match the operation manifest")
|
||||
if rollback.expected_after_hash != after_hash:
|
||||
raise ValueError("rollback expected_after_hash mismatch")
|
||||
rollback_artifact_digest = validate_digest(
|
||||
_text(document["rollback_artifact_digest"], "rollback_artifact_digest")
|
||||
)
|
||||
if rollback_artifact_digest != rollback.artifact_digest:
|
||||
raise ValueError("rollback_artifact_digest mismatch")
|
||||
verification_revision = _text(
|
||||
document["verification_contract_revision"],
|
||||
"verification_contract_revision",
|
||||
)
|
||||
acceptance_revision = _text(
|
||||
document["acceptance_contract_revision"],
|
||||
"acceptance_contract_revision",
|
||||
)
|
||||
if verification_revision != verification.revision:
|
||||
raise ValueError("verification_contract_revision mismatch")
|
||||
if acceptance_revision != verification.acceptance_revision:
|
||||
raise ValueError("acceptance_contract_revision mismatch")
|
||||
validate_effect_key(_text(document["effect_key"], "effect_key"))
|
||||
|
||||
contract = ExecutablePatchV1(
|
||||
schema="aios.executable-patch.v1",
|
||||
input_kind="EXECUTABLE_PATCH",
|
||||
type="git_patch",
|
||||
target=target,
|
||||
before_hash=before_hash,
|
||||
after_hash=after_hash,
|
||||
operations=operations,
|
||||
rollback=rollback,
|
||||
verification=verification,
|
||||
operation_id=expected_binding.operation_id,
|
||||
actor_id=expected_binding.actor_id,
|
||||
tenant_id=expected_binding.tenant_id,
|
||||
capability_namespace=expected_binding.capability_namespace,
|
||||
capability_id=expected_binding.capability_id,
|
||||
task_id=expected_binding.task_id,
|
||||
job_id=expected_binding.job_id,
|
||||
generation=generation,
|
||||
executor_ref="governed_source_patch_apply",
|
||||
repository=expected_binding.repository,
|
||||
base_oid=base_oid,
|
||||
target_branch=expected_binding.target_branch,
|
||||
patch_digest=patch_digest,
|
||||
file_digests=file_digests,
|
||||
scope_digest=expected_binding.scope_digest,
|
||||
policy_digest=expected_binding.policy_digest,
|
||||
plan_digest=expected_binding.plan_digest,
|
||||
effect_key=expected_binding.effect_key,
|
||||
rollback_artifact_digest=rollback_artifact_digest,
|
||||
verification_contract_revision=verification_revision,
|
||||
acceptance_contract_revision=acceptance_revision,
|
||||
)
|
||||
validate_patch_shape(patch_bytes, contract)
|
||||
return contract
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExecutablePatchV1",
|
||||
"PatchFileOperation",
|
||||
"PatchTarget",
|
||||
"RollbackContract",
|
||||
"VerificationContract",
|
||||
"parse_executable_patch",
|
||||
"validate_patch_shape",
|
||||
]
|
||||
@@ -54,13 +54,27 @@ class ReadonlyWorkerProfile:
|
||||
|
||||
|
||||
class IdentityVerifier:
|
||||
def __init__(self, trusted_identities: set[str] | None = None):
|
||||
self.trusted_identities = trusted_identities
|
||||
def __init__(
|
||||
self,
|
||||
trusted_identities: set[str] | None = None,
|
||||
*,
|
||||
verifier_identity: str | None = None,
|
||||
):
|
||||
self.trusted_identities = frozenset(trusted_identities or ())
|
||||
self.verifier_identity = verifier_identity
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return any(identity != "*" for identity in self.trusted_identities)
|
||||
|
||||
def verify(self, identity: str) -> bool:
|
||||
if self.trusted_identities is None:
|
||||
return True
|
||||
return identity in self.trusted_identities
|
||||
return bool(
|
||||
identity
|
||||
and (
|
||||
identity in self.trusted_identities
|
||||
or "*" in self.trusted_identities
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EvidenceVerifier:
|
||||
@@ -92,6 +106,7 @@ class PolicyEngine:
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
evidence_attestation_verifier: Callable[[object], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
@@ -99,12 +114,28 @@ class PolicyEngine:
|
||||
) -> None:
|
||||
if max_risk not in RISK_RANK:
|
||||
raise ValueError("invalid max_risk")
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier()
|
||||
if execution_enabled and (
|
||||
identity_verifier is None or not identity_verifier.is_configured
|
||||
):
|
||||
raise ValueError(
|
||||
"execution_enabled requires an explicit trusted identity verifier"
|
||||
)
|
||||
if execution_enabled and evidence_attestation_verifier is None:
|
||||
raise ValueError(
|
||||
"execution_enabled requires an evidence attestation verifier"
|
||||
)
|
||||
configured_identities = {
|
||||
identity for identity in (identity_capabilities or {}) if identity
|
||||
}
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier(
|
||||
configured_identities if not execution_enabled else None
|
||||
)
|
||||
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
|
||||
self.execution_enabled = execution_enabled
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.evidence_attestation_verifier = evidence_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
@@ -195,6 +226,8 @@ class PolicyEngine:
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
if action not in action_capabilities:
|
||||
return PolicyDecision(False, f"unknown action {action}")
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
@@ -39,6 +41,22 @@ def _digest(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json(dict(value)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical_observation_value(value: object) -> object:
|
||||
"""Convert a typed readback observation to stable JSON data."""
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ConflictError("exact host closure time is not timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): _canonical_observation_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_canonical_observation_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthoritativeRootActionPlan:
|
||||
review_id: str
|
||||
@@ -265,6 +283,37 @@ class RootActionRunResult:
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExactRootActionBinding:
|
||||
"""Trusted host resolution from a C6 operation to an existing root review."""
|
||||
|
||||
review_id: str
|
||||
mission_id: str
|
||||
root_task_id: str
|
||||
root_revision: int
|
||||
plan_digest: str
|
||||
execution_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if any(
|
||||
not isinstance(value, str) or not value
|
||||
for value in (
|
||||
self.review_id,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
self.plan_digest,
|
||||
self.execution_key,
|
||||
)
|
||||
):
|
||||
raise ValueError("exact root action binding identity required")
|
||||
if (
|
||||
isinstance(self.root_revision, bool)
|
||||
or not isinstance(self.root_revision, int)
|
||||
or self.root_revision < 0
|
||||
):
|
||||
raise ValueError("exact root action revision is invalid")
|
||||
|
||||
|
||||
class RootActionExecutor(Protocol):
|
||||
def reconcile(
|
||||
self,
|
||||
@@ -278,7 +327,7 @@ class RootActionExecutor(Protocol):
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
*,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> None: ...
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
@@ -292,6 +341,7 @@ PlanBuilder = Callable[[RootReviewRecord], AuthoritativeRootActionPlan]
|
||||
Rereviewer = Callable[
|
||||
[AuthoritativeRootActionPlan, Mapping[str, object] | None], RootActionRereview
|
||||
]
|
||||
ExactReviewBindingProvider = Callable[[object], ExactRootActionBinding]
|
||||
|
||||
|
||||
class RootActionConsumer:
|
||||
@@ -304,12 +354,14 @@ class RootActionConsumer:
|
||||
plan_builder: PlanBuilder,
|
||||
rereviewer: Rereviewer,
|
||||
executor: RootActionExecutor | None,
|
||||
exact_review_binding_provider: ExactReviewBindingProvider | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
) -> Mapping[str, object]:
|
||||
self.store = store
|
||||
self._plan_builder = plan_builder
|
||||
self._rereviewer = rereviewer
|
||||
self._executor = executor
|
||||
self._exact_review_binding_provider = exact_review_binding_provider
|
||||
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
@@ -416,9 +468,20 @@ class RootActionConsumer:
|
||||
raise ConflictError("root action claim renewal CAS lost")
|
||||
|
||||
def _claim_next(
|
||||
self, *, consumer_id: str, now: float, lease_seconds: int
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: float,
|
||||
lease_seconds: int,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
) -> RootActionClaim | RootActionRunResult | None:
|
||||
with self.store._transaction():
|
||||
exact_sql = " AND d.review_id=?" if exact_binding is not None else ""
|
||||
args: tuple[object, ...] = (
|
||||
(now, exact_binding.review_id)
|
||||
if exact_binding is not None
|
||||
else (now,)
|
||||
)
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json,r.root_revision_after "
|
||||
"FROM task_root_action_dispatches d JOIN task_root_reviews r "
|
||||
@@ -426,11 +489,13 @@ class RootActionConsumer:
|
||||
"WHERE d.status IN ('EFFECT_UNKNOWN','DISPATCHED',"
|
||||
"'EXECUTED_PENDING_REREVIEW','CLAIMED','PENDING') "
|
||||
"AND (d.retry_at IS NULL OR d.retry_at<=?) "
|
||||
+ exact_sql
|
||||
+ " "
|
||||
"ORDER BY r.root_revision_after DESC,CASE d.status "
|
||||
"WHEN 'EFFECT_UNKNOWN' THEN 0 WHEN 'DISPATCHED' THEN 0 "
|
||||
"WHEN 'EXECUTED_PENDING_REREVIEW' THEN 1 "
|
||||
"WHEN 'CLAIMED' THEN 2 ELSE 3 END,d.created_at ASC",
|
||||
(now,),
|
||||
args,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if (
|
||||
@@ -443,7 +508,11 @@ class RootActionConsumer:
|
||||
}
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
continue
|
||||
if not (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
):
|
||||
continue
|
||||
root_task_id = str(row["root_task_id"])
|
||||
active = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_fences WHERE root_task_id=?",
|
||||
@@ -488,6 +557,43 @@ class RootActionConsumer:
|
||||
or plan.action != review.next_legal_action
|
||||
):
|
||||
raise ConflictError("root action plan/review authority mismatch")
|
||||
host_effect = self.store.conn.execute(
|
||||
"SELECT host_effect_key FROM task_root_action_effects "
|
||||
"WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
exact_binding is None
|
||||
and host_effect is not None
|
||||
and host_effect["host_effect_key"] is not None
|
||||
):
|
||||
# A C6-prepared action may only be resumed through the
|
||||
# exact facade, which supplies fresh signed authority.
|
||||
continue
|
||||
if exact_binding is not None and (
|
||||
plan.review_id != exact_binding.review_id
|
||||
or plan.mission_id != exact_binding.mission_id
|
||||
or plan.root_task_id != exact_binding.root_task_id
|
||||
or plan.root_revision != exact_binding.root_revision
|
||||
or plan.plan_digest != exact_binding.plan_digest
|
||||
or plan.execution_key != exact_binding.execution_key
|
||||
):
|
||||
raise ConflictError("trusted exact root action binding mismatch")
|
||||
if (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
return RootActionClaim(
|
||||
plan=plan,
|
||||
consumer_id=consumer_id,
|
||||
claim_token=str(row["claim_token"]),
|
||||
fence=int(row["claim_fence"]),
|
||||
lease_expires_at=float(row["claim_expires_at"]),
|
||||
lease_seconds=lease_seconds,
|
||||
reconcile_only=row["status"]
|
||||
in {"EFFECT_UNKNOWN", "DISPATCHED"},
|
||||
)
|
||||
if active is not None and active["active_review_id"] not in {
|
||||
None,
|
||||
row["review_id"],
|
||||
@@ -582,7 +688,13 @@ class RootActionConsumer:
|
||||
return claim
|
||||
return None
|
||||
|
||||
def _prepare_effect(self, claim: RootActionClaim, now: float):
|
||||
def _prepare_effect(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
now: float,
|
||||
*,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
):
|
||||
plan = claim.plan
|
||||
request_digest = _digest(
|
||||
{
|
||||
@@ -635,6 +747,41 @@ class RootActionConsumer:
|
||||
if effect["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if host_binding is not None:
|
||||
host_json = canonical_json(dict(host_binding))
|
||||
host_digest = hashlib.sha256(host_json.encode("utf-8")).hexdigest()
|
||||
host_effect_key = host_binding.get("effect_key")
|
||||
if not isinstance(host_effect_key, str) or not host_effect_key:
|
||||
raise ConflictError("exact host effect key is invalid")
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_effect_key,host_binding_json,host_binding_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if current["host_effect_key"] is None:
|
||||
try:
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_effect_key=?,"
|
||||
"host_binding_json=?,host_binding_digest=?,updated_at=? "
|
||||
"WHERE execution_key=? AND host_effect_key IS NULL",
|
||||
(
|
||||
host_effect_key,
|
||||
host_json,
|
||||
host_digest,
|
||||
now,
|
||||
plan.execution_key,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ConflictError("exact host effect key already bound") from exc
|
||||
if changed.rowcount != 1:
|
||||
raise ConflictError("exact host binding CAS lost")
|
||||
elif (
|
||||
current["host_effect_key"] != host_effect_key
|
||||
or current["host_binding_json"] != host_json
|
||||
or current["host_binding_digest"] != host_digest
|
||||
):
|
||||
raise ConflictError("exact host action binding conflict")
|
||||
status = (
|
||||
"EXECUTED_PENDING_REREVIEW"
|
||||
if effect_state == "SUCCEEDED"
|
||||
@@ -654,6 +801,52 @@ class RootActionConsumer:
|
||||
)
|
||||
return effect_state, receipt, effect_phase
|
||||
|
||||
def _record_writer_lease(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
lease: Mapping[str, object] | None,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease is None:
|
||||
return
|
||||
lease_json = canonical_json(dict(lease))
|
||||
lease_digest = hashlib.sha256(lease_json.encode("utf-8")).hexdigest()
|
||||
with self.store._transaction():
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET writer_lease_json=?,"
|
||||
"writer_lease_digest=?,updated_at=? WHERE execution_key=? "
|
||||
"AND writer_lease_json IS NULL",
|
||||
(lease_json, lease_digest, now, claim.plan.execution_key),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT writer_lease_json,writer_lease_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(claim.plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
row is None
|
||||
or row["writer_lease_json"] != lease_json
|
||||
or row["writer_lease_digest"] != lease_digest
|
||||
):
|
||||
raise ConflictError("WriterLease durable binding conflict")
|
||||
|
||||
@staticmethod
|
||||
def _validate_exact_writer_lease(lease: object) -> Mapping[str, object]:
|
||||
if not isinstance(lease, Mapping):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
fence = lease.get("writer_lease_fence")
|
||||
if (
|
||||
not isinstance(lease_id, str)
|
||||
or not lease_id.strip()
|
||||
or isinstance(fence, bool)
|
||||
or not isinstance(fence, int)
|
||||
or fence < 0
|
||||
):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
return lease
|
||||
|
||||
def _transition_effect_phase(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
@@ -910,18 +1103,25 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
)
|
||||
|
||||
def run_once(
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
prepare_only: bool = False,
|
||||
external_revalidator: Callable[[], None] | None = None,
|
||||
) -> RootActionRunResult:
|
||||
if not consumer_id or lease_seconds < 1:
|
||||
raise ValueError("invalid root action consumer claim")
|
||||
now_value = self._now(now)
|
||||
claimed = self._claim_next(
|
||||
consumer_id=consumer_id, now=now_value, lease_seconds=lease_seconds
|
||||
consumer_id=consumer_id,
|
||||
now=now_value,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=exact_binding,
|
||||
)
|
||||
if claimed is None:
|
||||
return RootActionRunResult(RootActionRunState.IDLE)
|
||||
@@ -949,7 +1149,11 @@ class RootActionConsumer:
|
||||
claim, status="EXECUTED_PENDING_REREVIEW", now=now_value
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
effect_state, receipt, effect_phase = self._prepare_effect(claim, now_value)
|
||||
effect_state, receipt, effect_phase = (
|
||||
self._prepare_effect(claim, now_value)
|
||||
if host_binding is None
|
||||
else self._prepare_effect(claim, now_value, host_binding=host_binding)
|
||||
)
|
||||
if effect_state == "SUPERSEDED":
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.CLOSED,
|
||||
@@ -970,6 +1174,13 @@ class RootActionConsumer:
|
||||
extra_args=(receipt_json, _digest(receipt)),
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
if prepare_only:
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.EXECUTED,
|
||||
plan.review_id,
|
||||
plan.execution_key,
|
||||
"PREPARED",
|
||||
)
|
||||
if self._executor is None:
|
||||
with self.store._transaction():
|
||||
self._set_dispatch(
|
||||
@@ -986,9 +1197,10 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
"ROOT_ACTION_EXECUTOR_UNAVAILABLE",
|
||||
)
|
||||
before_external_call = lambda: self._assert_live_claim(
|
||||
claim, now_value, renew=True
|
||||
)
|
||||
def before_external_call() -> None:
|
||||
self._assert_live_claim(claim, now_value, renew=True)
|
||||
if external_revalidator is not None:
|
||||
external_revalidator()
|
||||
if effect_phase == "BEFORE_LEASE":
|
||||
# Admission and the immutable plan are the authority for this one
|
||||
# first send. A pre-lease GET cannot prove absence: the live
|
||||
@@ -997,10 +1209,13 @@ class RootActionConsumer:
|
||||
claim, expected="BEFORE_LEASE", target="LEASE_INTENT", now=now_value
|
||||
)
|
||||
before_external_call()
|
||||
self._executor.acquire_lease(
|
||||
lease = self._executor.acquire_lease(
|
||||
plan, before_external_call=before_external_call
|
||||
)
|
||||
before_external_call()
|
||||
if host_binding is not None:
|
||||
lease = self._validate_exact_writer_lease(lease)
|
||||
self._record_writer_lease(claim, lease, now_value)
|
||||
self._transition_effect_phase(
|
||||
claim, expected="LEASE_INTENT", target="LEASE_ACQUIRED", now=now_value
|
||||
)
|
||||
@@ -1033,6 +1248,594 @@ class RootActionConsumer:
|
||||
return result
|
||||
return self._rereview(claim, now_value)
|
||||
|
||||
def run_once(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
) -> RootActionRunResult:
|
||||
return self._run(
|
||||
consumer_id=consumer_id,
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
|
||||
def configure_exact_review_binding_provider(
|
||||
self, provider: ExactReviewBindingProvider
|
||||
) -> None:
|
||||
if not callable(provider):
|
||||
raise TypeError("trusted exact review binding provider required")
|
||||
if self._exact_review_binding_provider is not None:
|
||||
raise ConflictError("exact review binding provider already configured")
|
||||
self._exact_review_binding_provider = provider
|
||||
|
||||
def assert_exact_owner_configured(self) -> None:
|
||||
if self._exact_review_binding_provider is None or self._executor is None:
|
||||
raise ConflictError("exact root action owner is not configured")
|
||||
|
||||
def _exact_resolution(self, binding: object) -> ExactRootActionBinding:
|
||||
self.assert_exact_owner_configured()
|
||||
assert self._exact_review_binding_provider is not None
|
||||
resolved = self._exact_review_binding_provider(binding)
|
||||
if not isinstance(resolved, ExactRootActionBinding):
|
||||
raise ConflictError("trusted exact review binding is invalid")
|
||||
return resolved
|
||||
|
||||
def _exact_plan(
|
||||
self, binding: object, resolved: ExactRootActionBinding
|
||||
) -> AuthoritativeRootActionPlan:
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("operation binding required")
|
||||
row = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json FROM task_root_action_dispatches d "
|
||||
"JOIN task_root_reviews r ON r.review_id=d.review_id "
|
||||
"WHERE d.review_id=?",
|
||||
(resolved.review_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("trusted exact root review is unavailable")
|
||||
if row["plan_json"]:
|
||||
raw = json.loads(str(row["plan_json"]))
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("persisted root action plan is invalid")
|
||||
plan = AuthoritativeRootActionPlan.from_dict(raw)
|
||||
else:
|
||||
plan = self._plan_builder(self._review(row))
|
||||
if (
|
||||
plan.review_id != resolved.review_id
|
||||
or plan.mission_id != resolved.mission_id
|
||||
or plan.root_task_id != resolved.root_task_id
|
||||
or plan.root_revision != resolved.root_revision
|
||||
or plan.plan_digest != resolved.plan_digest
|
||||
or plan.execution_key != resolved.execution_key
|
||||
or plan.target_task_id != binding.task_id
|
||||
or plan.target_job_id != binding.job_id
|
||||
or plan.external_idempotency_key != binding.effect_key
|
||||
):
|
||||
raise ConflictError("C6 operation/root action authority mismatch")
|
||||
for name in ("writer_lease_request", "commit_request"):
|
||||
request = plan.effect_payload.get(name)
|
||||
if not isinstance(request, Mapping):
|
||||
raise ConflictError(f"exact root action {name} unavailable")
|
||||
expected = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"expected_branch_oid": binding.source_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"generation": binding.generation,
|
||||
}
|
||||
allowed = set(expected)
|
||||
if name == "writer_lease_request":
|
||||
expected.update(
|
||||
{"branch_mode": "CREATE_NEW", "base_oid": binding.source_oid}
|
||||
)
|
||||
allowed.update({"branch_mode", "base_oid"})
|
||||
else:
|
||||
allowed.update({"files", "message"})
|
||||
if set(request) != allowed:
|
||||
raise ConflictError(f"exact root action {name} fields mismatch")
|
||||
if any(request.get(key) != value for key, value in expected.items()):
|
||||
raise ConflictError(f"exact root action {name} binding mismatch")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def _c6_commit_mutation(
|
||||
*, binding, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
"""Bind the real Bridge mutation to the signed C6 operation identity."""
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if hashlib.sha256(patch_bytes).hexdigest() != contract.patch_digest:
|
||||
raise ConflictError("C6 patch bytes digest mismatch")
|
||||
commit = plan.effect_payload.get("commit_request")
|
||||
if not isinstance(commit, Mapping):
|
||||
raise ConflictError("exact root action commit request unavailable")
|
||||
if commit.get("message") != commit_message:
|
||||
raise ConflictError("C6 commit message/root mutation mismatch")
|
||||
raw_files = commit.get("files")
|
||||
if not isinstance(raw_files, (list, tuple)) or isinstance(
|
||||
raw_files, (str, bytes)
|
||||
):
|
||||
raise ConflictError("C6 exact commit files require Bridge entry array")
|
||||
actual: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
seen_folded: set[str] = set()
|
||||
for raw in raw_files:
|
||||
if not isinstance(raw, Mapping) or set(raw) != {"path", "content_base64"}:
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
path = raw.get("path")
|
||||
encoded = raw.get("content_base64")
|
||||
if not isinstance(path, str) or not path or not isinstance(encoded, str):
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
if path in seen or path.casefold() in seen_folded:
|
||||
raise ConflictError("C6 exact commit files contain duplicate paths")
|
||||
try:
|
||||
content = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
|
||||
raise ConflictError("C6 exact commit file content is invalid") from exc
|
||||
seen.add(path)
|
||||
seen_folded.add(path.casefold())
|
||||
actual.append({"path": path, "sha256": hashlib.sha256(content).hexdigest()})
|
||||
actual.sort(key=lambda item: item["path"])
|
||||
expected = [
|
||||
{"path": path, "sha256": digest}
|
||||
for path, digest in contract.file_digests
|
||||
]
|
||||
if any(item["sha256"] == "DELETED" for item in expected) or actual != expected:
|
||||
raise ConflictError("C6 patch contract/root files mismatch")
|
||||
mutation = {
|
||||
"schema": "aios.c6.commit-mutation.v1",
|
||||
"files": actual,
|
||||
"message": commit_message,
|
||||
}
|
||||
mutation_digest = hashlib.sha256(
|
||||
canonical_json(mutation).encode("utf-8")
|
||||
).hexdigest()
|
||||
expected_projection = {
|
||||
"schema": "aios.c6.root-plan-binding.v1",
|
||||
"operation_binding_digest": binding_digest(binding),
|
||||
"plan_digest": binding.plan_digest,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"commit_mutation_digest": mutation_digest,
|
||||
}
|
||||
projection = plan.effect_payload.get("c6_operation_binding")
|
||||
if not isinstance(projection, Mapping) or dict(projection) != expected_projection:
|
||||
raise ConflictError("C6 operation/root plan mutation identity mismatch")
|
||||
return {**mutation, "digest": mutation_digest}
|
||||
|
||||
@staticmethod
|
||||
def _host_binding_document(
|
||||
*, binding, claim, authority, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
mutation = RootActionConsumer._c6_commit_mutation(
|
||||
binding=binding,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "aios.c6.root-action-host-binding.v2",
|
||||
"action_id": plan.execution_key,
|
||||
"operation_binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"effect_key": binding.effect_key,
|
||||
"claim_receipt": claim.to_dict(),
|
||||
"authorization_ref": authority.authorization_ref,
|
||||
"authority_key_id": authority.authority_key_id,
|
||||
"authority_key_fingerprint": authority.authority_key_fingerprint,
|
||||
"authority_record_sha256": authority.authority_record_sha256,
|
||||
"authority_generation": authority.authority_generation,
|
||||
"task_generation": authority.task_generation,
|
||||
"expected_branch_oid": authority.expected_branch_oid,
|
||||
"review_id": plan.review_id,
|
||||
"mission_id": plan.mission_id,
|
||||
"root_task_id": plan.root_task_id,
|
||||
"root_revision": plan.root_revision,
|
||||
"plan_digest": plan.plan_digest,
|
||||
"root_execution_key": plan.execution_key,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"c6_plan_digest": binding.plan_digest,
|
||||
"commit_mutation_digest": mutation["digest"],
|
||||
"commit_message_sha256": hashlib.sha256(
|
||||
commit_message.encode("utf-8")
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_host_document(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConflictError("exact host binding is unavailable")
|
||||
result = dict(value)
|
||||
if result.get("schema") != "aios.c6.root-action-host-binding.v2":
|
||||
raise ConflictError("exact host binding schema is invalid")
|
||||
return result
|
||||
|
||||
def _action_from_row(self, row):
|
||||
from .host_patch_executor import HostPatchAction
|
||||
|
||||
raw = str(row["host_binding_json"] or "")
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
if not raw or row["host_binding_digest"] != digest:
|
||||
raise ConflictError("exact host binding digest mismatch")
|
||||
host = self._validate_host_document(json.loads(raw))
|
||||
if host.get("effect_key") != row["host_effect_key"]:
|
||||
raise ConflictError("exact host effect key mismatch")
|
||||
lease = (
|
||||
json.loads(str(row["writer_lease_json"]))
|
||||
if row["writer_lease_json"] is not None
|
||||
else None
|
||||
)
|
||||
receipt = (
|
||||
json.loads(str(row["receipt_json"]))
|
||||
if row["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if row["state"] == "SUCCEEDED":
|
||||
state = "COMMITTED"
|
||||
elif row["state"] == "BLOCKED":
|
||||
state = "FAILED_FINAL"
|
||||
elif row["effect_phase"] == "BEFORE_LEASE":
|
||||
state = "PREPARED"
|
||||
else:
|
||||
state = "EFFECT_UNKNOWN"
|
||||
lease_id = None
|
||||
lease_fence = None
|
||||
if isinstance(lease, Mapping):
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
lease_fence = lease.get("writer_lease_fence")
|
||||
return HostPatchAction(
|
||||
action_id=str(host["action_id"]),
|
||||
state=state,
|
||||
binding_digest=str(host["binding_digest"]),
|
||||
effect_key=str(host["effect_key"]),
|
||||
authorization_ref=str(host["authorization_ref"]),
|
||||
authority_key_id=str(host["authority_key_id"]),
|
||||
authority_key_fingerprint=str(host["authority_key_fingerprint"]),
|
||||
authority_record_sha256=str(host["authority_record_sha256"]),
|
||||
claim_id=str(host["claim_receipt"]["claim_id"]),
|
||||
claim_fence=int(host["claim_receipt"]["receipt_revision"]),
|
||||
task_generation=int(host["task_generation"]),
|
||||
authority_generation=int(host["authority_generation"]),
|
||||
expected_branch_oid=str(host["expected_branch_oid"]),
|
||||
claim_receipt=dict(host["claim_receipt"]),
|
||||
writer_lease_id=(str(lease_id) if lease_id is not None else None),
|
||||
writer_lease_fence=(int(lease_fence) if lease_fence is not None else None),
|
||||
result_oid=(
|
||||
str(receipt["commit_oid"])
|
||||
if isinstance(receipt, Mapping) and receipt.get("commit_oid") is not None
|
||||
else None
|
||||
),
|
||||
receipt_ref=(
|
||||
str(row["receipt_digest"])
|
||||
if row["receipt_digest"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def read_exact(self, effect_key: str):
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(effect_key,),
|
||||
).fetchone()
|
||||
return None if row is None else self._action_from_row(row)
|
||||
|
||||
def iter_pending_exact(self, *, limit: int = 16):
|
||||
"""Enumerate durable host intents from the existing effect ledger."""
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise ConflictError("bounded exact pending limit required")
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects "
|
||||
"WHERE host_effect_key IS NOT NULL AND host_binding_json IS NOT NULL "
|
||||
"AND host_closure_digest IS NULL "
|
||||
"ORDER BY created_at,execution_key LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
action = self._action_from_row(row)
|
||||
if action.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
continue
|
||||
raw = self._validate_host_document(
|
||||
json.loads(str(row["host_binding_json"]))
|
||||
).get("operation_binding")
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("pending exact operation binding unavailable")
|
||||
try:
|
||||
operation = OperationBinding(**dict(raw))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConflictError("pending exact operation binding invalid") from exc
|
||||
result.append((operation, action))
|
||||
return tuple(result)
|
||||
|
||||
def mark_exact_closed(self, *, binding, action_id: str, observation) -> None:
|
||||
from .operation_view import (
|
||||
ClosureObservation,
|
||||
binding_digest,
|
||||
derive_closure,
|
||||
)
|
||||
|
||||
if not isinstance(observation, ClosureObservation):
|
||||
raise ConflictError("typed exact host closure observation required")
|
||||
observed_at = self._clock()
|
||||
now = self._now(observed_at)
|
||||
closure = derive_closure(binding, observation, now=observed_at)
|
||||
if (
|
||||
closure.execution_recorded,
|
||||
closure.independently_verified,
|
||||
closure.business_accepted,
|
||||
closure.outcome,
|
||||
closure.reason_code,
|
||||
) != (True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID"):
|
||||
raise ConflictError("exact host closure is incomplete")
|
||||
|
||||
with self.store._transaction():
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("exact host action unavailable")
|
||||
action = self._action_from_row(row)
|
||||
expected_binding_digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
verification = observation.verification
|
||||
acceptance = observation.acceptance
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref
|
||||
or verification.receipt_ref
|
||||
)
|
||||
if (
|
||||
action.action_id != action_id
|
||||
or action.state != "COMMITTED"
|
||||
or action.binding_digest != expected_binding_digest
|
||||
or not action.receipt_ref
|
||||
or effect.binding_digest != expected_binding_digest
|
||||
or effect.receipt_ref != action.receipt_ref
|
||||
or effect.result_artifact_digest is None
|
||||
or verification.binding_digest != expected_binding_digest
|
||||
or verification.receipt_ref != action.receipt_ref
|
||||
or verification.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or not verifier_receipt_ref
|
||||
or acceptance.binding_digest != expected_binding_digest
|
||||
or acceptance.receipt_ref != action.receipt_ref
|
||||
or acceptance.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or acceptance.verifier_receipt_ref != verifier_receipt_ref
|
||||
):
|
||||
raise ConflictError("exact host closure/action binding mismatch")
|
||||
|
||||
observation_value = _canonical_observation_value(asdict(observation))
|
||||
observation_raw = canonical_json(observation_value)
|
||||
observation_digest = hashlib.sha256(
|
||||
observation_raw.encode("utf-8")
|
||||
).hexdigest()
|
||||
document = {
|
||||
"schema": "aios.c6.three-layer-closure.v2",
|
||||
"binding_digest": action.binding_digest,
|
||||
"effect_key": action.effect_key,
|
||||
"action_id": action.action_id,
|
||||
"root_receipt_ref": action.receipt_ref,
|
||||
"effect_receipt_ref": effect.receipt_ref,
|
||||
"result_artifact_digest": effect.result_artifact_digest,
|
||||
"verification_receipt_ref": verifier_receipt_ref,
|
||||
"verifier_id": verification.verifier_id,
|
||||
"acceptance_revision": acceptance.acceptance_revision,
|
||||
"acceptor_id": acceptance.acceptor_id,
|
||||
"expected_acceptance_revision": (
|
||||
observation.expected_acceptance_revision
|
||||
),
|
||||
"expected_acceptor_id": observation.expected_acceptor_id,
|
||||
"closure_observation_digest": observation_digest,
|
||||
"execution_recorded": closure.execution_recorded,
|
||||
"independently_verified": closure.independently_verified,
|
||||
"business_accepted": closure.business_accepted,
|
||||
"outcome": closure.outcome,
|
||||
"reason_code": closure.reason_code,
|
||||
}
|
||||
raw = canonical_json(document)
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_closure_json=?,"
|
||||
"host_closure_digest=?,host_closure_observed_at=?,updated_at=? "
|
||||
"WHERE host_effect_key=? AND state='SUCCEEDED' "
|
||||
"AND host_binding_json=? AND host_binding_digest=? "
|
||||
"AND receipt_digest=? AND host_closure_digest IS NULL",
|
||||
(
|
||||
raw, digest, now, now, binding.effect_key,
|
||||
row["host_binding_json"], row["host_binding_digest"],
|
||||
action.receipt_ref,
|
||||
),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_closure_json,host_closure_digest "
|
||||
"FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
current is None
|
||||
or current["host_closure_json"] != raw
|
||||
or current["host_closure_digest"] != digest
|
||||
):
|
||||
raise ConflictError("exact host closure CAS conflict")
|
||||
|
||||
@staticmethod
|
||||
def _assert_action_authority(action, binding, authority) -> None:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or action.authorization_ref != authority.authorization_ref
|
||||
or action.authority_key_id != authority.authority_key_id
|
||||
or action.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or action.authority_record_sha256 != authority.authority_record_sha256
|
||||
or action.authority_generation != authority.authority_generation
|
||||
or action.task_generation != authority.task_generation
|
||||
or action.claim_id != authority.claim_id
|
||||
or action.claim_fence != authority.claim_fence
|
||||
or action.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise ConflictError("exact host action authority mismatch")
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
lease_seconds = max(1, min(60, int(authority.expires_at - now.timestamp())))
|
||||
result = self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
prepare_only=True,
|
||||
)
|
||||
if result.detail != "PREPARED":
|
||||
raise ConflictError("exact root action was not prepared")
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
raise ConflictError("prepared exact root action is unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return action
|
||||
|
||||
def _claim_exact_existing(self, binding, action, authority, now: float):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
if plan.execution_key != action.action_id:
|
||||
raise ConflictError("exact root action identity changed")
|
||||
claimed = self._claim_next(
|
||||
consumer_id=f"c6:{action.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
)
|
||||
if not isinstance(claimed, RootActionClaim):
|
||||
raise ConflictError("exact root action claim unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return claimed
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
authority,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state in {"COMMITTED", "FAILED_FINAL", "PREPARED"}:
|
||||
return action
|
||||
now = self._now(self._clock())
|
||||
claim = self._claim_exact_existing(binding, action, authority, now)
|
||||
_state, _receipt, phase = self._prepare_effect(claim, now)
|
||||
if phase in {"BEFORE_LEASE", "LEASE_ACQUIRED"}:
|
||||
return self.read_exact(binding.effect_key)
|
||||
if self._executor is None:
|
||||
raise ConflictError("root action executor unavailable")
|
||||
|
||||
def revalidate() -> None:
|
||||
self._assert_live_claim(claim, now, renew=True)
|
||||
before_external_call()
|
||||
|
||||
revalidate()
|
||||
observation = self._executor.reconcile(
|
||||
claim.plan, before_external_call=revalidate
|
||||
)
|
||||
revalidate()
|
||||
result = self._record_observation(claim, observation, now)
|
||||
if result is None:
|
||||
self._rereview(claim, now)
|
||||
observed = self.read_exact(binding.effect_key)
|
||||
if observed is None:
|
||||
raise ConflictError("reconciled exact root action is unavailable")
|
||||
return observed
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state != "PREPARED":
|
||||
return action
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
persisted = self.store.conn.execute(
|
||||
"SELECT host_binding_json FROM task_root_action_effects "
|
||||
"WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if persisted is None or persisted["host_binding_json"] != canonical_json(host):
|
||||
raise ConflictError("prepared exact host binding changed")
|
||||
self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=self._clock(),
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
external_revalidator=before_external_call,
|
||||
)
|
||||
completed = self.read_exact(binding.effect_key)
|
||||
if completed is None:
|
||||
raise ConflictError("exact root action completion unavailable")
|
||||
return completed
|
||||
|
||||
|
||||
class PublicBridgeRootActionExecutor:
|
||||
"""Contract adapter for the authenticated public Bridge operation surface."""
|
||||
@@ -1329,6 +2132,7 @@ class PublicBridgeRootActionExecutor:
|
||||
raise ConflictError("Bridge writer lease response binding mismatch")
|
||||
if "job_id" in result and result["job_id"] != plan.target_job_id:
|
||||
raise ConflictError("Bridge writer lease response Job mismatch")
|
||||
return dict(result)
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
|
||||
@@ -15,7 +15,7 @@ claim transaction / repository operation.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
from typing import Any, Callable, Iterable, Mapping, Protocol, Sequence
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
@@ -94,6 +94,7 @@ class ClaimConditions:
|
||||
authorization_revision: str
|
||||
scope_digest: str
|
||||
capacity_limit: int
|
||||
operation_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -121,6 +122,7 @@ class SchedulabilityObservation:
|
||||
dependencies_satisfied: bool
|
||||
freeze_blocked: bool
|
||||
conflicting_lease: bool
|
||||
operation_digest: str | None = None
|
||||
|
||||
@property
|
||||
def capacity_remaining(self) -> int:
|
||||
@@ -128,7 +130,7 @@ class SchedulabilityObservation:
|
||||
|
||||
def stable_binding(self) -> dict[str, object]:
|
||||
"""Fields whose change invalidates an earlier proof."""
|
||||
return {
|
||||
binding = {
|
||||
"task_id": self.task_id,
|
||||
"task_revision": self.task_revision,
|
||||
"task_generation": self.task_generation,
|
||||
@@ -149,6 +151,9 @@ class SchedulabilityObservation:
|
||||
"conflicting_lease": self.conflicting_lease,
|
||||
"heartbeat_fresh": self.heartbeat_fresh,
|
||||
}
|
||||
if self.operation_digest is not None:
|
||||
binding["operation_digest"] = self.operation_digest
|
||||
return binding
|
||||
|
||||
def claim_conditions(self) -> ClaimConditions:
|
||||
return ClaimConditions(
|
||||
@@ -161,6 +166,7 @@ class SchedulabilityObservation:
|
||||
authorization_revision=self.authorization_revision,
|
||||
scope_digest=self.scope_digest,
|
||||
capacity_limit=self.capacity_limit,
|
||||
operation_digest=self.operation_digest,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +200,11 @@ class SchedulabilityProof:
|
||||
if ttl_seconds <= 0 or ttl_seconds > 300:
|
||||
raise ValueError("schedulability proof ttl must be in (0,300]")
|
||||
payload = {
|
||||
"schema": "aios.schedulability-proof.v2",
|
||||
"schema": (
|
||||
"aios.schedulability-proof.v3"
|
||||
if observation.operation_digest is not None
|
||||
else "aios.schedulability-proof.v2"
|
||||
),
|
||||
"proof_id": proof_id or f"sched-{uuid.uuid4()}",
|
||||
"task_id": _text(task_id, "task_id"),
|
||||
"worker_id": _text(worker_id, "worker_id"),
|
||||
@@ -301,12 +311,18 @@ class SchedulabilityService:
|
||||
clock=time.time,
|
||||
proof_ttl_seconds: float = 30.0,
|
||||
heartbeat_grace_factor: float = 3.0,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] | None = None,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.proofs = proofs
|
||||
self.clock = clock
|
||||
self.proof_ttl_seconds = proof_ttl_seconds
|
||||
self.heartbeat_grace_factor = heartbeat_grace_factor
|
||||
if capability_matcher is None:
|
||||
from .capabilities import has_capabilities
|
||||
|
||||
capability_matcher = has_capabilities
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _caps(value: object) -> tuple[str, ...]:
|
||||
@@ -366,9 +382,8 @@ class SchedulabilityService:
|
||||
blockers.append(capacity_error)
|
||||
if not fresh:
|
||||
blockers.append(Blocker("STALE_OR_INACTIVE_WORKER", "worker", "worker heartbeat is not fresh"))
|
||||
missing = sorted(set(required) - set(offered))
|
||||
if missing:
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(missing)))
|
||||
if not self.capability_matcher(offered, required):
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(required)))
|
||||
if not scope_ok:
|
||||
blockers.append(Blocker("EXACT_SCOPE_MISMATCH", "authorization", "worker/task signed scope mismatch"))
|
||||
if not auth_allowed:
|
||||
@@ -403,6 +418,11 @@ class SchedulabilityService:
|
||||
dependencies_satisfied=deps,
|
||||
freeze_blocked=freeze,
|
||||
conflicting_lease=lease,
|
||||
operation_digest=(
|
||||
str(task["operation_digest"])
|
||||
if task.get("operation_digest") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return obs, tuple(blockers)
|
||||
|
||||
@@ -468,7 +488,14 @@ class SchedulabilityService:
|
||||
return proof
|
||||
|
||||
@staticmethod
|
||||
def _validate_claim_receipt(result: Mapping[str, Any]) -> None:
|
||||
def _validate_claim_receipt(
|
||||
result: Mapping[str, Any], proof: SchedulabilityProof
|
||||
) -> None:
|
||||
if proof.schema == "aios.schedulability-proof.v3":
|
||||
from .schedulability_store_adapter import validate_task_plane_receipt
|
||||
|
||||
validate_task_plane_receipt(result, proof=proof)
|
||||
return
|
||||
required = ("claim_id", "lease_id", "fencing_generation", "receipt_revision")
|
||||
missing = [name for name in required if result.get(name) in (None, "")]
|
||||
if missing:
|
||||
@@ -493,6 +520,11 @@ class SchedulabilityService:
|
||||
raise SchedulabilityChanged("proof expired")
|
||||
if proof.worker_id != worker_id or proof.actor_identity != actor_identity:
|
||||
raise SchedulabilityChanged("proof identity mismatch")
|
||||
if (
|
||||
getattr(self.authority, "requires_v3_proof", False)
|
||||
and proof.schema != "aios.schedulability-proof.v3"
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim authority requires v3 proof")
|
||||
current, blockers = self.observe(proof.task_id, worker_id, actor_identity)
|
||||
if blockers:
|
||||
raise SchedulabilityChanged(
|
||||
@@ -509,5 +541,5 @@ class SchedulabilityService:
|
||||
lease_seconds=lease_seconds,
|
||||
schedulability_proof_digest=proof.proof_digest,
|
||||
)
|
||||
self._validate_claim_receipt(result)
|
||||
self._validate_claim_receipt(result, proof)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Task Plane-backed atomic schedulability authority for the C6 pilot.
|
||||
|
||||
This adapter introduces no lease or authority database. It is enabled only
|
||||
when a host supplies a binding that holds the TaskPlane transaction together
|
||||
with its real authorization and freeze boundary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Callable, Iterable, Literal, Mapping, Protocol
|
||||
import math
|
||||
import time
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import ConflictError, LockedError, ServiceUnavailableError
|
||||
from .schedulability import ClaimConditions, SchedulabilityChanged, SchedulabilityProof
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
_RECEIPT_SCHEMA = "aios.task-plane-claim-receipt.v3"
|
||||
_RECEIPT_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authority_namespace",
|
||||
"task_id",
|
||||
"claim_id",
|
||||
"worker_id",
|
||||
"worker_identity",
|
||||
"claimed_revision",
|
||||
"receipt_revision",
|
||||
"attempt",
|
||||
"operation_digest",
|
||||
"lease_until",
|
||||
"proof_digest",
|
||||
}
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthorityAtomicBindingUnavailable",
|
||||
"ClaimAuthorityBinding",
|
||||
"ClaimTransaction",
|
||||
"ScopedLeaseController",
|
||||
"TaskPlaneClaimReceiptV3",
|
||||
"TaskPlaneSchedulabilityAuthority",
|
||||
"validate_task_plane_receipt",
|
||||
]
|
||||
|
||||
|
||||
class AuthorityAtomicBindingUnavailable(ServiceUnavailableError):
|
||||
code = "AUTHORITY_ATOMIC_BINDING_UNAVAILABLE"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class ClaimTransaction(Protocol):
|
||||
"""A host-owned context that holds the store and authority lock together."""
|
||||
|
||||
store: TaskPlaneStore
|
||||
authority_id: str
|
||||
|
||||
def assert_conditions(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_holder(
|
||||
self, task_id: str, claim_id: str, worker_id: str, actor_identity: str
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_authorized(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
receipt: TaskPlaneClaimReceiptV3,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ClaimAuthorityBinding(Protocol):
|
||||
"""Host integration point; request-provided callbacks are never accepted."""
|
||||
|
||||
def claim_context(self, store: TaskPlaneStore) -> AbstractContextManager[ClaimTransaction]: ...
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]: ...
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
_BINDING_METHODS = (
|
||||
"claim_context",
|
||||
"read_authorization",
|
||||
"exact_scope_digest",
|
||||
"freeze_blocked",
|
||||
)
|
||||
|
||||
|
||||
def _require_binding(store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
"""Reject a partial host wiring before it can produce a lease effect."""
|
||||
if store is None or binding is None or any(
|
||||
not callable(getattr(binding, name, None)) for name in _BINDING_METHODS
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPlaneClaimReceiptV3:
|
||||
schema: Literal["aios.task-plane-claim-receipt.v3"]
|
||||
authority_namespace: Literal["task-plane"]
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_id: str
|
||||
worker_identity: str
|
||||
claimed_revision: int
|
||||
receipt_revision: int
|
||||
attempt: int
|
||||
operation_digest: str
|
||||
lease_until: float
|
||||
proof_digest: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _int(value: object, name: str, *, minimum: int = 0) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _float(value: object, name: str) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))
|
||||
or float(value) <= 0
|
||||
):
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _parse_receipt(result: Mapping[str, object]) -> TaskPlaneClaimReceiptV3:
|
||||
if not isinstance(result, Mapping) or set(result) != _RECEIPT_FIELDS:
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt fields")
|
||||
if result["schema"] != _RECEIPT_SCHEMA or result["authority_namespace"] != "task-plane":
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt namespace")
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=_text(result["task_id"], "task_id"),
|
||||
claim_id=_text(result["claim_id"], "claim_id"),
|
||||
worker_id=_text(result["worker_id"], "worker_id"),
|
||||
worker_identity=_text(result["worker_identity"], "worker_identity"),
|
||||
claimed_revision=_int(result["claimed_revision"], "claimed_revision"),
|
||||
receipt_revision=_int(result["receipt_revision"], "receipt_revision"),
|
||||
attempt=_int(result["attempt"], "attempt", minimum=1),
|
||||
operation_digest=_text(result["operation_digest"], "operation_digest"),
|
||||
lease_until=_float(result["lease_until"], "lease_until"),
|
||||
proof_digest=_text(result["proof_digest"], "proof_digest"),
|
||||
)
|
||||
|
||||
|
||||
def validate_task_plane_receipt(
|
||||
result: Mapping[str, object], *, proof: SchedulabilityProof
|
||||
) -> TaskPlaneClaimReceiptV3:
|
||||
"""Validate the C6 receipt without treating it as a WriterLease fence."""
|
||||
if proof.schema != "aios.schedulability-proof.v3":
|
||||
raise SchedulabilityChanged("v3 receipt requires v3 proof")
|
||||
receipt = _parse_receipt(result)
|
||||
if (
|
||||
receipt.task_id != proof.task_id
|
||||
or receipt.worker_id != proof.worker_id
|
||||
or receipt.worker_identity != proof.observation.worker_identity
|
||||
or receipt.proof_digest != proof.proof_digest
|
||||
or receipt.operation_digest != proof.observation.operation_digest
|
||||
or receipt.receipt_revision <= receipt.claimed_revision
|
||||
or receipt.lease_until <= proof.created_at
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim receipt does not bind proof")
|
||||
return receipt
|
||||
|
||||
|
||||
class TaskPlaneSchedulabilityAuthority:
|
||||
"""Use the existing TaskPlane store as the only claim/lease authority."""
|
||||
|
||||
requires_v3_proof = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskPlaneStore,
|
||||
binding: ClaimAuthorityBinding,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] = has_capabilities,
|
||||
) -> None:
|
||||
_require_binding(store, binding)
|
||||
if not callable(capability_matcher):
|
||||
raise TypeError("capability_matcher must be callable")
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _task_view(task: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"generation": task.task_plan.metadata.get("generation", 0),
|
||||
"status": task.status,
|
||||
"required_capabilities": list(task.task_plan.required_capabilities),
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
"priority": task.task_plan.metadata.get("priority", 0),
|
||||
"created_at": task.created_at,
|
||||
"metadata": dict(task.task_plan.metadata),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _worker_view(worker: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"worker_id": worker.worker_id,
|
||||
"identity": worker.identity,
|
||||
"capabilities": list(worker.capabilities),
|
||||
"status": worker.status,
|
||||
"disabled": worker.disabled,
|
||||
"registration_revision": worker.registration_revision,
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
"heartbeat_interval_seconds": worker.heartbeat_interval_seconds,
|
||||
"last_heartbeat": worker.last_heartbeat,
|
||||
}
|
||||
|
||||
def read_task(self, task_id: str) -> Mapping[str, Any]:
|
||||
return self._task_view(self.store.get_task(task_id))
|
||||
|
||||
def list_candidate_tasks(self) -> list[Mapping[str, Any]]:
|
||||
return [self._task_view(task) for task in self.store.list_tasks()]
|
||||
|
||||
def read_worker(self, worker_id: str) -> Mapping[str, Any]:
|
||||
return self._worker_view(self.store.get_worker(worker_id))
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]:
|
||||
value = self.binding.read_authorization(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(value, Mapping)
|
||||
or not isinstance(value.get("allowed"), bool)
|
||||
or not isinstance(value.get("authorization_ref"), str)
|
||||
or not value["authorization_ref"]
|
||||
or not isinstance(value.get("revision"), str)
|
||||
or not value["revision"]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return value
|
||||
|
||||
def active_claim_count(self, worker_id: str) -> int:
|
||||
with self.store._lock:
|
||||
return int(
|
||||
self.store.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, time.time()),
|
||||
).fetchone()[0]
|
||||
)
|
||||
|
||||
def dependencies_satisfied(self, task: Mapping[str, Any]) -> bool:
|
||||
record = self.store.get_task(str(task["task_id"]))
|
||||
return all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in record.task_plan.dependencies
|
||||
)
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool:
|
||||
# Only the exact boolean False proves the absence of a freeze. An
|
||||
# unavailable or malformed authority value must deny an automatic claim.
|
||||
return self.binding.freeze_blocked(task) is not False
|
||||
|
||||
def conflicting_lease(self, task_id: str) -> bool:
|
||||
return self.store.active_claim(task_id) is not None
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]:
|
||||
result = self.binding.exact_scope_digest(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(result, tuple)
|
||||
or len(result) != 2
|
||||
or not isinstance(result[0], bool)
|
||||
or not isinstance(result[1], str)
|
||||
or not result[1]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return result
|
||||
|
||||
def claim_atomically(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
lease_seconds: int,
|
||||
schedulability_proof_digest: str,
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or lease_seconds <= 0:
|
||||
raise SchedulabilityChanged("invalid claim lease duration")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
transaction.assert_conditions(task_id, worker_id, actor_identity, expected)
|
||||
task = self.store.get_task(task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
operation_digest = task.task_plan.operation_digest()
|
||||
generation = task.task_plan.metadata.get("generation", 0)
|
||||
task_view = self._task_view(task)
|
||||
worker_view = self._worker_view(worker)
|
||||
authorization = self.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.revision != expected.task_revision
|
||||
or task.status != expected.task_status
|
||||
or generation != expected.task_generation
|
||||
or worker.registration_revision != expected.worker_generation
|
||||
or worker.identity != expected.worker_identity
|
||||
or worker.max_concurrency != expected.capacity_limit
|
||||
or (expected.operation_digest is not None and operation_digest != expected.operation_digest)
|
||||
or not self.capability_matcher(worker.capabilities, task.task_plan.required_capabilities)
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or authorization["allowed"] is not True
|
||||
or authorization["authorization_ref"] != expected.authorization_ref
|
||||
or authorization["revision"] != expected.authorization_revision
|
||||
or scope != (True, expected.scope_digest)
|
||||
or self.freeze_blocked(task_view)
|
||||
or not all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in task.task_plan.dependencies
|
||||
)
|
||||
or self.store.active_claim(task_id) is not None
|
||||
or self.active_claim_count(worker_id) >= worker.max_concurrency
|
||||
):
|
||||
raise ConflictError("atomic claim condition changed")
|
||||
claim_internal = getattr(self.store, "_claim_task_in_transaction", None)
|
||||
if not callable(claim_internal):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
claim = claim_internal(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
worker_identity=actor_identity,
|
||||
lease_seconds=lease_seconds,
|
||||
expected_revision=expected.task_revision,
|
||||
operation_digest=operation_digest,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
expected_binding=expected.to_dict(),
|
||||
)
|
||||
post_claim = self.store.get_task(task_id)
|
||||
receipt = TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=claim.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,
|
||||
claimed_revision=claim.task_revision,
|
||||
receipt_revision=post_claim.revision,
|
||||
attempt=claim.attempt,
|
||||
operation_digest=claim.operation_digest,
|
||||
lease_until=claim.lease_until,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
)
|
||||
return receipt.to_dict()
|
||||
|
||||
|
||||
class ScopedLeaseController:
|
||||
"""Renew an existing TaskPlane claim; it never creates a second lease."""
|
||||
|
||||
def __init__(self, store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
_require_binding(store, binding)
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
|
||||
def _validate_persisted_claim_evidence(
|
||||
self,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
active: Any,
|
||||
task: Any,
|
||||
worker: Any,
|
||||
) -> Mapping[str, object]:
|
||||
"""Bind a C6 receipt to the durable claim decision, not its caller input."""
|
||||
# The claim row is created before the task's CLAIMED transition. The
|
||||
# transition is the only source for a v3 receipt revision; accepting a
|
||||
# larger caller-provided value would let a legacy claim masquerade as
|
||||
# a C6 claim.
|
||||
if current.receipt_revision != active.task_revision + 1:
|
||||
raise ConflictError("receipt revision does not bind active claim")
|
||||
expected_task_revision = (
|
||||
current.receipt_revision
|
||||
if task.status == "CLAIMED"
|
||||
else current.receipt_revision + 1
|
||||
)
|
||||
if task.revision != expected_task_revision:
|
||||
raise ConflictError("receipt revision does not bind task state")
|
||||
|
||||
# A C6 proof digest is durable only when it appears in the exact
|
||||
# TASK_CLAIMED event for this claim. The legacy claim path records no
|
||||
# proof digest, so it cannot be upgraded by assembling v3-shaped JSON.
|
||||
if not self.store.verify_event_chain(current.task_id):
|
||||
raise ConflictError("claim event chain verification failed")
|
||||
claim_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if (
|
||||
event.get("type") == "TASK_CLAIMED"
|
||||
and isinstance(event.get("data"), Mapping)
|
||||
and event["data"].get("claim_id") == current.claim_id
|
||||
and event["data"].get("worker_id") == current.worker_id
|
||||
and event["data"].get("proof_digest") == current.proof_digest
|
||||
)
|
||||
]
|
||||
if len(claim_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to claimed event")
|
||||
expected_binding = claim_events[0]["data"].get("expected_binding")
|
||||
required_binding_fields = {
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
}
|
||||
if not isinstance(expected_binding, Mapping) or set(expected_binding) != required_binding_fields:
|
||||
raise ConflictError("claim lacks durable C6 authority binding")
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
isinstance(expected_binding["task_revision"], bool)
|
||||
or not isinstance(expected_binding["task_revision"], int)
|
||||
or expected_binding["task_revision"] < 0
|
||||
or expected_binding["task_revision"] != current.claimed_revision
|
||||
or expected_binding["task_revision"] != active.task_revision
|
||||
or isinstance(expected_binding["task_generation"], bool)
|
||||
or not isinstance(expected_binding["task_generation"], int)
|
||||
or expected_binding["task_generation"] != task_generation
|
||||
or expected_binding["task_status"] not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}
|
||||
or isinstance(expected_binding["worker_generation"], bool)
|
||||
or not isinstance(expected_binding["worker_generation"], int)
|
||||
or expected_binding["worker_generation"] < 1
|
||||
or expected_binding["worker_identity"] != current.worker_identity
|
||||
or isinstance(expected_binding["capacity_limit"], bool)
|
||||
or not isinstance(expected_binding["capacity_limit"], int)
|
||||
or expected_binding["capacity_limit"] < 1
|
||||
or expected_binding["capacity_limit"] != worker.max_concurrency
|
||||
or not isinstance(expected_binding["operation_digest"], str)
|
||||
or expected_binding["operation_digest"] != current.operation_digest
|
||||
or not isinstance(expected_binding["authorization_ref"], str)
|
||||
or not expected_binding["authorization_ref"]
|
||||
or not isinstance(expected_binding["authorization_revision"], str)
|
||||
or not expected_binding["authorization_revision"]
|
||||
or not isinstance(expected_binding["scope_digest"], str)
|
||||
or not expected_binding["scope_digest"]
|
||||
):
|
||||
raise ConflictError("durable C6 claim binding changed")
|
||||
|
||||
# The scheduler decision retains the remaining immutable claim facts.
|
||||
# Requiring exactly one matching decision also rejects a proof digest
|
||||
# copied between attempts for the same task.
|
||||
decision_payload = {
|
||||
"worker_id": current.worker_id,
|
||||
"worker_identity": current.worker_identity,
|
||||
"operation_digest": current.operation_digest,
|
||||
"attempt": current.attempt,
|
||||
"proof_digest": current.proof_digest,
|
||||
}
|
||||
decision_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if event.get("type") == "SCHEDULE_DECISION" and event.get("data") == decision_payload
|
||||
]
|
||||
if len(decision_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to schedule decision")
|
||||
self._validate_worker_heartbeat_chain(
|
||||
bound_revision=expected_binding["worker_generation"],
|
||||
worker=worker,
|
||||
claim_actor_identity=current.worker_identity,
|
||||
)
|
||||
return expected_binding
|
||||
|
||||
def _validate_worker_heartbeat_chain(
|
||||
self,
|
||||
*,
|
||||
bound_revision: object,
|
||||
worker: Any,
|
||||
claim_actor_identity: str,
|
||||
) -> None:
|
||||
"""Allow only a complete run of ordinary worker heartbeats after claim.
|
||||
|
||||
Worker registration revisions advance on every worker heartbeat. The
|
||||
worker audit stream is therefore the authoritative distinction between
|
||||
liveness refresh and re-registration or privilege change.
|
||||
"""
|
||||
current_revision = worker.registration_revision
|
||||
if (
|
||||
isinstance(bound_revision, bool)
|
||||
or not isinstance(bound_revision, int)
|
||||
or isinstance(current_revision, bool)
|
||||
or not isinstance(current_revision, int)
|
||||
or current_revision < bound_revision
|
||||
):
|
||||
raise ConflictError("worker registration revision changed")
|
||||
if current_revision == bound_revision:
|
||||
return
|
||||
|
||||
audit_task_id = "task-worker-registry"
|
||||
if not self.store.verify_event_chain(audit_task_id):
|
||||
raise ConflictError("worker registry event chain verification failed")
|
||||
expected_revisions = list(range(bound_revision + 1, current_revision + 1))
|
||||
events = []
|
||||
for event in self.store.list_events(audit_task_id):
|
||||
data = event.get("data")
|
||||
if not isinstance(data, Mapping) or data.get("worker_id") != worker.worker_id:
|
||||
continue
|
||||
revision = data.get("registration_revision")
|
||||
if isinstance(revision, bool) or not isinstance(revision, int) or revision <= bound_revision:
|
||||
continue
|
||||
events.append((event, data, revision))
|
||||
if len(events) != len(expected_revisions):
|
||||
raise ConflictError("worker registration audit chain incomplete")
|
||||
for expected_revision, (event, data, revision) in zip(expected_revisions, events):
|
||||
if (
|
||||
event.get("type") != "WORKER_HEARTBEAT"
|
||||
or set(data) != {"worker_id", "actor_identity", "registration_revision"}
|
||||
or data.get("actor_identity") != claim_actor_identity
|
||||
or revision != expected_revision
|
||||
):
|
||||
raise ConflictError("worker registration changed after C6 claim")
|
||||
|
||||
def _validate_active_receipt(
|
||||
self,
|
||||
transaction: ClaimTransaction,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
) -> None:
|
||||
"""Validate a receipt without extending its lease or granting a new one."""
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker = getattr(transaction, "assert_lease_holder", None)
|
||||
authorization_checker = getattr(transaction, "assert_lease_authorized", None)
|
||||
if not callable(holder_checker) or not callable(authorization_checker):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker(current.task_id, current.claim_id, worker_id, actor_identity)
|
||||
|
||||
# ``active_claim`` uses the store's authoritative clock and status
|
||||
# filter. Do not trust a receipt's former lease expiry by itself.
|
||||
active = self.store.active_claim(current.task_id)
|
||||
if active is None:
|
||||
raise LockedError("lease expired or inactive")
|
||||
if (
|
||||
active.claim_id != current.claim_id
|
||||
or active.task_id != current.task_id
|
||||
or active.worker_id != worker_id
|
||||
or active.worker_identity != actor_identity
|
||||
or active.task_revision != current.claimed_revision
|
||||
or active.operation_digest != current.operation_digest
|
||||
or active.attempt != current.attempt
|
||||
or active.lease_until <= time.time()
|
||||
):
|
||||
raise ConflictError("active claim does not match receipt")
|
||||
|
||||
task = self.store.get_task(current.task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
expected_binding = self._validate_persisted_claim_evidence(
|
||||
current, active=active, task=task, worker=worker
|
||||
)
|
||||
task_view = TaskPlaneSchedulabilityAuthority._task_view(task)
|
||||
worker_view = TaskPlaneSchedulabilityAuthority._worker_view(worker)
|
||||
authorization = self.binding.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.binding.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.status not in {"CLAIMED", "RUNNING"}
|
||||
or task.task_plan.operation_digest() != current.operation_digest
|
||||
or not isinstance(authorization, Mapping)
|
||||
or authorization.get("allowed") is not True
|
||||
or authorization.get("authorization_ref") != expected_binding["authorization_ref"]
|
||||
or authorization.get("revision") != expected_binding["authorization_revision"]
|
||||
or not isinstance(scope, tuple)
|
||||
or len(scope) != 2
|
||||
or scope[0] is not True
|
||||
or not isinstance(scope[1], str)
|
||||
or not scope[1]
|
||||
or scope[1] != expected_binding["scope_digest"]
|
||||
or self.binding.freeze_blocked(task_view) is not False
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or worker.identity != current.worker_identity
|
||||
or not has_capabilities(worker.capabilities, task.task_plan.required_capabilities)
|
||||
):
|
||||
raise ConflictError("lease authority changed")
|
||||
authorization_checker(
|
||||
current.task_id, current.claim_id, worker_id, actor_identity, current
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def execution_guard(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
):
|
||||
"""Hold the authoritative claim binding through one pilot critical section.
|
||||
|
||||
This guard never calls ``heartbeat_claim`` and never makes a new claim.
|
||||
Its context remains open for the caller's critical section, so another
|
||||
same-store state change cannot interleave between validation and use.
|
||||
"""
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
yield current
|
||||
|
||||
def renew(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
lease_seconds: int,
|
||||
) -> Mapping[str, object]:
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
renewed = self.store.heartbeat_claim(
|
||||
current.task_id,
|
||||
current.claim_id,
|
||||
lease_seconds,
|
||||
expected_task_revision=current.claimed_revision,
|
||||
operation_digest=current.operation_digest,
|
||||
)
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=renewed.task_id,
|
||||
claim_id=renewed.claim_id,
|
||||
worker_id=renewed.worker_id,
|
||||
worker_identity=renewed.worker_identity,
|
||||
claimed_revision=renewed.task_revision,
|
||||
receipt_revision=current.receipt_revision,
|
||||
attempt=renewed.attempt,
|
||||
operation_digest=renewed.operation_digest,
|
||||
lease_until=renewed.lease_until,
|
||||
proof_digest=current.proof_digest,
|
||||
).to_dict()
|
||||
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import (
|
||||
@@ -26,6 +26,7 @@ from .models import (
|
||||
ProbeOutcome,
|
||||
TaskPlan,
|
||||
TaskRecord,
|
||||
TaskStateTransitionError,
|
||||
WorkerClaim,
|
||||
WorkerRegistration,
|
||||
canonical_json,
|
||||
@@ -65,11 +66,38 @@ class TaskPlaneStore:
|
||||
self.high_assurance = high_assurance
|
||||
self.fault_injector = None
|
||||
self.probe_attestation_verifier = None
|
||||
self.evidence_attestation_verifier = None
|
||||
self._lock = threading.RLock()
|
||||
self._tx_state = threading.local()
|
||||
self._closed = False
|
||||
self._init_db(db_path)
|
||||
|
||||
@staticmethod
|
||||
def _verifier_identity(verifier: Any) -> str | None:
|
||||
identity = getattr(verifier, "verifier_identity", None)
|
||||
return str(identity) if identity else None
|
||||
|
||||
def configure_evidence_attestation_verifier(self, verifier: Any) -> Any:
|
||||
"""Install one evidence verifier without weakening an existing binding."""
|
||||
|
||||
current = self.evidence_attestation_verifier
|
||||
if verifier is None:
|
||||
return current
|
||||
if current is None:
|
||||
self.evidence_attestation_verifier = verifier
|
||||
return verifier
|
||||
if current is verifier:
|
||||
return current
|
||||
current_identity = self._verifier_identity(current)
|
||||
candidate_identity = self._verifier_identity(verifier)
|
||||
if (
|
||||
current_identity is not None
|
||||
and candidate_identity is not None
|
||||
and current_identity == candidate_identity
|
||||
):
|
||||
return current
|
||||
raise ValueError("conflicting evidence attestation verifier identity")
|
||||
|
||||
def _init_db(self, path: str) -> None:
|
||||
if path != ":memory:":
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
@@ -410,6 +438,18 @@ class TaskPlaneStore:
|
||||
# an external request. They must never migrate to a phase
|
||||
# that permits a new lease or commit.
|
||||
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
||||
# C6 exact-owner integration extends this existing effect
|
||||
# ledger. The versioned binding is immutable and its
|
||||
# separately indexed effect key permits exact readback;
|
||||
# it is deliberately not a second action ledger.
|
||||
"host_effect_key": "TEXT",
|
||||
"host_binding_json": "TEXT",
|
||||
"host_binding_digest": "TEXT",
|
||||
"writer_lease_json": "TEXT",
|
||||
"writer_lease_digest": "TEXT",
|
||||
"host_closure_json": "TEXT",
|
||||
"host_closure_digest": "TEXT",
|
||||
"host_closure_observed_at": "REAL",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
@@ -436,6 +476,11 @@ class TaskPlaneStore:
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
||||
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_action_host_effect "
|
||||
"ON task_root_action_effects(host_effect_key) "
|
||||
"WHERE host_effect_key IS NOT NULL"
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_missions (
|
||||
@@ -754,22 +799,60 @@ class TaskPlaneStore:
|
||||
status: str,
|
||||
failure_reason: str | None = None,
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
outer_depth = getattr(self._tx_state, "depth", 0)
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
observed = current
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
if outer_depth:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status=status, error=exc
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
raise
|
||||
|
||||
def _append_transition_denial(
|
||||
self,
|
||||
task_id: str,
|
||||
observed: TaskRecord,
|
||||
*,
|
||||
attempted_status: str,
|
||||
error: TaskStateTransitionError,
|
||||
) -> None:
|
||||
try:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_TRANSITION_DENIED",
|
||||
{
|
||||
"from_status": observed.status,
|
||||
"attempted_status": attempted_status,
|
||||
"observed_revision": observed.revision,
|
||||
"reason": str(error),
|
||||
},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
except Exception as audit_error:
|
||||
raise RuntimeError(
|
||||
"task transition denied but denial audit persistence failed"
|
||||
) from audit_error
|
||||
|
||||
def register_worker(
|
||||
self,
|
||||
@@ -1048,112 +1131,203 @@ class TaskPlaneStore:
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
with self._transaction():
|
||||
return self._claim_task_in_transaction(
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
lease_seconds,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
proof_digest=proof_digest,
|
||||
expected_binding=expected_binding,
|
||||
)
|
||||
|
||||
def _claim_task_in_transaction(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
worker_identity: str,
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
"""Claim after revalidating the supplied binding in the active transaction."""
|
||||
|
||||
now = _now()
|
||||
lease_until = now + max(1, int(lease_seconds))
|
||||
with self._transaction():
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if expected_binding is not None:
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
not isinstance(task_generation, int)
|
||||
or isinstance(task_generation, bool)
|
||||
or task_generation < 0
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
raise ConflictError("task generation invalid")
|
||||
checks = {
|
||||
"task_revision": task.revision,
|
||||
"task_generation": task_generation,
|
||||
"task_status": task.status,
|
||||
"worker_generation": worker.registration_revision,
|
||||
"worker_identity": worker.identity,
|
||||
"capacity_limit": worker.max_concurrency,
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
}
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
for field, actual in checks.items():
|
||||
if expected_binding.get(field) != actual:
|
||||
raise ConflictError(f"claim binding {field} mismatch")
|
||||
if expected_binding.get("operation_digest") != operation_digest:
|
||||
raise ConflictError("claim binding operation_digest mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
}
|
||||
if proof_digest is not None:
|
||||
schedule_payload["proof_digest"] = proof_digest
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
self.append_event(task_id, "TASK_CLAIMED", {"claim_id": claim_id, "worker_id": worker_id}, subject=f"task/{task_id}")
|
||||
return self._row_to_claim(self.conn.execute(
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
)
|
||||
claimed_payload = {"claim_id": claim_id, "worker_id": worker_id}
|
||||
if proof_digest is not None:
|
||||
claimed_payload["proof_digest"] = proof_digest
|
||||
if expected_binding is not None:
|
||||
# Preserve only the C6 conditions rechecked above plus the host
|
||||
# authorization/scope version fields. A later renew/execute must
|
||||
# bind to this durable decision, rather than caller-shaped receipt
|
||||
# JSON. Do not serialize arbitrary values accepted by this
|
||||
# internal compatibility seam.
|
||||
claimed_payload["expected_binding"] = {
|
||||
field: expected_binding.get(field)
|
||||
for field in (
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
)
|
||||
}
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CLAIMED",
|
||||
claimed_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return self._row_to_claim(
|
||||
self.conn.execute(
|
||||
"SELECT * FROM claims WHERE claim_id=?",
|
||||
(claim_id,),
|
||||
).fetchone())
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def heartbeat_claim(self, task_id: str, claim_id: str, lease_seconds: int) -> WorkerClaim:
|
||||
def heartbeat_claim(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
lease_seconds: int,
|
||||
*,
|
||||
expected_task_revision: int | None = None,
|
||||
operation_digest: str | None = None,
|
||||
) -> WorkerClaim:
|
||||
now = _now()
|
||||
lease_until = now + max(1, lease_seconds)
|
||||
with self._transaction():
|
||||
@@ -1168,14 +1342,32 @@ class TaskPlaneStore:
|
||||
raise ConflictError("claim not active")
|
||||
if claim.lease_until <= now:
|
||||
raise LockedError("lease expired")
|
||||
if (
|
||||
expected_task_revision is not None
|
||||
and claim.task_revision != expected_task_revision
|
||||
):
|
||||
raise ConflictError("claim task revision mismatch")
|
||||
if (
|
||||
operation_digest is not None
|
||||
and claim.operation_digest != operation_digest
|
||||
):
|
||||
raise ConflictError("claim operation digest mismatch")
|
||||
if self.get_task(task_id).status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
||||
raise ConflictError("task is terminal")
|
||||
updated = self.conn.execute(
|
||||
"""
|
||||
UPDATE claims SET lease_until=?
|
||||
WHERE claim_id=? AND task_id=? AND status='ACTIVE' AND lease_until>?
|
||||
AND task_revision=? AND operation_digest=?
|
||||
""",
|
||||
(lease_until, claim_id, task_id, now),
|
||||
(
|
||||
lease_until,
|
||||
claim_id,
|
||||
task_id,
|
||||
now,
|
||||
claim.task_revision,
|
||||
claim.operation_digest,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise LockedError("claim changed during heartbeat")
|
||||
@@ -1408,6 +1600,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.add_evidence_envelope(envelope)
|
||||
@@ -1455,16 +1648,26 @@ class TaskPlaneStore:
|
||||
def cancel_task_with_event(
|
||||
self, task_id: str, expected_revision: int, reason: str
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
observed = self.get_task(task_id)
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return updated
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status="CANCELLED", error=exc
|
||||
)
|
||||
return updated
|
||||
raise
|
||||
|
||||
def delegate_task_with_budget_and_events(
|
||||
self,
|
||||
@@ -1699,38 +1902,120 @@ class TaskPlaneStore:
|
||||
prev = event["hash"]
|
||||
return True
|
||||
|
||||
def add_evidence(self, task_id: str, kind: str, payload: dict[str, Any], worker_identity: str) -> str:
|
||||
def add_evidence(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
worker_identity: str,
|
||||
*,
|
||||
envelope: EvidenceEnvelope | None = None,
|
||||
claim_id: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> str:
|
||||
"""Add authenticated evidence while retaining the legacy call shape.
|
||||
|
||||
Old unsigned calls now fail with a migration error before any write.
|
||||
Producers may submit a complete envelope or all explicit proof fields.
|
||||
"""
|
||||
|
||||
if envelope is not None:
|
||||
if (
|
||||
envelope.task_id != task_id
|
||||
or envelope.kind != kind
|
||||
or envelope.worker_identity != worker_identity
|
||||
or envelope.payload != redact_value(payload)
|
||||
):
|
||||
raise ValidationError("evidence envelope arguments mismatch")
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
if not (signature or attestation_ref):
|
||||
raise ValidationError(
|
||||
"evidence authentication required; submit a complete verified "
|
||||
"envelope or explicit signature/provider proof"
|
||||
)
|
||||
claim = self.active_claim(task_id)
|
||||
claim_id = claim.claim_id if claim else "claim-unbound-shadow"
|
||||
envelope = EvidenceEnvelope.create(
|
||||
effective_claim_id = claim_id or (
|
||||
claim.claim_id if claim else "claim-unbound-shadow"
|
||||
)
|
||||
if claim is not None and effective_claim_id != claim.claim_id:
|
||||
raise ValidationError("evidence claim does not match active claim")
|
||||
built = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
claim_id=effective_claim_id,
|
||||
kind=kind,
|
||||
worker_identity=worker_identity,
|
||||
payload=payload,
|
||||
previous_evidence_hash=self._last_evidence_hash(task_id),
|
||||
evidence_id=evidence_id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
self.add_evidence_envelope(built)
|
||||
return built.evidence_id
|
||||
|
||||
def _last_evidence_hash(self, task_id: str) -> str:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT digest FROM evidence WHERE task_id=?
|
||||
ORDER BY created_at DESC,evidence_id DESC LIMIT 1
|
||||
ORDER BY rowid DESC LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return str(row["digest"]) if row else ""
|
||||
|
||||
def _last_evidence_sequence(self, authority_domain: str) -> int | None:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT MAX(CAST(json_extract(envelope_json, '$.sequence') AS INTEGER))
|
||||
FROM evidence
|
||||
WHERE json_extract(envelope_json, '$.authority_domain')=?
|
||||
AND json_type(envelope_json, '$.sequence')='integer'
|
||||
""",
|
||||
(authority_domain,),
|
||||
).fetchone()
|
||||
return None if row is None or row[0] is None else int(row[0])
|
||||
|
||||
def add_evidence_envelope(self, envelope: EvidenceEnvelope) -> str:
|
||||
with self._transaction():
|
||||
self.get_task(envelope.task_id)
|
||||
if (envelope.authority_domain is None) != (envelope.sequence is None):
|
||||
raise ConflictError("evidence authority domain and sequence must be paired")
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not envelope.authority_domain
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ConflictError("evidence authority sequence invalid")
|
||||
last_sequence = self._last_evidence_sequence(
|
||||
envelope.authority_domain
|
||||
)
|
||||
if last_sequence is not None and envelope.sequence <= last_sequence:
|
||||
raise ConflictError("evidence authority sequence not monotonic")
|
||||
expected_previous = self._last_evidence_hash(envelope.task_id)
|
||||
if not envelope.verify(
|
||||
expected_task_id=envelope.task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=expected_previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.conn.execute(
|
||||
@@ -1759,13 +2044,14 @@ class TaskPlaneStore:
|
||||
task_id = _validated_id(task_id, "task_id")
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY created_at ASC",
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY rowid ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def verify_evidence_integrity(self, task_id: str) -> bool:
|
||||
previous = ""
|
||||
domain_sequences: dict[str, int] = {}
|
||||
for evidence in self.list_evidence(task_id):
|
||||
try:
|
||||
raw = evidence.get("envelope_json")
|
||||
@@ -1781,10 +2067,24 @@ class TaskPlaneStore:
|
||||
worker_identity=payload["worker_identity"],
|
||||
payload=payload["payload"],
|
||||
created_at=float(payload["created_at"]),
|
||||
expires_at=float(
|
||||
payload.get(
|
||||
"expires_at", float(payload["created_at"]) + 300.0
|
||||
)
|
||||
),
|
||||
previous_evidence_hash=payload["previous_evidence_hash"],
|
||||
evidence_digest=payload["evidence_digest"],
|
||||
signature=payload.get("signature"),
|
||||
attestation_ref=payload.get("attestation_ref"),
|
||||
signature_algorithm=payload.get("signature_algorithm"),
|
||||
signing_key_id=payload.get("signing_key_id"),
|
||||
verifier_identity=payload.get("verifier_identity"),
|
||||
authority_domain=payload.get("authority_domain"),
|
||||
sequence=(
|
||||
None
|
||||
if payload.get("sequence") is None
|
||||
else int(payload["sequence"])
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
@@ -1798,9 +2098,20 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
)
|
||||
):
|
||||
return False
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence <= domain_sequences.get(
|
||||
envelope.authority_domain, 0
|
||||
)
|
||||
):
|
||||
return False
|
||||
domain_sequences[envelope.authority_domain] = envelope.sequence
|
||||
previous = envelope.evidence_digest
|
||||
return True
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -405,51 +407,85 @@ def run_secret_scan_provider(
|
||||
tree_oid: str,
|
||||
store_id: str = "secret_scan",
|
||||
provider_identity: str = "aios.secret-scanner",
|
||||
artifact_path: Path | None = None,
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute the real secret scanner (gitleaks) and produce a root receipt."""
|
||||
if not Path(gitleaks_bin).is_file():
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_UNAVAILABLE")
|
||||
artifact_sha = None
|
||||
if artifact_path is not None:
|
||||
artifact_sha = _validate_artifact_scan_target(artifact_path, target)
|
||||
# --no-git scans the working tree as a filesystem (does not require a .git
|
||||
# dir), so an exported release checkout can be scanned faithfully.
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "0",
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
warnings = 0
|
||||
# gitleaks exits 1 when findings are present; with --exit-code 0 it returns 0
|
||||
# but prints findings. Count lines that indicate a finding.
|
||||
finding_lines = [
|
||||
ln for ln in scanned.stdout.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
] + [
|
||||
ln for ln in scanned.stderr.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
]
|
||||
artifact_sha = None
|
||||
if target.is_file():
|
||||
artifact_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-secret-report-") as report_root:
|
||||
report = Path(report_root) / "findings.json"
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "1",
|
||||
"--report-format", "json", "--report-path", str(report),
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
try:
|
||||
findings = json.loads(report.read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID") from exc
|
||||
if not isinstance(findings, list) or any(not isinstance(v, dict) for v in findings):
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID")
|
||||
report_sha = hashlib.sha256(report.read_bytes()).hexdigest()
|
||||
if artifact_path is not None and _validate_artifact_scan_target(artifact_path, target) != artifact_sha:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_CHANGED")
|
||||
# Passed only when the scanner actually ran cleanly (exit 0) AND found zero
|
||||
# leaks. A scanner crash (non-zero exit) is never a pass.
|
||||
clean_run = scanned.returncode == 0
|
||||
result = {
|
||||
"passed": clean_run and len(finding_lines) == 0,
|
||||
"passed": clean_run and len(findings) == 0,
|
||||
"scanner": "gitleaks",
|
||||
"exit_code": scanned.returncode,
|
||||
"target": str(target),
|
||||
"findings": len(finding_lines),
|
||||
"findings": len(findings),
|
||||
"report_sha256": report_sha,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"clean_run": clean_run,
|
||||
}
|
||||
return result, _receipt_ref_for(result, "secret_scan")
|
||||
|
||||
|
||||
def _validate_artifact_scan_target(artifact: Path, target: Path) -> str:
|
||||
"""Bind a real filesystem scan to every byte of its exact archive payload."""
|
||||
if artifact.is_symlink() or not artifact.is_file() or target.is_symlink() or not target.is_dir():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_TARGET_INVALID")
|
||||
raw = artifact.read_bytes()
|
||||
import io
|
||||
expected: set[str] = set()
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as archive:
|
||||
members = archive.getmembers()
|
||||
if not members or len(members) > 20_000 or sum(m.size for m in members) > 2 * 1024**3:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_LIMIT")
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if not member.isfile() or member.name.startswith("/") or "\\" in member.name or not parts or any(p in {".", ".."} for p in parts) or member.name in expected:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID")
|
||||
path = target.joinpath(*parts)
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(target.resolve()):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
if archive.extractfile(member).read() != path.read_bytes():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
expected.add(member.name)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID") from exc
|
||||
actual = {p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file()}
|
||||
if actual != expected or any(p.is_symlink() for p in target.rglob("*")):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _receipt_ref_for(result: Mapping[str, Any], kind: str) -> str:
|
||||
run_id = hashlib.sha256(canonical_json_bytes(result)).hexdigest()[:16]
|
||||
return f"run-{run_id}/receipt.json"
|
||||
@@ -531,6 +567,7 @@ def _run_provider_executor(
|
||||
target=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
artifact_path=(Path(os.environ["AIOS_RELEASE_ARTIFACT"]) if os.environ.get("AIOS_RELEASE_ARTIFACT") else None),
|
||||
)
|
||||
if kind == "gitea":
|
||||
gitea_api_url = os.environ.get("AIOS_GITEA_API_URL", "").strip()
|
||||
@@ -601,9 +638,14 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--store-root", required=True)
|
||||
parser.add_argument("--receipt-ref", required=True)
|
||||
parser.add_argument("--repo-root", default=None, help="Real source checkout to execute against")
|
||||
parser.add_argument("--artifact-path", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.repo_root:
|
||||
os.environ["AIOS_REPO_ROOT"] = args.repo_root
|
||||
if args.artifact_path:
|
||||
if args.kind != "secret_scan":
|
||||
raise SystemExit("ARTIFACT_BINDING_REQUIRES_SECRET_SCAN")
|
||||
os.environ["AIOS_RELEASE_ARTIFACT"] = str(args.artifact_path)
|
||||
try:
|
||||
digest = produce(
|
||||
args.kind,
|
||||
|
||||
@@ -46,12 +46,23 @@ def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
||||
slug=version.lower()
|
||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
||||
|
||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def _health(url: str, binding: Mapping[str, Any], *, runtime_verifier=None) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# Health bodies are untrusted declarations; even a body containing PASS
|
||||
# cannot certify itself. A host-owned independent verifier must bind
|
||||
# the response to the exact candidate and fresh observed runtime.
|
||||
try:
|
||||
independently_verified = callable(runtime_verifier) and runtime_verifier(binding, body) is True
|
||||
except Exception:
|
||||
independently_verified = False
|
||||
result_status = "ACTIVE_VERIFIED" if exact and independently_verified else "UNKNOWN"
|
||||
else:
|
||||
result_status = "ACTIVE_VERIFIED" if exact else "CONFLICT"
|
||||
return {**body, "status": result_status, "http_status": status}
|
||||
|
||||
def _extract(artifact: Path, destination: Path) -> None:
|
||||
import tarfile
|
||||
@@ -70,7 +81,13 @@ def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
||||
|
||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
binding=_binding(value)
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# This adapter installs exactly one legacy health service. A C6
|
||||
# multi-component payload requires the governed component host binding;
|
||||
# do not report a health-only installation as a C6 deployment.
|
||||
raise AdapterBlocked("component_host_binding_required")
|
||||
p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
||||
|
||||
@@ -78,7 +78,12 @@ def serve(host: str, port: int) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("--root",type=Path,default=Path(".")); parser.add_argument("--manifest",type=Path); parser.add_argument("--serve",action="store_true"); parser.add_argument("--host",default="127.0.0.1"); parser.add_argument("--port",type=int,default=18927); args=parser.parse_args()
|
||||
if args.serve: serve(args.host,args.port); return 0
|
||||
if args.serve:
|
||||
# A health listener may report liveness only after its real artifact
|
||||
# manifest has been verified. This is intentionally not runtime
|
||||
# identity proof; that needs a separate trusted observation.
|
||||
verify_manifest(args.root,args.manifest)
|
||||
serve(args.host,args.port); return 0
|
||||
print(json.dumps(verify_manifest(args.root,args.manifest),sort_keys=True)); return 0
|
||||
|
||||
if __name__ == "__main__": raise SystemExit(main())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,6 +14,25 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
||||
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
||||
|
||||
def _loopback(value):
|
||||
try: return ipaddress.ip_address(value).is_loopback
|
||||
except (ValueError, TypeError): return False
|
||||
|
||||
def validate_dev_profile():
|
||||
if DEV_ALLOW and not (os.getenv('AIOS_RUNTIME_PROFILE') == 'isolated-staging' and _loopback(HOST)):
|
||||
raise RuntimeError('DEV_ALLOW requires isolated-staging and loopback')
|
||||
|
||||
def runtime_selection(principal, data):
|
||||
if not principal.get('token_ok'): raise PermissionError('authenticated principal required')
|
||||
if any(key in data for key in ('username', 'user_id', 'role', 'tier')):
|
||||
raise ValueError('runtime identity must come from authenticated principal')
|
||||
username=principal['username']; role=principal['role']
|
||||
if role not in _ROLE_WHITELIST or re.fullmatch(r'[A-Za-z0-9_@.-]{1,120}', username) is None or username in ('.','..'):
|
||||
raise ValueError('invalid authenticated principal')
|
||||
tier='ultra' if role=='owner' else 'regular'
|
||||
return username, role, tier
|
||||
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
||||
@@ -66,6 +85,7 @@ def token_identity(token: str):
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
@@ -83,7 +103,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -159,7 +181,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'not_found','path':path},404)
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
def do_POST(self):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == '/api/runtime/select':
|
||||
if not (_loopback(HOST) and _loopback(self.client_address[0])):
|
||||
self.send_json({'error':'loopback ingress required'},403); return
|
||||
a = self.require_write()
|
||||
if not a: return
|
||||
data = parse_body(self)
|
||||
try: runtime_selection(a, data)
|
||||
except (ValueError, PermissionError):
|
||||
self.send_json({'error':'invalid runtime selection identity'},403); return
|
||||
else:
|
||||
data = parse_body(self)
|
||||
init_db()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -213,7 +247,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not m: self.send_json({'error':'model not found'},404); return
|
||||
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
||||
if path == '/api/runtime/select':
|
||||
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
||||
username,role,tier=runtime_selection(a, data); risk=data.get('risk','normal')
|
||||
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
||||
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
||||
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
||||
@@ -268,5 +302,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
|
||||
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
||||
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -12,9 +12,10 @@ Version-controlled execution instructions for AIOS agents.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Create versioned instruction file.
|
||||
2. Target agent executes instruction.
|
||||
3. Result is recorded with GitOps audit information.
|
||||
1. Attach `UNATTENDED_EXECUTION_POLICY_V1.md` to every task instruction.
|
||||
2. Create the versioned, scope-specific instruction file.
|
||||
3. Target agent executes within the current authorization, policy and platform capability.
|
||||
4. Record result and evidence with GitOps audit information.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -25,5 +26,6 @@ agent-instructions/<agent>/<instruction-name>-v<version>.md
|
||||
- GitOps managed only.
|
||||
- Patch-first workflow.
|
||||
- No secrets, tokens, passwords or credentials.
|
||||
- Production changes require approval.
|
||||
- Main branch changes require approval.
|
||||
- Do not turn routine, already authorized steps into per-action Owner approval requests.
|
||||
- Production and protected-branch actions require their existing formal governance contract and machine gates; approval is needed only when the contract or gate explicitly requires a new Owner decision.
|
||||
- A denial, unknown outcome or unavailable capability is reported with its actual reason and is never bypassed by changing Agent or channel.
|
||||
|
||||
@@ -19,7 +19,8 @@ Target Agent:
|
||||
- Patch-first
|
||||
- No secrets
|
||||
- No force push
|
||||
- Production changes require approval
|
||||
- Follow `UNATTENDED_EXECUTION_POLICY_V1.md` for authorization inheritance, automatic progress, recovery and human-input necessity.
|
||||
- Production changes require a valid, exact-scope authorization contract and all configured machine gates; obtain Owner approval only where that contract or an existing governance gate requires it.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Unattended Execution Policy
|
||||
|
||||
Version: 1
|
||||
Applies to: Codex, OpenClaw, Hermes, Windows Codex App, custom review agents, and delegated child tasks.
|
||||
Status: Normative task policy. This file does not grant credentials, permissions, production access, or Owner authority.
|
||||
|
||||
## Default
|
||||
|
||||
For a user-authorized task with a clear goal, bounded scope, available capability, and affordable budget, continue through the necessary fact gathering, implementation, recovery, validation, evidence capture, and handoff without asking for routine confirmation. Do not ask whether to continue, run an already requested check, or choose a reasonable low-impact option.
|
||||
|
||||
Use current authoritative configuration, logs, artifacts, and supported APIs to obtain facts the Agent can retrieve. Keep the user's goal fixed; do not expand it into a new business objective or responsibility decision.
|
||||
|
||||
## Authorization and policy
|
||||
|
||||
- A child task, retry, resumed run, or Agent handoff inherits the root task's authorization evidence and latest valid version, narrowed by its own scope and current platform capability.
|
||||
- Recheck authorization, policy, target identity, and capability before each consequential action. A model or Agent change does not renew authority or reset cumulative time, cost, or attempt budgets.
|
||||
- Existing signed release, deployment, WriterLease, branch-protection, and production gates remain authoritative. A task instruction or Auto-review decision cannot mint or broaden those authorities.
|
||||
- Do not weaken a persistent policy, remove isolation, change credentials, or route around a denial to make the current task pass.
|
||||
- Classify outcomes separately: authorization (`VALID`, `OUT_OF_SCOPE`, `REVOKED`, `UNKNOWN`), policy (`ALLOW`, `DENY`, `CHECK_FAILED`), execution (`NOT_STARTED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `OUTCOME_UNKNOWN`), and workflow (`READY`, `RECOVERING`, `RECONCILING`, `BLOCKED_CAPABILITY`, `NEEDS_OWNER_INPUT`, `COMPLETED`).
|
||||
|
||||
## Recovery and coordination
|
||||
|
||||
- Record a stable operation identity before a side effect and a distinct attempt identity for each execution attempt. Reuse an operation identity only when the intended effect and material parameters are unchanged.
|
||||
- Retry within the existing budget only when non-execution is established or the target operation is idempotent. Reconcile an unknown result before any replay; never report success from an ambiguous response.
|
||||
- Persist the last confirmed step and its evidence so a restart or handoff can resume. Continue independent work when one branch is blocked.
|
||||
- Record platform denial, policy denial, missing capability, failed check, and execution failure as distinct causes. Do not turn a failure into a silent wait or an approval request.
|
||||
|
||||
## Human input threshold
|
||||
|
||||
Request an Owner only when a required fact cannot be obtained through an available supported capability, an action is physically or identity-bound to that person, or the task requires a substantive goal/ownership decision outside the active authorization and no approved default exists. Before requesting input, finish independent work and provide one concise request containing the missing item, governing rule, retrieval attempts, concrete alternatives, and how the answer changes execution.
|
||||
|
||||
## Acceptance contract
|
||||
|
||||
For a fixed, predeclared sample, report business completion separately from fault-handling correctness. Required aggregate targets are: normal in-scope business completion 100%; routine human approvals 0; human wait time 0; unauthorized effects 0; blind replay of unknown effects 0; and truthful failure/blocker recording 100%. A legitimate denial may pass a fault-handling case, but does not count as business completion.
|
||||
|
||||
The regression suite must cover:
|
||||
|
||||
1. Authorized change, validation, and evidence archiving complete without a human approval.
|
||||
2. A single reasonable low-impact option is selected and recorded.
|
||||
3. A known default resolves multiple low-impact options without a choice prompt.
|
||||
4. Changed artifacts and negative tests are revalidated against the new artifact identity.
|
||||
5. A child sees the latest valid root authorization version.
|
||||
6. Handoff, context compaction, and restart preserve authorization, cumulative budget, and last confirmed progress.
|
||||
7. Revoked or expired authority stops subsequent effects.
|
||||
8. A path or ordinary dependency error is diagnosed and repaired within scope.
|
||||
9. A real platform denial is recorded accurately; no alternate channel is used to evade it.
|
||||
10. Retrievable source material is obtained by the Agent without manual transfer by the user.
|
||||
11. A blocked child does not prevent independent work from progressing within the configured service interval.
|
||||
12. A confirmed pre-effect transport failure is retried within budget.
|
||||
13. A lost receipt after successful execution is reconciled by operation identity without duplicating the effect.
|
||||
14. Persistently unknown effects are reconciled without blind replay.
|
||||
15. Material parameter changes cannot reuse an old operation identity as a retry.
|
||||
16. Reviewer timeout fails closed and never authorizes the pending effect.
|
||||
17. In-scope use of an existing service credential succeeds without exposing it; an out-of-scope destination is denied.
|
||||
18. The execution Agent cannot widen policy or remove isolation to pass its own task.
|
||||
19. A formally pre-authorized release follows its machine checks, execution, health check, and contract-defined rollback path.
|
||||
20. Equivalent Agents produce consistent decisions under equivalent authority and capability.
|
||||
21. Agent/model changes do not reset cumulative budget; unknown usage stays explicitly unknown.
|
||||
22. Silent deferral and platform auto-rejection count as incomplete work.
|
||||
23. A genuinely person-bound action is reported once after independent work is complete.
|
||||
24. Duplicate event delivery or coordinator restart does not repeat an effect and does consume the continuation event.
|
||||
|
||||
These are acceptance requirements, not claims of runtime implementation or test results. Record evidence for each run and retain the fixed sample definition.
|
||||
@@ -6,12 +6,14 @@ Target Agent: codex
|
||||
## Objective
|
||||
Recover AIOS system issues requiring deep diagnosis or administrator assistance.
|
||||
|
||||
Follow `../UNATTENDED_EXECUTION_POLICY_V1.md`: carry authorized diagnosis, repair, validation and evidence capture through to a concrete result without routine confirmation.
|
||||
|
||||
## Execution
|
||||
1. Inspect runtime status.
|
||||
2. Inspect GitOps state.
|
||||
3. Identify root cause.
|
||||
4. Propose safe patch.
|
||||
5. Apply only approved GitOps changes.
|
||||
4. Prepare the smallest safe patch and validate it against the exact target and active governance contract.
|
||||
5. Apply changes that are within valid authorization and pass formal gates; reconcile ambiguous effects before retry.
|
||||
|
||||
## Escalation
|
||||
Use when server agents cannot resolve issues.
|
||||
@@ -20,7 +22,7 @@ Use when server agents cannot resolve issues.
|
||||
- GitOps only.
|
||||
- No secrets.
|
||||
- No force push.
|
||||
- Production changes require approval.
|
||||
- Production changes require exact-scope authorization and all formal governance gates. Obtain a new Owner decision only when the active contract or gate requires one; do not bypass or fabricate it.
|
||||
|
||||
## Return
|
||||
Status:
|
||||
|
||||
@@ -7,21 +7,20 @@ Target Agent: openclaw
|
||||
Maintain server-side AIOS runtime and perform safe optimization tasks.
|
||||
|
||||
## Execution
|
||||
1. Check runtime health.
|
||||
2. Review logs and GitOps status.
|
||||
3. Identify optimization opportunities.
|
||||
4. Create patch proposals.
|
||||
5. Execute approved changes.
|
||||
1. Check runtime health, logs, GitOps status and current authorization using available read interfaces.
|
||||
2. Diagnose failures and identify the smallest in-scope repair or optimization.
|
||||
3. Apply and validate changes covered by the current authorization and formal machine gates; persist evidence and recovery state.
|
||||
4. Continue independent work when one action is blocked.
|
||||
|
||||
## Escalation
|
||||
When solution selection or implementation is uncertain, prepare full context and escalate for review.
|
||||
Use an Agent review for uncertain technical choices when available. Request Owner input only for a substantive out-of-scope decision or an explicit Owner-only gate; include the concrete gap and finish independent work first.
|
||||
|
||||
## Constraints
|
||||
- GitOps only.
|
||||
- Patch-first workflow.
|
||||
- No secrets.
|
||||
- No force push.
|
||||
- Production release requires approval.
|
||||
- Production release requires its exact valid authorization contract and configured governance gates; a new Owner approval is required only when that contract or gate explicitly requires one.
|
||||
|
||||
## Return
|
||||
Status:
|
||||
|
||||
@@ -6,24 +6,22 @@ Target Agent: openclaw
|
||||
|
||||
## Objective
|
||||
|
||||
Manage owner approval workflow for high-risk AIOS operations.
|
||||
Manage Owner input only when the active formal governance contract requires a new Owner decision. Apply `../UNATTENDED_EXECUTION_POLICY_V1.md` to every task.
|
||||
|
||||
## Approval Required For
|
||||
## Cases That May Require a New Owner Decision
|
||||
|
||||
- V-level version changes.
|
||||
- Production release.
|
||||
- Tag creation.
|
||||
- Runtime changes with production impact.
|
||||
- Administrator authorization actions.
|
||||
- Unresolved multi-agent decisions.
|
||||
- V-level version changes, production release, tag creation, or production-impacting runtime changes when the exact action is not covered by a valid signed authorization/release contract or an existing governance gate explicitly requires a fresh Owner decision.
|
||||
- Administrator authorization actions that change persistent privileges or trust roots.
|
||||
- A substantive goal, ownership, or business-risk choice outside the active task authorization with no approved default.
|
||||
|
||||
An action category alone is not proof that a new approval is required. Read the current contract and gate state. Do not substitute an Agent review for a required signed Owner approval, and do not request the same approval again when valid evidence already covers the exact action.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect approval context.
|
||||
2. Include baseline version, branch, diff summary and risk assessment.
|
||||
3. Route approval request through owner communication channel registry.
|
||||
4. Wait for approval decision.
|
||||
5. Record decision in GitOps audit.
|
||||
1. Read the current authorization, signed contract, policy and gate evidence.
|
||||
2. Complete all in-scope checks and independent work; prepare the exact diff, baseline, risk, rollback and missing-decision summary.
|
||||
3. If and only if a new Owner decision is required, route one consolidated request through the configured channel and persist the dependency without polling or blocking independent work.
|
||||
4. Record any received decision and its evidence in GitOps audit; otherwise report `NEEDS_OWNER_INPUT` with the exact blocker.
|
||||
|
||||
## Approval Context
|
||||
|
||||
@@ -37,7 +35,8 @@ Must include:
|
||||
|
||||
## Constraints
|
||||
|
||||
- No direct production action without approval.
|
||||
- No production effect without valid exact-scope authorization and all formal governance gates.
|
||||
- Never fabricate approval, broaden a signed scope, or bypass a platform denial.
|
||||
- No secrets in messages or reports.
|
||||
- Use configured owner communication channels only.
|
||||
- Maintain audit records.
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
# AIOS Agent Policies
|
||||
Policies for AIOS Git Agent execution.
|
||||
|
||||
## Unattended execution baseline
|
||||
|
||||
Every dispatched instruction inherits [`agent-instructions/UNATTENDED_EXECUTION_POLICY_V1.md`](../../agent-instructions/UNATTENDED_EXECUTION_POLICY_V1.md). This policy removes routine per-action confirmation for work already within a valid authorization contract. It does not grant access or replace signed Owner approval, WriterLease, protected-branch rules, release gates, or platform capability checks.
|
||||
|
||||
For each rollout, record the fixed acceptance sample and evidence separately for normal business completion and fault handling. The policy's aggregate targets are goals; do not report them as measured until the runtime adapter and coordinator have produced receipts for the full sample.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# C4 capability contract provenance
|
||||
|
||||
The C4 checkout has no `aios_capability_contract.py`, although
|
||||
`active-runtime/app/aios_agent_task_protocol.py` imports it at module scope.
|
||||
The read-only QA capture used for the missing source closure is:
|
||||
|
||||
- source: `C:\\Users\\ImRuY\\.codex-restored\\work\\v2r2c5-release-20260926\\qa\\capability-contract.txt`
|
||||
- SHA256: `c97c9f8a3cf199a9c0348da1212aa26d161aad8ddaed84db534e46f490f2422f`
|
||||
- 140 lines
|
||||
|
||||
The contract preserves separate task and worker alias maps, maps unknown task
|
||||
names to `exact:<token>`, ignores unknown worker names, and returns false for
|
||||
unknown or mismatched eligibility. It is used by formal AgentTask poll and
|
||||
claim paths only. Generic `task_plane` matching remains outside this lane.
|
||||
|
||||
The authoritative runtime overlay is captured separately by QA:
|
||||
|
||||
- base SHA256: `953de1d491b4f1bc47c71f502027fd488e6c48ecc49da8a91dc9d8754c320d17`
|
||||
- overlay SHA256: `7555aa067fc01c6a25276a834dda4924b404f3b0c529ed3f99e9f1d83d18798e`
|
||||
|
||||
The consolidated module preserves the base mappings and adds only the seven
|
||||
explicit lifecycle labels: `agent-task:control`, `governed.task.create`,
|
||||
`governed.write.files`, `task.principal.issue`, `testing`, `verification`, and
|
||||
`writer.lease`. It does not depend on an absolute deployed path.
|
||||
|
||||
Validation logs:
|
||||
|
||||
- `PYTHONPATH=active-runtime/app python -m pytest -q tests/test_v2r2c5_capability_contract.py` -> 6 passed.
|
||||
- `PYTHONPATH=active-runtime/app;active-runtime python -m pytest -q tests/test_task_admission_bootstrap.py tests/test_task_admission_bootstrap_chain_contract.py` -> 45 passed.
|
||||
- `tests/test_task_admission_bootstrap_http.py` could not collect on Windows because the existing `gitea_tag_publication.py` imports unavailable `fcntl`; no capability assertion ran in that file.
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/authorization-bundle.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseAuthorizationBundle"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"bundle_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"bundle_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"REQUESTED",
|
||||
"ACTIVE",
|
||||
"REVOKED",
|
||||
"COMPLETED"
|
||||
]
|
||||
},
|
||||
"owner_intent_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"issuer_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"root_grant_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repositories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"environments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"component_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/action"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repositories",
|
||||
"environments",
|
||||
"component_ids",
|
||||
"actions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delegation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"child_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"successor_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"agent_transfer": {
|
||||
"const": true
|
||||
},
|
||||
"scope_must_not_expand": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"child_tasks",
|
||||
"successor_tasks",
|
||||
"agent_transfer",
|
||||
"scope_must_not_expand"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"validity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"not_before": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
},
|
||||
"until": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"RELEASE_CLOSED",
|
||||
"REVOKED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"revocation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"revocation_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"not_before",
|
||||
"until",
|
||||
"revocation_ref",
|
||||
"revocation_generation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"credentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maximum_ttl_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600
|
||||
},
|
||||
"renewable_within_goal": {
|
||||
"const": true
|
||||
},
|
||||
"issuer_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"maximum_ttl_seconds",
|
||||
"renewable_within_goal",
|
||||
"issuer_operation_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"forbidden": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"delete_database",
|
||||
"format_storage",
|
||||
"destroy_unique_backup",
|
||||
"change_root_trust",
|
||||
"disable_audit"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 5,
|
||||
"maxItems": 5
|
||||
},
|
||||
"owner_required_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/owner_reason"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 4,
|
||||
"maxItems": 4
|
||||
},
|
||||
"execution_binding": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"contract_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"task_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"attempt_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"lease_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"capability_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"contract_sha256",
|
||||
"content_key",
|
||||
"target_environment",
|
||||
"task_id",
|
||||
"attempt_id",
|
||||
"lease_generation",
|
||||
"capability_receipt_ref",
|
||||
"expires_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"bundle_id",
|
||||
"bundle_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"status",
|
||||
"owner_intent_ref",
|
||||
"issuer_ref",
|
||||
"root_grant_ref",
|
||||
"authority_receipt_ref",
|
||||
"scope",
|
||||
"delegation",
|
||||
"validity",
|
||||
"credentials",
|
||||
"risk_budget",
|
||||
"forbidden",
|
||||
"owner_required_reasons",
|
||||
"execution_binding",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"root_grant_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/common.schema.json",
|
||||
"$comment": "Local identifiers only; no network retrieval. LIVE_CONFIG is a declaration, never proof of authorization.",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"oid": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 3
|
||||
},
|
||||
"digest_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ref",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"stage": {
|
||||
"enum": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"source_update",
|
||||
"build",
|
||||
"test",
|
||||
"staging",
|
||||
"backup",
|
||||
"governed_merge",
|
||||
"production_switch",
|
||||
"rollback",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_update",
|
||||
"acceptance",
|
||||
"task_create",
|
||||
"agent_transfer",
|
||||
"credential_derive",
|
||||
"lease_renew",
|
||||
"evidence_append"
|
||||
]
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"OWNER",
|
||||
"AGENT",
|
||||
"SYSTEM"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fixture": {
|
||||
"enum": [
|
||||
"FIXTURE",
|
||||
"LIVE_CONFIG"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"CREATED",
|
||||
"PREPARING",
|
||||
"RUNNING",
|
||||
"WAITING_ENVIRONMENT",
|
||||
"WAITING_CAPACITY",
|
||||
"WAITING_AUTHORITY",
|
||||
"WAITING_AGENT",
|
||||
"WAITING_OWNER",
|
||||
"RECONCILING",
|
||||
"RECOVERING",
|
||||
"VERIFYING",
|
||||
"FINALIZING",
|
||||
"ACCEPTED",
|
||||
"FAILED",
|
||||
"ROLLED_BACK",
|
||||
"CLOSED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"activity": {
|
||||
"enum": [
|
||||
"EXECUTING",
|
||||
"WAIT_AUTHORITY",
|
||||
"WAIT_ENVIRONMENT",
|
||||
"WAIT_AGENT",
|
||||
"WAIT_REVIEW",
|
||||
"WAIT_OWNER",
|
||||
"RECOVERY",
|
||||
"QUEUED",
|
||||
"UNKNOWN"
|
||||
]
|
||||
},
|
||||
"owner_reason": {
|
||||
"enum": [
|
||||
"GOAL_CHANGE",
|
||||
"TRUST_BOUNDARY_CHANGE",
|
||||
"IRREVERSIBLE_OPERATION",
|
||||
"BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/release-contract.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseContract"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"DRAFT",
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"policy_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"catalog_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"authority_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"governed_commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repository_id",
|
||||
"authority_ref",
|
||||
"branch",
|
||||
"commit_oid",
|
||||
"tree_oid",
|
||||
"governed_commit_oid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"build": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"definition_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"output_kind": {
|
||||
"const": "runtime"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"definition_ref",
|
||||
"output_kind"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"artifact": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"runtime",
|
||||
"source_archive"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"host_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"service_unit": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_root": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"bind_address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"config_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"credential_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"owner_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"next_owner_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"health_probe_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"component_id",
|
||||
"host_ref",
|
||||
"service_unit",
|
||||
"runtime_root",
|
||||
"bind_address",
|
||||
"port",
|
||||
"config_ref",
|
||||
"credential_refs",
|
||||
"owner_ref",
|
||||
"next_owner_ref",
|
||||
"health_probe_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseline_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"artifact_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"restore_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"restore_evidence_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"data_compatibility": {
|
||||
"enum": [
|
||||
"VERIFIED",
|
||||
"UNKNOWN",
|
||||
"INCOMPATIBLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baseline_ref",
|
||||
"version",
|
||||
"artifact_sha256",
|
||||
"restore_operation_ref",
|
||||
"restore_evidence_ref",
|
||||
"data_compatibility"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"operations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resolve_contract": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"preflight": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"build_test": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"evidence_closure": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"candidate_freeze": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authorization_check": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_switch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"verify": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseline_registry": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"close": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"probe_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"required_receipt_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"runtime_identity",
|
||||
"business_acceptance",
|
||||
"authorization",
|
||||
"backup",
|
||||
"rollback_readiness",
|
||||
"tag",
|
||||
"baseline",
|
||||
"source_lineage"
|
||||
]
|
||||
},
|
||||
"minItems": 8,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"probe_refs",
|
||||
"required_receipt_kinds"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"test_report_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"review_report_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"closure_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content_key",
|
||||
"test_report_ref",
|
||||
"review_report_refs",
|
||||
"closure_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"workload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_modified": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"base_tree_oid",
|
||||
"files_added",
|
||||
"files_modified",
|
||||
"files_deleted",
|
||||
"lines_added",
|
||||
"lines_deleted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"state",
|
||||
"target_environment",
|
||||
"policy_ref",
|
||||
"catalog_ref",
|
||||
"source",
|
||||
"build",
|
||||
"artifact",
|
||||
"components",
|
||||
"rollback",
|
||||
"operations",
|
||||
"risk_budget",
|
||||
"acceptance",
|
||||
"evidence",
|
||||
"workload",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": [
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
"tree_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"const": "FROZEN"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"governed_commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "runtime"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"test_report_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"closure_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"properties": {
|
||||
"restore_evidence_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"data_compatibility": {
|
||||
"const": "VERIFIED"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"schema": "aios.component-build.v1",
|
||||
"components": [
|
||||
{
|
||||
"component_id": "formal-agent-task",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api_lineage_supersession.py",
|
||||
"active-runtime/app/aios_task_parameterized_recovery.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"active-runtime/migrations/005_task_lineage_supersession.sql",
|
||||
"active-runtime/migrations/006_task_lineage_supersession_initial_projection.sql"
|
||||
],
|
||||
"entrypoint": "aios_agent_task_protocol:TaskStore",
|
||||
"dependency_contract_version": "aios.agent-task.capability-contract.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "bridge",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_chatgpt_bridge_v2_server.py",
|
||||
"active-runtime/app/agent_context_api.py",
|
||||
"active-runtime/app/agent_observability_api.py",
|
||||
"active-runtime/app/agent_event_api.py",
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"active-runtime/app/aios_gitops_model_api.py",
|
||||
"active-runtime/app/aios_model_api_staging.py",
|
||||
"active-runtime/app/aios_control_plane_runtime_status.py",
|
||||
"active-runtime/app/governed_unattended_release.py",
|
||||
"active-runtime/app/silent_release_execution.py",
|
||||
"active-runtime/app/release_standing_authority.py",
|
||||
"active-runtime/app/release_host_authority.py",
|
||||
"active-runtime/app/release_campaign_authorization.py",
|
||||
"active-runtime/app/owner_authorization_broker.py",
|
||||
"active-runtime/app/task_center_api.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/systemd/aios-chatgpt-bridge-v2-staging.service",
|
||||
"adapters/gitops/aios_gitops_model_api.py",
|
||||
"contracts/release/v1/common.schema.json",
|
||||
"contracts/release/v1/release-contract.schema.json",
|
||||
"contracts/release/v1/authorization-bundle.schema.json",
|
||||
"contracts/release/v1/release-run-event.schema.json",
|
||||
"deploy/aios-v2/release-policy.v1.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/event_recovery.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/styles.css"
|
||||
],
|
||||
"entrypoint": "aios_chatgpt_bridge_v2_server:main",
|
||||
"dependency_contract_version": "aios.chatgpt-gitops-bridge.v2",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18892,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [
|
||||
"gitea-governance-executor-token"
|
||||
],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "task-plane",
|
||||
"roots": [
|
||||
"active-runtime/app/task_plane/api.py",
|
||||
"active-runtime/app/task_plane/c6_composition.py",
|
||||
"active-runtime/app/task_plane/host_patch_executor.py",
|
||||
"active-runtime/app/task_plane/host_receipt_readers.py",
|
||||
"active-runtime/app/task_plane/authorization_composition.py",
|
||||
"active-runtime/app/task_plane/patch_contract.py",
|
||||
"active-runtime/app/task_plane/evidence_verifier.py",
|
||||
"active-runtime/app/task_plane/decision_policy.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"scripts/autonomous-release/c6_host_lab.py",
|
||||
"deploy/aios-v2/c6-host-lab.requirements.lock",
|
||||
"deploy/aios-v2/c6-host-lab.config.json",
|
||||
"deploy/aios-v2/c6-host-lab.service"
|
||||
],
|
||||
"entrypoint": "app.task_plane.api:run_server",
|
||||
"dependency_contract_version": "aios.task-plane.v3",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false,
|
||||
"aro": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "health",
|
||||
"roots": [
|
||||
"active-runtime/app/v2r2c2_health_service.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
||||
],
|
||||
"entrypoint": "v2r1c6_privacy_tools:main",
|
||||
"dependency_contract_version": "aios.component-manifest.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18928,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"runtime_verification": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.c6-host-lab-config.v1",
|
||||
"activation": "DIAGNOSTIC_ONLY",
|
||||
"deployable": false,
|
||||
"production_deployed": false,
|
||||
"service_type": "simple",
|
||||
"network_listeners": true,
|
||||
"listen_mode": "DYNAMIC_LOOPBACK_PORT",
|
||||
"allowed_lab_root_regex": "^/var/tmp/aios-v2r2c6-lab-[0-9]{8}([-A-Za-z0-9_.]+)?$",
|
||||
"required_safety_properties": [
|
||||
"NoNewPrivileges",
|
||||
"ProtectSystem",
|
||||
"ProtectHome",
|
||||
"ReadOnlyPaths",
|
||||
"ReadWritePaths"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Linux x86_64 CPython 3.12 isolated C6 validation environment; no production readiness claim.
|
||||
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
|
||||
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||
anyio==4.15.1 --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101
|
||||
attrs==26.1.0 --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
|
||||
cffi==2.1.1 --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf
|
||||
click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360
|
||||
cryptography==50.0.1 --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a
|
||||
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
|
||||
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
|
||||
iniconfig==2.3.0 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
jsonschema==4.26.0 --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
||||
jsonschema-specifications==2025.9.1 --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
pycparser==3.0 --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
pydantic==2.13.5 --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73
|
||||
pydantic_core==2.46.5 --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2
|
||||
Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
|
||||
pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
|
||||
referencing==0.37.0 --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231
|
||||
rpds-py==2026.6.3 --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6
|
||||
starlette==1.7.0 --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e
|
||||
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
|
||||
typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
uvicorn==0.54.0 --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=AIOS C6 isolated provisional artifact diagnostic
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Render this reference with scripts/autonomous-release/c6_host_lab.py. The
|
||||
# generated unit contains the pinned artifact, receipt, runner and contract paths.
|
||||
NoNewPrivileges=true
|
||||
# The authorized isolated root is under /var/tmp, so systemd PrivateTmp would
|
||||
# hide the only permitted runtime path from this service.
|
||||
PrivateTmp=false
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.1/32
|
||||
UMask=0077
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"kind": "ReleasePolicy",
|
||||
"policy_id": "AIOS-RELEASE-POLICY-V1",
|
||||
"installation_state": "NOT_INSTALLED",
|
||||
"entrypoint": "release promote VERSION",
|
||||
"read_only_prep_alias": "release prep VERSION",
|
||||
"stage_order": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
],
|
||||
"root_identity": {
|
||||
"reuse_existing_mission": true,
|
||||
"reuse_existing_release_run": true,
|
||||
"recovery_creates_attempt_not_new_goal": true
|
||||
},
|
||||
"human_policy": {
|
||||
"additional_interactions_target": 0,
|
||||
"owner_required_reasons": [
|
||||
"GOAL_CHANGE",
|
||||
"TRUST_BOUNDARY_CHANGE",
|
||||
"IRREVERSIBLE_OPERATION",
|
||||
"BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
],
|
||||
"signer_unavailable_state": "WAITING_AUTHORITY",
|
||||
"reviewer_unavailable_state": "WAITING_CAPACITY",
|
||||
"initial_request_target": 1,
|
||||
"selection_submission_target": 0,
|
||||
"receipt_or_notification_is_human_interaction": false
|
||||
},
|
||||
"timing_targets_seconds": {
|
||||
"preflight": 300,
|
||||
"ready_candidate_to_acceptance_ordinary": 3600,
|
||||
"ready_candidate_to_acceptance_hotfix": 900
|
||||
},
|
||||
"timing_measurement": {
|
||||
"always_report_request_to_acceptance": true,
|
||||
"hide_preparation_time": false,
|
||||
"exclude_failures_from_reporting": false,
|
||||
"initial_calibration_runs": 5,
|
||||
"calibration_does_not_establish_p95": true
|
||||
},
|
||||
"retry": {
|
||||
"fast_attempts": 3,
|
||||
"backoff_seconds": [
|
||||
5,
|
||||
15
|
||||
],
|
||||
"maximum_wait_interval_seconds": 300,
|
||||
"unknown_effect_requires_reconcile": true,
|
||||
"no_identical_blocker_without_new_fact": true,
|
||||
"fast_attempts_includes_initial": true,
|
||||
"dependency_probe_initial_seconds": 60,
|
||||
"exhaustion_action": "ROUTE_TO_ENGINEERING_PRODUCER_WITHOUT_NEW_OWNER_PROMPT"
|
||||
},
|
||||
"infrastructure": {
|
||||
"reuse_existing_signer": true,
|
||||
"reuse_existing_outbox": true,
|
||||
"new_mandatory_daemons": [],
|
||||
"new_mandatory_databases": [],
|
||||
"new_mandatory_message_brokers": []
|
||||
},
|
||||
"runtime_budget_source": "existing_authoritative_environment_policy",
|
||||
"missing_budget_behavior": "BLOCKED_CONFIGURATION_GAP",
|
||||
"trial_mode": "SHADOW_THEN_STAGING_THEN_SMALL_REVERSIBLE_PRODUCTION",
|
||||
"live_fixture_policy": "DENY_FIXTURE_AND_UNRESOLVED_REFERENCES",
|
||||
"rollback": {
|
||||
"reconcile_before_retry": true,
|
||||
"retain_previous_artifact": true,
|
||||
"bind_current_accepted_baseline": true,
|
||||
"close_only_after_compensation_or_failure_receipt": true
|
||||
},
|
||||
"revision": "1.1.0",
|
||||
"stage_timing_targets_seconds": {
|
||||
"request_to_start": 30,
|
||||
"preflight_aspirational": 120,
|
||||
"authorization_check": 10,
|
||||
"runtime_switch": 300,
|
||||
"verify": 300
|
||||
},
|
||||
"stage_target_status": "PROPOSED_NOT_MEASURED_DO_NOT_SHORTEN_REQUIRED_OBSERVATION",
|
||||
"failure_routing": {
|
||||
"F0": "EXISTING_RETRY_RENEW_RECONCILE",
|
||||
"F1": "ENGINEERING_OR_AUTHORITY_PRODUCER",
|
||||
"F2": "VERIFIED_OWNER_BOUNDARY_ONLY",
|
||||
"unmapped": "F1_STOP_RELATED_EFFECTS_AND_CLASSIFY",
|
||||
"mixed_boundary_precedence": true
|
||||
},
|
||||
"continuation": {
|
||||
"reuse_existing_checkpoint_and_saga": true,
|
||||
"checkpoint_is_authority": false,
|
||||
"revalidate_authority_before_effect": true,
|
||||
"preserve_root_clock": true,
|
||||
"unknown_effect_before_retry": "RECONCILE",
|
||||
"goal_valid_until": [
|
||||
"RELEASE_CLOSED",
|
||||
"REVOKED",
|
||||
"CANCELLED"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# C5 控制面闭合报告
|
||||
|
||||
状态:本地工程闭合候选已形成;live control plane 尚未闭合。本文是交付准备记录,不是生产发布、activation、Task admission 或 RC 成功证明。
|
||||
|
||||
## 已闭合的工程边界
|
||||
|
||||
架构决定和实施计划将 unattended runner 定义为编排客户端。它不拥有 Owner authority、epoch issuer、Task DB、Git writer、approval verifier 或 production executor。现有组件仍是唯一权威:`TaskAdmissionBootstrapService`/`AgentTaskAdmissionPort` 负责 envelope、grant 和 Task admission;既有 lease/principal/writer chain 负责写入;trusted builder 和 `SilentReleaseActionService` 负责 candidate/provider evidence;`ReleaseSagaStore`、`ReleaseOrchestrator` 和 `ReleaseRecoveryWorker` 负责部署、回滚与未知副作用恢复。旧的 `release_state_machine.py` 不应再作为第二状态权威。
|
||||
|
||||
本地 runner `active-runtime/app/governed_unattended_release.py` 已提供以下受限组合接口:
|
||||
|
||||
```text
|
||||
GovernedUnattendedReleaseRunner.intake(intent)
|
||||
GovernedUnattendedReleaseRunner.admit(intent, authorization)
|
||||
GovernedUnattendedReleaseRunner.publish(intent, admission_receipt)
|
||||
GovernedUnattendedReleaseRunner.postpublication_rc(event_id, source_commit, version, source_readback)
|
||||
GovernedUnattendedReleaseRunner.deploy(candidate_id)
|
||||
```
|
||||
|
||||
`SourcePublicationIntent` 对 repository、source/tree/base OID、精确 target branch、generation、execution digest、file allowlist、capability set 和 risk 做不可变规范化,并派生稳定 `task_id` 与 idempotency key。`admit` 在 envelope 已创建后遇到超时、连接断开或 5xx 时先 reconcile,不把未知结果当作未应用,也不重复提交;禁止错误不会重试。`publish` 要求同一 Task 的 `task_lease`、`task_principal`、`writer_lease`、authenticated commit receipt、base/branch/generation/execution digest/source/tree 全部绑定。缺一即阻断。
|
||||
|
||||
本地模拟器 `scripts/simulate_c5_unattended_release.py` 从 Git object 读取 source/base/tree/diff/archive,运行候选 C5 contract tests,使用临时 Owner key 和真实 admission/bootstrap/TaskStore 实现完成隔离签名与 admission,再写入现有模型的 candidate/manifest/RC JSON。输出明确标记 `SIMULATION`、`official_release=false`、`authority_source_commit=null`、`acceptance=PENDING_AUTHENTICATED_PRODUCTION_PROVIDER_EVIDENCE`、`approval=NOT_REQUESTED`、`deployment=NOT_ATTEMPTED`。这条链路可证明结构、签名验证、隔离 TaskStore、幂等和非权威 artifact 写出;不能证明 live authority、live publication、trusted production provider、release approval、runtime health 或 deployment。
|
||||
|
||||
`governed_release_evidence_producer.py` 的 `produce_evidence_closure(...)` 可生成现有 RC evidence closure,但要求 tests、canary、Gitea、rollback、secret-scan 每一类均有 trusted-provider receipt,并校验 artifact/manifest/package closure/source/tree。相同 closure 幂等,内容变化为冲突。该闭合器不得被调用来伪造 provider pass。
|
||||
|
||||
现有 release service 的正式接口仍应按以下顺序复用:
|
||||
|
||||
```text
|
||||
SilentReleaseActionService.create_release_intent(payload)
|
||||
-> get_release_candidate(candidate_id)
|
||||
-> get_release_plan(candidate_id)
|
||||
-> get_acceptance(candidate_id)
|
||||
-> verify_release_acceptance(candidate_id)
|
||||
-> get_approval_envelope(candidate_id)
|
||||
-> submit_owner_approval(signed_payload)
|
||||
-> start_deployment(candidate_id)
|
||||
```
|
||||
|
||||
`create_release_intent` 只能接收 event/source/version,并从 authenticated source admission resolver 得到版本与 admission ID;不能接收 caller evidence。`submit_owner_approval` 必须经既有 signed release-plan verifier 和 durable approval store。`start_deployment` 会重新加载 durable approval,调用既有 saga/orchestrator;caller 传入的 approval 不作为授权来源。
|
||||
|
||||
## 当前 live 未闭合
|
||||
|
||||
以下条件仍未证实,不能写成 PASS 或 COMPLETE:
|
||||
|
||||
- `authority_source_commit` 仍为 `null`;没有正式 live source publication receipt、authority Git readback、TaskLease/TaskPrincipal/WriterLease/commit closure。
|
||||
- 没有 live C5 exact source-only policy 的 Owner-signed activation、policy digest、issuer、revocation source 和 fresh epoch。不能用本地 policy/request hash、旧 activation binding、simulation epoch 或借用 credential 替代。
|
||||
- 没有 live registered machine caller 的可用 admission route;当前 runner 不能自行成为 authority issuer 或把 `engineering` 风险提升为全局允许值。
|
||||
- 没有真实 trusted builder/provider resolver 的完整绑定,尤其是 authenticated tests、canary、Gitea、rollback、secret-scan receipts;本地 candidate artifact 不是生产 evidence closure。
|
||||
- 没有 pre-authorized Owner approval broker 为精确 candidate/release plan 签名。source-only authorization 不等于 release approval。
|
||||
- 没有完成 live `SilentReleaseActionService` 的 candidate、acceptance、release plan、saga readback,也没有 deployment composition、runtime health、rollback 和 post-tag/finalization readback。
|
||||
- C6 已冻结;C5 自动化支持若进入后续发布,必须重新计算 source/tree/file binding、execution plan 和 signed authority,不能把支持代码自动并入既有 candidate。
|
||||
|
||||
## 最终本地验证
|
||||
|
||||
2026-09-26,在 Linux/Python 3.12.3 的隔离目录中完成 **184 passed** 定向回归(5.53 秒)。范围包含 epoch v1/v2、source policy、runner、真实 release service/Owner signature 集成、既有 admission/bootstrap/recovery、production contract 和 provider evidence producer。没有重跑历史全量业务测试,也没有把旧的 598 passed 计为本次新增代码的结果。
|
||||
|
||||
新增集成覆盖实际 signed builder、signed Owner approval、source substitution 拒绝、无 verifier 拒绝及 durable attestation 篡改拒绝。公开 acceptance 接口包含脱敏字段;`verify_release_acceptance` 在现有 service 内加载原始 durable 数据重新验签,保持公开脱敏规则,向 runner 返回验证摘要。
|
||||
|
||||
最终 `demo-run-4` 从不可变候选 `536a2af0f07962495c88526061ce43fb27994c15` 的 archive 执行 C5 capability N01/N02/N03,经过真实 SSHSIG 验签、真实隔离 Task admission、幂等重放和 scope/revocation 负例,成功写出 `V2R2C5_RC.simulation.json`。该 candidate 保持 `BUILDING`;正式 source OID 为空,provider acceptance 待补、approval 未请求、deployment 未执行。
|
||||
|
||||
可复核证据位于集成工作树相邻的 `../evidence/final-targeted-tests.xml`、`../evidence/demo-run-4/V2R2C5_RC.simulation.json` 和 `../evidence/demo-run-4/SHA256_MANIFEST.json`。这些结果证明隔离工程闭环,不证明 live 发布闭环。
|
||||
|
||||
## 可恢复下一步
|
||||
|
||||
1. 在现有 governed Task → TaskLease → TaskPrincipal → WriterLease → Governed Commit 链上发布 runner/epoch compatibility/exact policy guard 支持,并 read back commit/tree/receipt。
|
||||
2. 由已有 Owner authority 通过控制面激活精确 C5 source-only policy,记录 signed activation、policy digest、issuer、expiry、revocation source;随后请求 fresh epoch。禁止直接写受信任目录或复用旧 epoch。
|
||||
3. 用真实 registered caller 执行 admission;对 envelope、admission receipt、Task 状态和 authority OID 做 readback。任何 unknown effect 先 reconcile。
|
||||
4. 仅在 publication receipt、lease chain、commit receipt 和 authority readback 完整后,调用 `create_release_intent`,再由真实 provider 生成并验证 candidate、acceptance、plan、saga 和 RC evidence。
|
||||
5. 由预授权 Owner approval broker 对精确 release plan 签名,经 `submit_owner_approval` 验证并 durable 存储。缺 broker 时保持阻断,不降低 risk、不 mint client signature。
|
||||
6. 若另有明确生产授权,再调用 `start_deployment`;依赖既有 pre-switch/canary、health、rollback、tag/finalization reconciliation。否则将结果保留在已签名、可重放的 RC/approval-ready evidence,而不触碰生产。
|
||||
|
||||
## 运行时假设
|
||||
|
||||
真实组合依赖 Linux/POSIX `fcntl` locks、`fsync`/atomic rename、受限 symlink/open、Git authoritative bare repo、Gitea/ref readback、systemd service identity/restart、health endpoint、Ed25519 verification,以及 KMS/HSM/secret-manager reference。`execution_enabled=false`/shadow mode 是默认安全状态;生产执行缺少 approval verifier、trusted builder、builder attestation verifier、orchestrator、pre-switch validator 或 trusted policy 时必须 fail closed。
|
||||
@@ -0,0 +1,52 @@
|
||||
# C5 release automation implementation and rollout
|
||||
|
||||
The engineering task implements a repeatable orchestration path and isolated executable proof. It does not treat local artifacts as an activated production capability. V2R2C6 is frozen.
|
||||
|
||||
## Parallel implementation ownership
|
||||
|
||||
1. Epoch compatibility: admission bootstrap and signed-epoch provider; v1 regression, original-byte v2 verification, tamper/schema/revocation tests.
|
||||
2. Exact policy lifecycle: existing structured Owner issuer plus an exact source-publication guard; TTL, current trust, revocation, scope and configured-risk checks.
|
||||
3. Thin runner: generated immutable intake, admission/reconciliation, publication proof gate, existing trusted candidate service and signed approval/deployment entry points.
|
||||
4. Integration: isolated executable demo, targeted contract tests, architecture/gap reports and immutable local commit/archive evidence.
|
||||
|
||||
Each implementation lane uses a separate Git branch/worktree. Integration uses ordinary local commits without force-push, main mutation, TaskDB mutation or production writes.
|
||||
|
||||
## Minimum executable closure
|
||||
|
||||
Run the simulation CLI with a Git repository, committed source/base and a new output directory. It must derive the file list, tree and source archive from Git objects; run the selected checks; issue a fresh test-only Owner policy/epoch using ephemeral keys; verify them with the actual registry verifier and dual-schema provider; submit an envelope through the actual admission service against an isolated Task transport; and write an existing-model RC artifact with all evidence hashes.
|
||||
|
||||
The output must say `SIMULATION`, `official_release=false`, and retain `authority_source_commit=null`. It must not invent live provider receipts or set `READY_FOR_APPROVAL` without the existing acceptance path. Ephemeral private keys are confined to a temporary directory and excluded from exported evidence. A signed test receipt authenticates only its stated isolated environment.
|
||||
|
||||
From this implementation checkout on Linux/POSIX, with Git, OpenSSH, pytest and cryptography available:
|
||||
|
||||
```bash
|
||||
python3 scripts/simulate_c5_unattended_release.py \
|
||||
--repository /path/to/local/candidate/repository \
|
||||
--source 536a2af0f07962495c88526061ce43fb27994c15 \
|
||||
--base e5f8209f57b43fc39e55c4555776e6362ce326eb \
|
||||
--output /path/to/new/isolated/evidence-directory
|
||||
```
|
||||
|
||||
The command creates its own isolated Task store and ephemeral signing domain. It has no live transport or deployment adapter. No manually assembled JSON, existing Owner credential, service restart or production endpoint is needed.
|
||||
|
||||
## Required checks
|
||||
|
||||
- v1/v2 consumer compatibility, original bytes, issuer/key/namespace/digest, unknown schema, invalid fields, expiry and revocation.
|
||||
- Existing medium policy still denies engineering; exact source policy denies any changed branch/base/source/tree/files/caps/execution binding.
|
||||
- Missing verifier and missing/failing revocation lookup deny; admission replay rechecks authorization.
|
||||
- Stable intake identity, bounded retry, no retry for policy rejection, unknown side-effect reconciliation and conflicting receipt rejection.
|
||||
- Missing TaskLease/principal/WriterLease/commit/readback cannot produce a live RC.
|
||||
- Existing signed approval, provider evidence, saga integrity, deployment verification, rollback and post-tag reconciliation contracts.
|
||||
- C5 capability negatives N01/N02/N03 and affected regression tests only; historical full-suite counts are not reused as evidence for new changes.
|
||||
|
||||
## Governed rollout after local verification
|
||||
|
||||
1. Publish the support commit through the existing Task -> TaskLease -> TaskPrincipal -> WriterLease -> Governed Commit chain. Read back its OID/tree/receipt.
|
||||
2. Governed-install the dual-schema consumer and exact source-only policy guard on the existing control-plane service, preserving the old artifact for rollback.
|
||||
3. Activate the exact policy and dedicated machine caller using the existing Owner authority. Record signed activation, policy digest, issuer and revocation source. This is a provisioning step, not an implicit authority granted by the runner.
|
||||
4. Request a fresh epoch and execute C5 admission/publication. Rebind evidence to the authority-readback OID; it may differ from the local candidate.
|
||||
5. Run actual build/test/security/canary/rollback providers and their authenticated resolver. Invoke `create_release_intent` against the authority source; read back candidate, acceptance, plan and saga.
|
||||
6. Have the pre-authorized Owner broker sign the exact release plan, and submit it through `submit_owner_approval`. Source-only authorization is insufficient here.
|
||||
7. Call the existing deployment controller with approved durable state. Canary/pre-switch, health, promotion, registry/baseline finalization and rollback follow the existing saga. Verify runtime identity and final consistency.
|
||||
|
||||
Missing live authority or provider composition is a durable blocker, never a reason to lower risk, bypass G0, mint signatures in the client or call production restart directly.
|
||||
@@ -0,0 +1,60 @@
|
||||
# C5 governed unattended release: architecture decision
|
||||
|
||||
Status: selected for isolated implementation; live activation is not implied.
|
||||
Task: TASK-AIOS-C5-GOVERNED-UNATTENDED-RELEASE-PLATFORM-DESIGN-AND-IMPLEMENTATION-20260926-R1
|
||||
|
||||
## Decision
|
||||
|
||||
Select **B, additive v1/v2 epoch consumption**. Verify the producer's original canonical signed bytes and retain the actual schema, issuer, key, policy digest and revocation semantics. Project common typed fields only after verification. A v2 payload is never relabelled v1 and its signature is never copied onto another payload.
|
||||
|
||||
The producer's `policy_digest` hashes the Owner policy. It is not interchangeable with the default Task Admission policy digest. The existing policy factory propagates this digest; the compatibility change preserves that binding. A v2 `canonical_payload_digest` covers the payload before that field is added, while the signature authenticates the complete original canonical payload.
|
||||
|
||||
Option A would either be the same dual-schema verification hidden behind a misleading v1 label, or require a new signature. Option C would change more producers and consumers without resolving the immediate authority/composition gap. B preserves v1 operation and has the smallest rollback surface.
|
||||
|
||||
## One control plane
|
||||
|
||||
The unattended runner is an orchestration client. It does not become an authority issuer, Task database writer, Git writer, approval verifier, release state machine, or production executor.
|
||||
|
||||
| Responsibility | Existing authority/component |
|
||||
|---|---|
|
||||
| Owner signatures and narrowing | `LocalOwnerAuthority`, registered Owner key and configured Owner policy |
|
||||
| Epoch verification | `SignedEpochDirectoryProvider`, `RegistryOwnerApprovalVerifier` |
|
||||
| Exact source-only policy | Structured Owner-signed policy, verified request guard |
|
||||
| Envelope, grant and Task admission | `TaskAdmissionBootstrapService`, `AgentTaskAdmissionPort` |
|
||||
| Claim, principal and WriterLease | Existing Task/lease/governed writer chain |
|
||||
| Source commit proof | Governed commit receipt plus authority Git readback |
|
||||
| Build, provider evidence and candidate | Trusted builder, provider resolver, `SilentReleaseActionService` |
|
||||
| Approval | Existing signed release-plan approval verifier and durable approval store |
|
||||
| Deployment and recovery | `ReleaseSagaStore`, `ReleaseOrchestrator`, `ReleaseRecoveryWorker` |
|
||||
|
||||
The older linear `release_state_machine.py` is not added as another release state authority. The runner consumes durable state from the existing stores. Recovery of an unknown mutation reconciles that state before another write.
|
||||
|
||||
## Least privilege and unattended authority
|
||||
|
||||
The source-only policy binds repository, source commit/tree, base commit, exact target branch, generation, execution digest, file set, capability set, engineering risk, expiry and fresh identity. It cannot authorize main writes, tags, deployment, baseline mutation or historical Task mutation. The global admission ceiling stays `medium`; an exact verified request may use its separately signed engineering policy. A different request must be denied before envelope creation.
|
||||
|
||||
Recognizing the `engineering` rank in the issuer does not raise any configured ceiling. An existing `medium` Owner policy still rejects engineering issuance. The structured policy issuer must narrow its configured Owner policy; it cannot install or activate a policy for itself.
|
||||
|
||||
Each authorization rechecks signature, current key trust, expiry, revocation and scope. No automatic extension of expired epochs is allowed. A controller requests a fresh epoch under an already activated Owner policy. Source-only authority cannot be exchanged for release approval. Automatic release approval requires a separately delegated Owner broker to sign the exact candidate and release plan; missing or revoked delegation stops the workflow.
|
||||
|
||||
“No interaction” is an execution property after governed provisioning. It is not permission to create a trust root or grant the controller new powers. Current production has not been provisioned for this new composition.
|
||||
|
||||
## Retry and recovery
|
||||
|
||||
Task and event identities derive from immutable input. Retries retain them. Policy rejection is terminal until policy/input changes. A timeout is not proof that a write failed: use the admission receipt/official Task readback, publication receipt and authority OID, or existing release saga/operation journal to reconcile. Unknown or conflicting effects remain blocked. Transport calls require bounded timeouts; a controller deadline alone cannot cancel a remote mutation.
|
||||
|
||||
Build and acceptance evidence are obtained through trusted providers, not caller-supplied pass flags. A local simulation artifact is explicitly marked non-authoritative. Its source OID, archive hash and cryptographic checks can be real without proving live publication, runtime acceptance or deployment.
|
||||
|
||||
## Risks and rollout
|
||||
|
||||
1. Schema acceptance alone does not activate a usable policy. Roll out the consumer and exact policy guard together through governed control-plane publication.
|
||||
2. The existing deployed Owner policy does not authorize the C5 scope, and the current caller has no usable authenticated activation/epoch route. Provision the narrowly scoped service identity and policy through the existing governance plane. Do not use direct root file writes as a substitute.
|
||||
3. Signed source policy and epoch must agree on the Owner policy digest. Mixing epochs, digests or request identities fails closed.
|
||||
4. The local source candidate `536a2af0f07962495c88526061ce43fb27994c15` is preserved. Automation support changes are separate commits. Any decision to include them in the release must regenerate the governed execution plan and exact file/source bindings.
|
||||
5. The existing deployment engine contains canary/pre-switch, runtime verification, rollback and post-tag reconciliation logic. Tests establish adapter contracts; they do not prove current production composition or health.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable the new runner, revoke its source-only policy/epoch and stop new admissions. Preserve journals, receipts, immutable Tasks and candidate records. Revert the consumer/support module deployment to the prior governed control-plane artifact; v1 remains supported throughout migration. Never rewrite already issued signed payloads or historical Task state.
|
||||
|
||||
Production deployment rollback remains the existing signed plan's rollback operation and release saga. A post-tag/finalization uncertainty requires reconciliation; it must not be hidden by an automatic tag rewrite. C4 stays the production baseline until a separately approved C5 deployment completes runtime verification.
|
||||
@@ -0,0 +1,57 @@
|
||||
# C6 主机接入设计与交接边界
|
||||
|
||||
Agent-B 在 `e87c70778aa0911436cac5b59f1aa11080c5ba77` 集成基线上补主机适配。C5 生产冻结与发布由 Agent-A 负责;本补丁不反向合入 C5。持续业务授权和短期执行凭证分离,原 Saga、Mission/Run、动作身份和验签合同保留。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[版本级业务授权与实时撤销状态] --> B[签名 current-record reader]
|
||||
S[Task / scope / lineage 权威记录] --> B
|
||||
B --> C[既有 StandingReleaseAuthorizationBroker]
|
||||
K[目的受限的已登记 release signer] --> C
|
||||
C --> D[短期精确执行凭证]
|
||||
D --> E[既有 Controller / Saga]
|
||||
E --> F[既有 TaskPlane 自动领取与租约]
|
||||
F --> G[精确 RootAction 持久 intent / effect]
|
||||
G --> H[正式 Bridge / WriterLease / 外部 CAS fence]
|
||||
H --> I[真实效果读回]
|
||||
V[独立 verifier 签名回执] --> J[既有三层 closure reducer]
|
||||
P[独立业务 acceptor 签名回执] --> J
|
||||
I --> J
|
||||
J --> A
|
||||
```
|
||||
|
||||
## 信任和执行接口
|
||||
|
||||
1. `release_host_authority.VerifiedCurrentRecordReader` 每次生成随机 challenge,校验响应目的、查询摘要、父授权摘要、时间窗口、已登记密钥与原始 Ed25519 签名。主机配置固定 purpose→key;响应不能带入新信任根。权威端必须每次读真实当前状态后签 challenge,不能给旧 ACTIVE snapshot 重新加时间。
|
||||
2. `HostReleaseAuthorityReaders` 将验签后的精确记录转换为既有 StandingReleaseAuthorization / ReleaseExecutionScope。执行范围绑定同轮父授权记录。broker 在签发前后和 effect 前重读,不用旧签名掩盖撤销、完成、关闭、根或范围变更。
|
||||
3. `RegisteredReleaseSignerCommand` 只向已登记 release approval signer 提交既有 `SignedReleasePlanApproval.canonical_bytes()`;校验 key、算法、消息摘要和 raw Ed25519 签名后返回原合同。SSHSIG armor 不是 raw Ed25519 签名,禁止解码冒充。发布 broker 不代替 TaskPrincipal、WriterLease、promotion/source authority。
|
||||
4. `task_plane.host_patch_executor` 连接现有 C6 pilot seam 与外部持久事务所有者。解析真实 ExecutablePatchV1;claim 不是 WriterLease。实际提交必须在外部事务线性化点检查当前授权代次、租约/fence 和 expected OID CAS。内存 callback、本地进程锁或测试事务不能证明生产跨主机原子性。
|
||||
5. `HostOperationReceiptReaders` 读取独立 verifier 与 business acceptor 的签名挑战响应。主机固定角色身份、不同 key 与 acceptance revision;执行者不能验证/验收自己。输入不得携带 `authenticated` 字段,只有验签和角色绑定成功才在内部观测中派生该值。原 closure reducer 再核对 effect receipt、结果摘要、verification receipt、时效和 acceptance revision。
|
||||
|
||||
RootAction 的 host binding 固定授权引用、授权代次、公钥指纹、Task/Job/claim/fence、补丁、计划与实际提交内容。短期观测的 challenge 和有效窗口单独验证,不成为新动作身份。普通旧式 `run_once` 不得领取这些已绑定 C6 动作;C6 的恢复先枚举既有账本未闭合 intent,再做领取发现,避免任务退出候选列表后遗留未知效果。恢复入口仅允许精确对账,不能重新 acquire/commit。
|
||||
|
||||
动作提交成功与三层验收闭合分别记录:尚缺独立验证或业务验收的已提交动作仍属于重启后的待闭合集合。闭合记录必须从受信回读重新核算精确 receipt 与摘要链,并在原 effect 行保存验收证据摘要;调用方自报成功不能移除待闭合动作。该记录用于恢复调度,不替代原始独立服务回执。
|
||||
|
||||
## 验收回读协议
|
||||
|
||||
`compose_host_release_controller` 提供实际组合入口,分别注入 current-record 与 approval 的目的受限 keyring,并配置 key_id→raw 公钥 SHA256;不同别名不能绕过目的隔离。组合冷启动不读取短期服务,实际签发/执行时才读并核验。内置 `BoundedCommandTransport` 只支持 POSIX 主机,以进程组、总时间和总输出上限管理提供方及子进程;Windows 在启动子进程前拒绝此 transport。字节协议 reader、签名适配及控制器仍可在 Windows 做隔离验证。
|
||||
|
||||
purpose 分别为 `operation-verification`、`operation-acceptance`。query 包含完整 OperationBinding、binding_digest、expected_identity、expected_key_id、expected_key_fingerprint;acceptance 另含 expected_acceptance_revision。响应使用 current-record v1 envelope;record 是原 operation-verification/acceptance-observation v1 的 schema 与对应 observation,省略 authenticated。acceptance 的 expected 身份和 revision 来自主机配置,不能由外部 receipt 自报。两角色的已登记身份、key_id 和 raw Ed25519 公钥 SHA256 均须不同,不能用同一公钥的不同别名绕过独立性检查;keyring 轮换必须显式更新对应主机策略,不能静默换钥。
|
||||
|
||||
公开业务检查、部署/恢复检查由实际独立服务生成。适配器不生成成功回执、不写平台 registry,也不把聊天角色当正式 worker 身份。缺服务、密钥、授权或当前记录时停止相关 effect,仍可继续诊断与其他独立工程。
|
||||
|
||||
## 安装和证据
|
||||
|
||||
`c6_host_lab.py` 验证 immutable artifact、sidecar、组件配置与 unit 字节,拒绝危险归档路径、链接、占位符及摘要漂移。实验服务使用独立目录、非 root UID 和 loopback 端点;必须分别记录进程启动/重启、真实组件加载和权限拒绝,不能把静态 systemd verify 或自定义 health JSON 当生产验收。
|
||||
|
||||
隔离测试使用临时密钥、仓库和事务,证据仅证明实现行为。正式主机仍需已登记 authority/scope/signer、patch worker 与 transaction owner、独立 verifier/acceptor。最终版本必须重新绑定 Agent-A 真实验收的 C5 source/tree/artifact/config/rollback。所有 provisional 制品维持 deployable=false;签名、candidate 或 source 变更必须重建摘要,不能复用旧批准。
|
||||
|
||||
存储迁移只在既有 root-action effect 账本增加可空字段和唯一索引,保留旧状态与未知副作用语义;旧 DB 重开回归独立验证。生产恢复到 C5 还必须关闭 C6 执行调度并使旧消费者不能领取遗留 C6 动作,未知效果保持隔离对账。不能仅切旧代码后继续消费新账本,也不能删除新增列、回灌旧 DB 或丢弃新任务来伪造恢复成功;这属于最终 C5 恢复合同尚需实测的门槛。
|
||||
|
||||
当前 Public Bridge 的 WriterLease 返回合同若没有真实 lease ID 和 fence,精确执行入口拒绝提交;不能由客户端自行生成这些字段。外部授权代次、租约与 OID 的原子校验仍必须由正式写入端兑现。崩溃后的纯对账路径不会因为本地记录为 LEASE_ACQUIRED 就自动重发提交;恢复调用方和正式写入端必须证明可安全续行后才能接通对应恢复能力。
|
||||
|
||||
`deploy/aios-v2/c6-host-lab.requirements.lock` 固定 Linux x86_64 / CPython 3.12 的 27 个 wheel 版本和 SHA256。这是隔离验证环境锁,不声明其他平台兼容。交接 wheelhouse 可用 `python -m pip install --no-index --find-links wheelhouse --require-hashes -r c6-host-lab.requirements.lock` 离线安装到新 venv;本次已实际重建并通过 `pip check` 和 cryptography/FastAPI/Uvicorn/pytest/jsonschema/yaml 导入。不要安装到系统 Python 或修改生产环境。
|
||||
|
||||
## 给 Agent-A 的串行配置边界
|
||||
|
||||
交接包给出固定源码、配置模板、协议、测试和摘要后,由 A 协调正式共享变更窗口。需要核对现有服务身份和 exact signer 协议,注册/配置只能走已有治理能力;不让 Owner 手工拼接签名。B 在正式写权交接前只操作自己的隔离 lab。未观测到可用服务时,缺口按 authority/signer/worker/fence/verifier 分项登记,不简写成“只等 C5”。
|
||||
@@ -0,0 +1,80 @@
|
||||
# V2R2C5 C5 mainline consolidation source record
|
||||
|
||||
## Scope and authority boundary
|
||||
|
||||
This maintenance candidate is reconciled onto current main
|
||||
`e33664f03302fd9de7635afdd8aa9339a50e5f0a` (tree
|
||||
`92ece1e9646ac2d12b4b467c82b1279ac1e11608`) and preserves its V2R2C6 source
|
||||
and security changes. The original reviewed selection was made against
|
||||
`b915386ccbed46a7ae77220887da8ae666649a79`. Current main already contains the
|
||||
product source commit; the remaining five reviewed source commits and isolated
|
||||
fixtures are carried by this candidate. This is a source and test consolidation
|
||||
only. It does not create a production baseline, update the runtime registry,
|
||||
activate a Task or Agent, publish a tag, or authorize a protected merge. The
|
||||
production source, tag, artifact, and existing baseline records remain immutable
|
||||
inputs to their validators.
|
||||
|
||||
| Layer | Commit | Current-main relationship |
|
||||
| --- | --- | --- |
|
||||
| installer / G1 | `7e14abeb6b91978ce16f3d903b6c0714af302991` | integrated by this candidate |
|
||||
| builder | `8fc61bd11213bf22cc795ba933a5f367f8831f82` | integrated by this candidate |
|
||||
| completion | `66530975f52a4d6f1449f18d23af808e0c9339bb` | integrated by this candidate |
|
||||
| deployment | `5443c93f00ce879e1025a53258557788da4dbcec` | integrated by this candidate |
|
||||
| boundary / provider | `74c0eb75348b752c82f45e53759edfff5a47e3ef` | integrated by this candidate |
|
||||
| product | `5f65e458957dd51c9973cfa956a624f3cbf54990` | already an ancestor of current main |
|
||||
|
||||
The reviewed selection manifest contained 41 paths and eight non-identical
|
||||
overlap records. No receipt, journal, live baseline, runtime registry record,
|
||||
or other generated production state was used as source.
|
||||
|
||||
## Per-file decisions
|
||||
|
||||
| Classification | Paths | Decision |
|
||||
| --- | --- | --- |
|
||||
| source | `active-runtime/app/aios_baseline/service.py`; `aios_capability_contract.py`; `aios_task_principal_v3_receipt_provider.py`; `approved_release_executor.py`; `c5_exact_deployment_adapter.py`; `c5_registered_installer_executor.py`; `c5_staging_installation.py`; `governed_publication_client.py`; `release_source_admission.py`; `task_plane/live_governed_writer_adapters.py` | Integrated G1/product behavior and fail-closed validators. |
|
||||
| builder source | `active-runtime/app/aios_trusted_candidate_builder.py` | Kept the later builder contract and integration validation. Replaced historical live policy identity constants with values read from the governed policy. |
|
||||
| completion source | `active-runtime/app/c5_completion_api.py`; `c5_completion_phase.py`; `c5_completion_renewal.py`; `c5_completion_signer.py`; `silent_release_hardening.py` | Retained completion-only successor validation. Authority root, state root, Owner key, predecessor fingerprint, and historical Saga head now require a root-owned digest-bound bindings file. |
|
||||
| shared completion/boundary source | `active-runtime/app/c5_release_execution_host.py` | Retained the completion host and added the boundary integration-policy checks. Source-admission Task identity is now supplied by the digest-bound host configuration. |
|
||||
| deployment source | `active-runtime/app/c5_production_deployment_adapter.py` | Applied the later strict symlink-resolution check to the integrated installer version. |
|
||||
| provider source | `active-runtime/app/trusted_evidence_providers.py` | Retained artifact-to-extracted-tree binding and structured gitleaks JSON handling. |
|
||||
| helper | `scripts/build_c5_live_principal_overlay.py`; `scripts/build_c5_staging_artifact.py`; `scripts/probe_issuer_v3_r2_readonly.py` | Kept deterministic/read-only helper behavior. Overlay creation now requires explicit Task and Agent bindings; the checked-in example uses synthetic fixture identities. The staging artifact producer emits the final consumer's fixed 128-tilde `c5-r14-source.conf` drop-in path. |
|
||||
| fixture | `tests/fixtures/c5-r14-execution-plan.json`; `tests/fixtures/issuer-v3-base/*`; `tests/fixtures/trusted-candidate-builder-policy-c5.json` | Kept only isolated synthetic Task/Agent identities. Moved the C5 builder policy from runtime config to test fixtures. |
|
||||
| tests | `tests/baseline_v1_1/test_claim_heartbeat.py`; `tests/baseline_v1_1/test_loop.py`; `tests/task_plane/test_live_governed_writer_adapters.py`; `tests/test_c5_builder_contract.py`; `tests/test_c5_completion_renewal.py`; `tests/test_c5_exact_deployment_adapter.py`; `tests/test_c5_production_deployment_adapter.py`; `tests/test_c5_registered_installer_executor.py`; `tests/test_c5_release_execution_host.py`; `tests/test_c5_staging_artifact.py`; `tests/test_c5_staging_installation.py`; `tests/test_governed_publication_client.py`; `tests/test_issuer_v3_overlay_builder.py`; `tests/test_issuer_v3_receipt_provider.py`; `tests/test_signer_contract_compatibility.py`; `tests/test_trusted_candidate_builder.py`; `tests/test_trusted_evidence_providers_harness.py`; `tests/test_v2r2c5_capability_contract.py` | Integrated the selected regression coverage and resolved overlap tests in favor of the combined security contract. Execution-enabled heartbeat fixtures explicitly install trusted identity and evidence verifiers. The staging-artifact fixture builds a disposable Git source repository and never reads a live release tree. |
|
||||
|
||||
## Overlap resolution
|
||||
|
||||
1. Builder source and tests use the later builder contract while preserving G1
|
||||
admission and rollback validation.
|
||||
2. Deployment source and tests retain the integrated adapter and add the later
|
||||
strict symlink target checks.
|
||||
3. The release host retains completion-only support and the boundary/provider
|
||||
integration checks; the boundary host was not accepted wholesale because it
|
||||
would remove completion behavior.
|
||||
4. `silent_release_hardening.py` retains the completion dependency and derives
|
||||
its state-root check from the registered completion authority while keeping
|
||||
the C6 standing-authorization guard and credential fencing.
|
||||
5. The six current-main overlaps were reconciled without whole-file selection:
|
||||
the baseline evidence changes were identical, the capability vocabulary kept
|
||||
the C6 digest additions, staging tests kept C6 file-mode fixtures, and the C5
|
||||
completion dependency was combined with C6 authorization guards.
|
||||
6. The unsigned staging producer and the final consumer now share the governed
|
||||
fixed drop-in identity. The consumer's path, mode, archive, hash, and signature
|
||||
validators remain unchanged and fail closed on any other producer path.
|
||||
|
||||
## Excluded ephemeral material
|
||||
|
||||
The candidate contains no live one-time Task or Agent identity, TTL, nonce,
|
||||
approval fingerprint, activation file, or default historical Saga-head binding.
|
||||
It also contains no copied receipt, live journal, baseline, registry projection,
|
||||
signer credential, or production service state. Immutable source/tree/artifact,
|
||||
lineage, hash, expiry, authentication, and compare-and-swap checks remain in the
|
||||
code. Test-only identities are explicitly named `fixture-*` or `FIXTURE-*`.
|
||||
|
||||
## Verification record
|
||||
|
||||
Verification is recorded against the committed candidate and its exported
|
||||
snapshot. Windows results are treated as development evidence only where POSIX
|
||||
ownership, mode, or symlink semantics are unavailable. The authoritative full
|
||||
regression is run from the exported tree in a disposable Linux unit with empty
|
||||
capabilities, private network and temporary directories, a read-only system,
|
||||
and no signer, live configuration, or Task database access.
|
||||
@@ -0,0 +1,99 @@
|
||||
# V2R2C6 acceptance record
|
||||
|
||||
Current review: see [V2R2C6_MAINLINE_VERIFICATION_20261001.md](V2R2C6_MAINLINE_VERIFICATION_20261001.md).
|
||||
The 2026-09-26 facts below are historical. C6 is now based on the accepted
|
||||
`V2R2C5` tag (`5f65e458957dd51c9973cfa956a624f3cbf54990`); source integration,
|
||||
component tests, and isolated Linux proofs have advanced. Production C6
|
||||
acceptance remains unestablished. Do not use the historical base or gate rows
|
||||
below as current deployment facts.
|
||||
|
||||
Status: **DEVELOPMENT CANDIDATE — NOT RELEASE ACCEPTED**.
|
||||
|
||||
This record separates source verification from package, host, governed runtime,
|
||||
and business acceptance. Final commit/tree and artifact identities are recorded
|
||||
in the delivery `SOURCE_MANIFEST.json`; validation commands/logs accompany it.
|
||||
|
||||
## Identity and provenance
|
||||
|
||||
| Item | Recorded fact | Acceptance effect |
|
||||
|---|---|---|
|
||||
| C5 base | local candidate `536a2af0f07962495c88526061ce43fb27994c15` | Pinned development ancestry only. It is not evidence that C5 was authority-published. |
|
||||
| C6 branch | `agent/v2r2c6-autonomy-20260926` | Local development branch; no protected-ref or publication claim. |
|
||||
| Final C6 HEAD | delivery `SOURCE_MANIFEST.json` | Exact committed source is required before merge/release review. |
|
||||
| Remote publication | not performed in this work | No C6 branch, tag, mainline, artifact, or deployment publication is claimed. |
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
| Gate | State | Evidence and remaining requirement |
|
||||
|---|---|---|
|
||||
| A. Requirement/file traceability | LOCAL SOURCE MAPPED | F1-F7, the architecture input and implementation mappings are recorded in the C6 documents. Delivery inventory binds changed files to committed hashes; runtime gaps remain listed below. |
|
||||
| B. Local source behavior | PASS IN RECORDED SCOPE | 734 passed, 3 POSIX skips, 17 subtests passed. Includes the full TaskPlane test directory and selected related C5/C6 suites; it does not prove production behavior. |
|
||||
| C. Repository-wide supported-platform regression | NOT ESTABLISHED | Windows cannot collect all Linux-only modules and absent external dependencies. The successful related-suite command is recorded separately and is not a repository-wide result. |
|
||||
| D. Component artifact/config | NOT ACCEPTED | Component/build-spec code and tests exist. Final immutable archive identity, extracted-package import, service-UID load, actual config digest, governed install receipt, readback and rollback evidence are required. |
|
||||
| E. Host composition | NOT TESTED | C6 is default-off. A real Root reader, pinned registry, registered worker authority, verifier, effect/readback owner, durable-intent owner, governed pilot and WriterLease/CAS adapter have not been bound and observed together. |
|
||||
| F. Governed end-to-end | NOT TESTED | No live admission → claim/heartbeat → TaskPrincipal → WriterLease → durable intent/effect → exact readback → independent verification → authorized acceptance run is recorded. |
|
||||
| G. Linux/restart/rollback | NOT TESTED | Clean Linux install, effective systemd config, service UID, listener owner, restart, stale observation, rollback and post-rollback readback remain required. Windows POSIX no-follow tests were skipped and the implementation fails closed there. |
|
||||
| H. Release/business acceptance | NOT ACCEPTED | C5 publication ancestry, C6 governed publication, independent release review, deployed runtime identity and authorized business acceptance are absent. |
|
||||
|
||||
## Functional acceptance statements
|
||||
|
||||
- Authorization planning is read-only. It may find an existing exact Root path,
|
||||
but cannot manufacture or extend Root authority. Any write still requires the
|
||||
existing governed WriterLease/CAS path.
|
||||
- Automatic claim is authoritative only when the store adapter performs all
|
||||
schedulability and authorization rechecks in the claim transaction. Its claim
|
||||
receipt is not a WriterLease.
|
||||
- Parallelism is accepted only for disjoint local resource scopes. Same-effect
|
||||
and same-branch writes remain serialized by the authoritative effect owner and
|
||||
WriterLease/CAS, including across processes and hosts.
|
||||
- Missing or ambiguous effect evidence stays `UNKNOWN`; it does not authorize a
|
||||
retry. ACK-loss recovery uses exact effect-key readback.
|
||||
- P6 is intentionally narrow: it accepts an exact, unexpired Owner-signed
|
||||
terminal decision and emits an idempotent `NOOP` receipt. It provides no
|
||||
resume, terminal reopen, effect execution, or successor creation path.
|
||||
- ARO Phase A is default-off and read-only across eight dimensions. Its six-value
|
||||
decision output and audit report do not grant authority or mutate task,
|
||||
policy, Git, release, or business state.
|
||||
- Execution recorded, independently verified, and business accepted remain
|
||||
separate gates. A component test, self-report, Shadow result, health endpoint,
|
||||
archive checksum, or verifier `UNKNOWN` cannot close business acceptance.
|
||||
|
||||
## Evidence accepted at this stage
|
||||
|
||||
The following evidence is accepted only as local component evidence:
|
||||
|
||||
- operation/readback/patch focused suite: 24 passed;
|
||||
- ARO/decision focused suite: 28 passed;
|
||||
- auto-claim, schedulability and capability contract workstream suite: 57 passed;
|
||||
- signed terminal-NOOP recovery plus lineage/API workstream suite: 38 passed;
|
||||
- external API security and related legacy tests: 25 passed, 2 Windows skips.
|
||||
|
||||
The skips cover POSIX directory-descriptor/no-follow behavior and require a Linux
|
||||
run. Package, runtime identity, evidence composition, full regression and live
|
||||
host totals are intentionally not inferred or filled from the presence of test
|
||||
files. Final commands, environment, exit codes/counts and artifact hashes
|
||||
accompany the delivery manifest. Source regression ran on Windows 11 / Python
|
||||
3.14.7: 734 passed, 3 skipped, 17 subtests passed in 112.85 seconds (runner wall
|
||||
time 115.27 seconds). Six Windows socket shutdown thread warnings remain in the
|
||||
log. The first aggregate failed six fixture time assertions; observation-time
|
||||
clocks fixed them, then the same complete related suite passed. No production
|
||||
behavior was relaxed to satisfy the fixture.
|
||||
|
||||
## Required release evidence still absent
|
||||
|
||||
1. Authority-published C5 identity and ancestry reconciliation against the local
|
||||
C5 candidate used for development.
|
||||
2. Independent release review and complete CI/test record on supported Linux
|
||||
environments; local HEAD/tree and related-suite inventory are delivered.
|
||||
3. Immutable component archives, build spec/config digests, dependency and
|
||||
rollback identities, governed installation receipt and readback.
|
||||
4. Trusted Linux host observation of loaded module/config/process identities,
|
||||
restart behavior, rollback, and drift detection.
|
||||
5. A real default-off-to-enabled composition with Root/registry/worker/evidence/
|
||||
intent/readback/WriterLease adapters and the registered governed source-patch
|
||||
pilot.
|
||||
6. The complete governed vertical flow, independent verifier result, authorized
|
||||
business acceptance and exact OID/effect reconciliation.
|
||||
|
||||
Until those records exist, the release verdict remains **NOT ACCEPTED**. This
|
||||
does not invalidate the focused source tests; it limits what they prove.
|
||||
@@ -0,0 +1,164 @@
|
||||
# V2R2C6 architecture and compatibility
|
||||
|
||||
2026-09-29 update: `V2R2C6_TWO_LAYER_AUTHORIZATION.md` specifies the concrete
|
||||
standing release broker, signed parent/actor binding, short credential renewal,
|
||||
stable action identity and actual effect guards. It builds on C6 `faf6eb2` and
|
||||
retains the existing Saga, approval store and capability ledger. Local host
|
||||
composition is provided; registered production readers/signer and external
|
||||
write fencing still require host integration and runtime acceptance.
|
||||
|
||||
2026-09-28 update: the branch incorporates reviewed C5 source
|
||||
`2af1dfbc40277ba1d89c8fae8b18098d40e51672` through merge `d54f832`.
|
||||
The release controller, standing-authorization routing and task/event projection
|
||||
changes are described in `V2R2C6_RELEASE_CONTROL_INTEGRATION.md`. These are local
|
||||
source changes; host composition and production acceptance remain separate.
|
||||
|
||||
Original development base: C5 local candidate `536a2af0f07962495c88526061ce43fb27994c15`
|
||||
(tree `9f27f76e7fa5561f90ff8bfe26fd18865b4a5ebb`). This is a source ancestry
|
||||
statement, not proof that C5 has been released. C6 is developed on
|
||||
`agent/v2r2c6-autonomy-20260926`; protected refs and deployed state are outside
|
||||
this development change.
|
||||
|
||||
## Authorization and automation
|
||||
|
||||
The Owner's existing authorization remains the trust boundary. A read-only
|
||||
planner checks the pinned Git capability declaration, exact task/actor/mission,
|
||||
repository/branch/source/file scope, time, budget and revocation. It never signs
|
||||
or extends a Root. `acquire_authorized_writer` re-reads those inputs and delegates
|
||||
only to the existing `GovernedWriterAutomation.acquire_verified` path. A valid
|
||||
Root can support the existing bounded Epoch issuance; an expired or widened
|
||||
Root requires the established Owner authorization flow.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
G[Immutable Git capability registry] --> P[Read-only authorization planner]
|
||||
R[Existing verified Root authority] --> P
|
||||
P --> W[Existing governed writer automation]
|
||||
I[Authenticated registered workers] --> C[C6 host tick / Task API]
|
||||
C --> S[Schedulability proof and atomic recheck]
|
||||
S --> T[Existing TaskPlane SQLite authority]
|
||||
T --> X[Registered source patch pilot]
|
||||
X --> W
|
||||
W --> F[TaskPrincipal / WriterLease / CAS]
|
||||
F --> O[Authoritative effect readback]
|
||||
O --> V[Independent verification]
|
||||
V --> A[Authorized business acceptance]
|
||||
```
|
||||
|
||||
`bind_c6` explicitly binds the existing TaskPlane service to its existing store,
|
||||
registered worker provider and operation-bound worker receipt verifier, pinned
|
||||
capability map, authoritative observation readers, durable intent reader and
|
||||
governed source patch pilot. The default configuration is disabled.
|
||||
The API host owns the optional tick; no second scheduler daemon, authorization
|
||||
database, signer, Task database or ExecutionLease is introduced. An installed
|
||||
module, enabled flag, or healthy HTTP response does not grant execution rights.
|
||||
|
||||
The only execution pilot is `governed_source_patch_apply`. A task must carry its
|
||||
immutable `c6_operation_binding_digest` and generation; that digest binds actor,
|
||||
tenant, task/job, source, branch, capability namespace, input/patch/plan/policy,
|
||||
scope and effect identity. Runtime preflight is read-only and repeated before
|
||||
effect. A pilot must persist an exact execution intent and the host must read
|
||||
that receipt back before execution. The current claim, authorization, freeze
|
||||
and exact effect state are checked again under the host authority guard before
|
||||
the pilot's short write critical section. Planning, model calls and general
|
||||
agent work must occur outside that section. The registered executor remains
|
||||
responsible for write-time Root/revocation/budget/WriterLease/CAS checks and
|
||||
effect-key idempotency at its own linearization point. No executor adapter
|
||||
is synthesized from an agent-provided `live=true` assertion.
|
||||
|
||||
## Concurrency, claims and recovery
|
||||
|
||||
Formal AgentTask capability tokens retain their C5 vocabulary; negotiated
|
||||
clients additionally bind its deterministic schema digest on registration,
|
||||
admission, polling and claim. Legacy clients retain C5 behavior. Generic
|
||||
TaskPlane wildcard capabilities stay in a different namespace.
|
||||
|
||||
The TaskPlane adapter rechecks authorization, scope, worker registration revision,
|
||||
capabilities, heartbeat, task revision/generation, dependencies, freeze and
|
||||
capacity inside the same authority/SQLite transaction that creates the claim.
|
||||
Its v3 receipt contains actual claim/revision/attempt/digest fields. An attempt
|
||||
number is never relabelled as a WriterLease fencing generation. Existing claim
|
||||
renewal is fenced by holder, claim identity and task operation digest. Enabled
|
||||
C6 HTTP heartbeats require the exact v3 claim receipt and use this same scoped
|
||||
controller; missing authority assertions deny renewal.
|
||||
|
||||
`MutationGuard` uses ticket identity and thread ownership. Replayed, forged,
|
||||
foreign or already-finished tickets cannot release another holder's lock.
|
||||
Optional resource scopes permit independent local targets to proceed in parallel;
|
||||
the existing WriterLease/CAS remains authoritative across processes and hosts.
|
||||
|
||||
Unknown effect outcomes are reconciled by the same immutable effect key. A
|
||||
missing reader, 404 or lost ACK does not authorize re-execution. The host keeps a
|
||||
pending unknown operation on readback only. Existing durable intents also block
|
||||
execution after a host restart; readback or formal recovery must resolve them.
|
||||
Durable effect knowledge belongs to
|
||||
the existing Task/Job authorities, not the host's in-memory observation cache.
|
||||
Terminal recovery validates exact signed identity/CAS/nonce before returning a
|
||||
NOOP, and does not rewrite historical tasks.
|
||||
|
||||
## Evidence and closure
|
||||
|
||||
Execution requires explicit finite trusted identities and an injected evidence
|
||||
verifier. Service construction preserves a store's configured verifier and
|
||||
rejects conflicting verifier identities. Legacy unsigned evidence producers
|
||||
receive a migration error before writing; complete signed envelopes bind the
|
||||
task, claim, producer, domain sequence, digest chain and expiry. The included
|
||||
HMAC adapter consumes an externally supplied key resolver; it creates no trust
|
||||
root or persisted key. A deployment must bind its approved verification provider.
|
||||
|
||||
Task/Job execution, independent verification and authorized business acceptance
|
||||
are separate facts. Shadow completion, self-verification, untrusted signatures,
|
||||
stale/cross-scope receipts or absent acceptance cannot become business success.
|
||||
Rejected terminal transitions are audited after the failed mutation transaction
|
||||
rolls back. Each evidence domain rejects duplicate/regressing sequence numbers;
|
||||
there is no invented global sequence across authorities.
|
||||
|
||||
ARO Phase A reads eight dimensions (function, performance, exception, usage,
|
||||
defect, history, deadlock and long-running work). It writes only immutable audit
|
||||
reports and exposes the six decision values. Missing facts remain UNKNOWN. It
|
||||
does not acquire authority, sign, execute, or automatically expand task scope.
|
||||
|
||||
## Components, runtime identity and configuration
|
||||
|
||||
`deploy/aios-v2/c6-components.json` declares formal AgentTask, Bridge, TaskPlane
|
||||
and health component roots. The builder reads dependency bytes from the exact
|
||||
Git commit with replacement objects disabled, resolves only selected local
|
||||
modules, and records every packaged file's digest. Tests import extracted
|
||||
components without a worktree PYTHONPATH. Dynamic/data dependencies must be
|
||||
explicit; production host bindings must be checked separately.
|
||||
|
||||
`BUILD-SPEC.json` freezes nonsecret effective config as a separate hashed build
|
||||
input. It permits credential reference names, not credential values or arbitrary
|
||||
environment/drop-in dictionaries. `COMPONENT-MANIFEST.json` records module,
|
||||
config, explicit per-component data/unit files, source/tree, entrypoint,
|
||||
dependency contract and rollback identities.
|
||||
The detached receipt records the final archive digest, avoiding self-reference.
|
||||
Exclusive publication prevents accidental overwrite; it is not a claim of
|
||||
filesystem immutability. Consumers must verify receipt and file hashes whenever
|
||||
opening an artifact. A source rollback reference is not a tested service rollback.
|
||||
The legacy health-only installer explicitly rejects C6 component deployment.
|
||||
These source packages require an approved component host installation binding.
|
||||
|
||||
Runtime status separates declared identity from trusted observed identity and
|
||||
uses PASS/FAIL/UNKNOWN. A 40-character Git OID is not an artifact SHA-256. Empty
|
||||
inventory, stale/missing observations and self-reported environment/health values
|
||||
cannot produce readiness or independent runtime verification. Liveness remains
|
||||
available. Clean Linux service installation/restart/rollback and independently
|
||||
observed module/config hashes are release gates, not inferred from package tests.
|
||||
|
||||
## Compatibility and later integration
|
||||
|
||||
Explicit-trust fixture/API migration is intentional: callers enabling execution
|
||||
must configure trusted identities and a verifier; unsigned evidence is rejected.
|
||||
C5 formal protocol exports and default legacy capability negotiation remain.
|
||||
Single-segment filenames and nested relative file paths have separate validators;
|
||||
protected path writes use POSIX directory descriptors/no-follow and fail closed
|
||||
on unsupported platforms. Runtime selection and event/context/observability
|
||||
ingress use server-derived identity and tenant scope. Development authorization
|
||||
bypass requires an explicitly isolated staging profile on loopback.
|
||||
|
||||
After C5 publication, verify the released C5 commit against this recorded base.
|
||||
If C5 changed, merge that released ancestry into the C6 branch and rerun affected
|
||||
checks. C6 review, governed repository publication, mainline merge, deployment
|
||||
and runtime/business acceptance remain separate gates. Do not infer any of them
|
||||
from a local source test, archive checksum, or this document.
|
||||
@@ -0,0 +1,125 @@
|
||||
# V2R2C6 architecture input reconciliation
|
||||
|
||||
Status: read-only reconciliation of the 2026-09-26 architecture review input
|
||||
against the current local C6 development candidate. This is not a new product
|
||||
requirement package, implementation authorization, runtime observation, or
|
||||
release decision.
|
||||
|
||||
## Input identity and evidence boundary
|
||||
|
||||
- Review ZIP SHA-256:
|
||||
`f5121258ac8b1308bc8b1f50ccea40ef686f5727aede2433df65eaba0372d166`.
|
||||
- The integration owner reported safe extraction and successful verification of
|
||||
all 395 checksum entries before this reconciliation.
|
||||
- Read scope was deliberately limited to `00-README.md`,
|
||||
`08-open-work-and-conflicts.md`, `10-reviewer-handoff.md`,
|
||||
`13-reference-review-reconciliation.md`, and the necessary
|
||||
`12-platform-capability-map.md`.
|
||||
- The package's source evidence is fixed to historical main
|
||||
`e5f8209f57b43fc39e55c4555776e6362ce326eb` (tree
|
||||
`743caa498fcaaa150d7a3097d1c402e9b20bb819`) and its runtime observations are a
|
||||
2026-09-26 snapshot. Neither is current C6 runtime truth.
|
||||
- Current C6 development remains based on the unpublished local C5 candidate
|
||||
`536a2af0f07962495c88526061ce43fb27994c15` on
|
||||
`agent/v2r2c6-autonomy-20260926`. The different baselines are retained rather
|
||||
than merged into one claim.
|
||||
|
||||
Commands and remediation suggestions inside the review package were treated as
|
||||
review data only. No production read/write, service action, authorization,
|
||||
Task/Job mutation, Git ref update, or package command was executed from it.
|
||||
|
||||
## Compatibility with the approved v15/C6 direction
|
||||
|
||||
| Review direction | Current C6 treatment | Reconciliation |
|
||||
|---|---|---|
|
||||
| One control plane and one write authority per domain | `OperationView` is a reconstructible read-only projection; C6 introduces no MissionRun/task/effect authority database. | Aligned. Do not expand DOPC into a universal state machine. |
|
||||
| Separate declared, installed, callable, authorized and fresh capability facts | Authorization planner, runtime observation and six-state operation preflight keep these facts distinct. | Aligned at source-contract level; installed/callable live evidence remains absent. |
|
||||
| Formal admission and claim use one vocabulary; unknown capabilities fail before a claimable row | Formal AgentTask schema digest and rejection tests are preserved separately from generic TaskPlane wildcard semantics. | H-02 is absorbed in source work; installed mixed-version routing is still unverified. |
|
||||
| Root/delegation cannot create an executor or reopen terminal state | Planner is read-only; WriterLease/CAS remains external authority; P6 only returns a signed terminal NOOP. | Aligned. No resume, effect execution, terminal reopen, or successor creation is attributed to P6. |
|
||||
| Exact pilot must exist before authorization can make it executable | `GovernedSourcePatchPilot` is an injected protocol and the only registered operation name is `governed_source_patch_apply`. | Partially aligned. The production executor/caller/install/readback chain is still a blocking absence. |
|
||||
| Unknown effect reconciles before retry | Exact immutable effect-key readback returns committed/not-committed/conflict/unknown and does not convert missing evidence into retry authority. | Aligned in component logic; restart and live authority ownership remain unverified. |
|
||||
| Independent verification and business acceptance remain separate | C6 evidence and closure contracts separate execution, verification and acceptance. | Aligned in source; real verifier and acceptor have not completed a live vertical flow. |
|
||||
| ARO must not self-authorize or self-modify safety policy | C6 delivers only default-off, read-only ARO Phase A over eight dimensions. | No conflict. The package's mutation gates, budget, rollback and independent acceptance apply to later ARO phases and are outside this C6 implementation. |
|
||||
|
||||
The review package therefore does not require a broad architecture rewrite. Its
|
||||
main contribution is sharper evidence and deployment closure criteria for work
|
||||
already represented in the approved v15 design.
|
||||
|
||||
## Requirements already absorbed by current C6 source
|
||||
|
||||
| Review index | Current source response | Remaining evidence limit |
|
||||
|---|---|---|
|
||||
| H-02 capability admission | `aios_capability_contract.py`, `aios_agent_task_protocol.py`, C6 capability tests | Real installed route/package negotiation not observed. |
|
||||
| H-03 selected security findings | Model/actions hardening plus `agent_context_api.py`, `agent_observability_api.py`, `agent_event_api.py`, `agent_event_bus.py` address M-1/M-2/M-4/M-5/M-6/M-7/M-12 source paths. | All 13 gates still require final production-module evidence; source fixes do not prove effective mounting or deployment. |
|
||||
| H-06 read-only preflight | `task_plane/operation_view.py` implements exact binding and six mutually exclusive outcomes with bounded evidence references. | Real runtime observations and pre-effect host revalidation are not bound. |
|
||||
| H-08 projection/readback | `operation_readback.py` preserves authority-over-projection and treats stale/conflicting facts as unknown. | No live lag/divergence/restart observation exists. |
|
||||
| H-11 event tenant/target isolation | Event ingress requires trusted-principal `tenant_id` and exact `allowed_event_targets`; publish persists tenant binding and list/get/transition/stats enforce it before mutation. Missing host scope fails closed. | Effective host principal projection and externally mounted routes remain unverified. |
|
||||
| H-14 ARO Phase A | `aro_readonly.py` and `decision_policy.py` provide eight dimensions, immutable reports and six decisions without business mutation. | Later autonomous remediation gates are deliberately absent. |
|
||||
| H-23 reference-review security threads | Context identity, observability scope, event path handling, runtime selection and unknown-action negative paths have C6 tests. | Historical V1.1 test/deployment claims were not reused as current PASS. |
|
||||
|
||||
## Current C6 implementation blockers confirmed by the new input
|
||||
|
||||
These are concrete composition gaps, not requests for another framework:
|
||||
|
||||
1. **Exact governed pilot (H-05).** C6 defines the input contract and host
|
||||
protocol, but no production `governed_source_patch_apply` executor has been
|
||||
identified and bound with its real caller, artifact, install path and exact
|
||||
authorization consumer. Until then preflight must return
|
||||
`EXECUTION_ENTRYPOINT_ABSENT` or another precise non-ready result.
|
||||
2. **Durable intent and effect owner (H-07).** The host requires an injected
|
||||
durable-intent reader and authoritative effect reader. A fixture or in-memory
|
||||
cache cannot satisfy this. The real Task/Job authority must own persistence,
|
||||
ACK-loss readback and restart reconciliation without creating a second DB.
|
||||
3. **Authority composition (H-04).** Default-off C6 has not been bound to the
|
||||
deployed verified Root reader, pinned Git registry, registered-worker
|
||||
authority, revocation/budget checks, or actual WriterLease/CAS path.
|
||||
4. **Independent closure (H-09).** The source supports explicit verifier
|
||||
identity and acceptance separation, but there is no current independent
|
||||
verifier plus authorized business acceptor result for the C6 candidate.
|
||||
5. **Runtime/component identity (H-01/H-20).** Component packaging contracts are
|
||||
present, but no clean Linux install, service-UID import, effective config,
|
||||
loaded byte digest, rollback or on-site C6 readback exists.
|
||||
6. **Test truth (H-19).** Focused Windows component suites are evidence only for
|
||||
their tested units. Linux-only no-follow, systemd, packaging, restart and full
|
||||
production imports remain required.
|
||||
|
||||
These blockers already fit the default-off host contract documented in
|
||||
`V2R2C6_CHANGE_MATRIX.md`; they do not justify enabling a generic shell writer,
|
||||
minting new authorization, or treating health/HTTP 200 as readiness.
|
||||
|
||||
## Newly explicit follow-up work outside the current C6 boundary
|
||||
|
||||
The review package also covers broader AIOS platform work. The following should
|
||||
remain separately owned follow-up routes unless a later requirement explicitly
|
||||
brings them into a release:
|
||||
|
||||
- **H-12 durable message architecture:** C6 now enforces event tenant and exact
|
||||
target scope from the trusted principal, but does not merge the file event
|
||||
bus, TaskPlane outbox and Agent-OS message/memory prototypes into one durable
|
||||
fabric. Delivery receipts, ordering, deduplication and DLQ ownership need a
|
||||
separately approved route. A new universal message service is not implied.
|
||||
- **H-13 model/tool governance:** registry freshness, provider probes, fallback,
|
||||
cost and audit are separate capability domains; C6 runtime-selection hardening
|
||||
does not prove upstream model/tool availability.
|
||||
- **H-15 retirement:** old Task/Bridge/model entrypoints require caller and
|
||||
in-flight-task inventories, single-writer proof and controlled drain before
|
||||
sunset.
|
||||
- **H-17 disaster recovery:** Git/DB/artifact/receipt backup and isolated restore
|
||||
must preserve nonce, lease and idempotency protections; no exercise occurred.
|
||||
- **H-18 API convergence:** source routes, OpenAPI, effective overlays and live
|
||||
methods need a per-operation compatibility table. Current source-level tests
|
||||
cannot establish effective routing.
|
||||
- **H-21/H-22 performance and non-core products:** real SLO/cost/manual-event
|
||||
measurements and WebOps/search/channel E2E belong to their product owners.
|
||||
- **ARO later phases:** capability allowlists, deny-lists, budgets, rollback and
|
||||
independent acceptance are prerequisites for any future mutation phase. They
|
||||
are not silently added to Phase A.
|
||||
|
||||
## Acceptance effect
|
||||
|
||||
The new architecture input strengthens the existing `NOT RELEASE ACCEPTED`
|
||||
verdict but does not change it to a new failure or a completed review. Current
|
||||
C6 source has absorbed several design and security contracts; package integrity,
|
||||
historical main inspection and focused tests remain separate from live component
|
||||
identity, governed execution, independent verification, rollback and business
|
||||
acceptance. Closing the six blockers above is the minimum C6 integration route.
|
||||
@@ -0,0 +1,91 @@
|
||||
# V2R2C6 change matrix
|
||||
|
||||
Current implementation and unresolved requirements are recorded in
|
||||
[V2R2C6_MAINLINE_VERIFICATION_20261001.md](V2R2C6_MAINLINE_VERIFICATION_20261001.md).
|
||||
The development identities and runtime observations below describe the original
|
||||
2026-09-26 milestone. The current C5 parent is accepted tag `V2R2C5`, source
|
||||
`5f65e458957dd51c9973cfa956a624f3cbf54990`.
|
||||
|
||||
Status: local development candidate tracking, 2026-09-26. This document maps
|
||||
implemented source and focused tests; it is not a release, deployment, runtime,
|
||||
or business-acceptance receipt.
|
||||
|
||||
- Development base: local C5 candidate `536a2af0f07962495c88526061ce43fb27994c15`.
|
||||
The candidate is not authority-published C5.
|
||||
- Development branch: `agent/v2r2c6-autonomy-20260926`.
|
||||
- Final C6 HEAD and the final whole-suite result are intentionally left for the
|
||||
integration owner after concurrent edits stop.
|
||||
|
||||
## Architecture review input
|
||||
|
||||
The later architecture review ZIP
|
||||
`f5121258ac8b1308bc8b1f50ccea40ef686f5727aede2433df65eaba0372d166`
|
||||
was reconciled in
|
||||
[`V2R2C6_ARCHITECTURE_INPUT_RECONCILIATION.md`](V2R2C6_ARCHITECTURE_INPUT_RECONCILIATION.md).
|
||||
Its fixed main (`e5f8209...`) and runtime observations are historical evidence,
|
||||
not current C6 truth. It confirms the present default-off blockers: no proven
|
||||
production exact pilot, durable-intent/effect owner, live Root/registry/worker/
|
||||
WriterLease composition, independent verifier/acceptor flow, Linux component
|
||||
readback, or complete production-module test evidence.
|
||||
|
||||
Its broader platform items do not expand this C6 into a universal rewrite.
|
||||
Event tenant/target authorization was absorbed into the M12 source patch;
|
||||
durable message delivery, model/tool registry freshness, legacy entrypoint
|
||||
retirement, disaster recovery, route/OpenAPI/overlay convergence, and later ARO
|
||||
mutation phases remain explicit follow-up work.
|
||||
|
||||
## F1-F7 traceability
|
||||
|
||||
| Finding | Implemented source | Focused verification surface | Current boundary |
|
||||
|---|---|---|---|
|
||||
| F1 component artifact coverage | `scripts/autonomous-release/component_release.py`, `deploy/aios-v2/c6-components.json`, `scripts/autonomous-release/build_trusted_release_artifact.py`, `active-runtime/app/trusted_production_adapter.py` | `tests/test_c6_component_package.py` | Source-level component manifests and package checks exist. Service-UID import, governed installation, component readback, rollback, and vertical live E2E are not verified. |
|
||||
| F2 runtime identity truth | `active-runtime/app/component_runtime_identity.py`, `active-runtime/app/aios_chatgpt_bridge_v2_server.py`, `active-runtime/app/deployment_state.py`, `active-runtime/app/v2r1c6_privacy_tools.py` | `tests/test_c6_runtime_identity.py` | Declared and observed identities, Git OID and artifact SHA-256, and PASS/FAIL/UNKNOWN are separated in source. No independently observed live module/config/process identity has been collected. |
|
||||
| F3 verifier composition and producer migration | `active-runtime/app/task_plane/evidence_verifier.py`, `active-runtime/app/task_plane/{api,models,policy,store,evidence}.py`, `active-runtime/app/task_plane/c6_composition.py` | `tests/test_c6_evidence_composition.py`, `tests/task_plane/test_c6_composition.py`, `tests/task_plane/test_c6_terminal_audit.py` | Execution-mode trust is explicit and conflicting/missing verifier bindings fail closed. The included HMAC verifier needs an external resolver and is not a production trust root. A real provider, migrated live producers, restart readback, and fault-atomic live flow remain unverified. |
|
||||
| F4 reproducible configuration | `scripts/autonomous-release/component_release.py`, `deploy/aios-v2/c6-components.json` (`BUILD-SPEC.json`/component manifest contracts) | `tests/test_c6_component_package.py` | Nonsecret effective configuration and credential references have build-time contracts. Clean Linux installation, real systemd effective configuration, listener owner, restart, and rollback equivalence remain unverified. |
|
||||
| F5 exact preflight | `active-runtime/app/task_plane/operation_view.py`, `operation_readback.py`, `patch_contract.py`, `c6_composition.py` | `tests/task_plane/test_operation_view.py`, `test_operation_readback.py`, `test_patch_contract_c6.py`, `test_c6_governed_pilot_integration.py` | Operation binding, six preflight outcomes, stale/unknown handling, exact effect readback, and source-patch contract are implemented. READY is read-only and must be rechecked before effect. Live Root, registry, durable intent, governed pilot, and WriterLease/CAS adapters are not proven. |
|
||||
| F6 capability domain contract | `active-runtime/app/aios_capability_contract.py`, `aios_agent_task_protocol.py`, `active-runtime/app/task_plane/c6_composition.py` | `tests/test_c6_capability_contract.py`, `tests/test_capability_registry_c6.py`, existing AgentTask protocol regressions | Formal AgentTask vocabulary/schema digest remains distinct from generic TaskPlane capabilities; unknown formal tokens fail closed. Installed route/package interoperability and live mixed-version negotiation remain unverified. |
|
||||
| F7 trust and acceptance semantics | `active-runtime/app/task_plane/{policy,models,evidence,evidence_verifier,c6_composition}.py` | `tests/test_c6_evidence_composition.py`, `tests/task_plane/test_c6_composition.py`, `test_c6_terminal_audit.py` | Execution recorded, independently verified, and business accepted are separate facts. Shadow, missing/untrusted verifier, and absent acceptance cannot close business success. A real acceptor and production consumer have not been exercised. |
|
||||
|
||||
## Original C6 capability goals
|
||||
|
||||
| Goal | Implemented source and behavior | Verification and explicit limit |
|
||||
|---|---|---|
|
||||
| Authorization automation | `task_plane/authorization_planner.py` reads a pinned Git declaration and verified Root snapshot; `authorization_composition.py` delegates only through existing governed writer automation. | `tests/task_plane/test_authorization_planner_c6.py`. The planner does not sign, create a Root, widen scope, or authorize a write by itself. |
|
||||
| Automatic claim and renewal | `task_plane/schedulability_store_adapter.py`, `auto_claim.py`, and existing store/schedulability modules recheck worker, task, authorization, scope, dependency, freeze, capacity, revision/generation, and active claim in the authority transaction. | `tests/task_plane/test_auto_claim_store_c6.py` plus existing schedulability tests cover contention, stale proof, expiry, holder and renewal denial. A TaskPlane claim generation is not a WriterLease fencing generation. |
|
||||
| Parallel work | `task_plane/governance_control_kernel.py` uses ticket identity/thread ownership and optional resource scope; disjoint work can proceed while same resource serializes. Cross-process writes remain governed by WriterLease/CAS. | `tests/task_plane/test_mutation_guard_ownership_c6.py`, `test_c6_governed_pilot_integration.py`. No multi-host live concurrency run has been performed. |
|
||||
| Unknown-effect recovery | `operation_readback.py` reconciles exact immutable effect keys and keeps missing/conflicting outcomes UNKNOWN; `aios_task_parameterized_recovery.py` adds the bounded terminal recovery adapter. | `test_operation_readback.py`, `tests/test_task_parameterized_recovery_c6.py`. **P6 supports only an exact Owner-signed terminal `NOOP`. It does not resume an effect, reopen a terminal task, or create/authorize a successor.** |
|
||||
| ARO Phase A | `task_plane/aro_readonly.py` evaluates function, performance, exception, usage, defect, history, deadlock, and long-running dimensions; `decision_policy.py` exposes six deterministic decisions. | `test_aro_phase_a_c6.py`, `test_decision_policy_c6.py`. Default off; read-only inputs; immutable audit output; no authority, signer, policy mutation, task mutation, or `AUTO_FIX` for Phase A. |
|
||||
| External API residual security | `agent_context_api.py`, `agent_observability_api.py`, `agent_event_api.py`, `agent_event_bus.py`, shared `security_path_guards.py`, plus model/actions hardening owned by integration. | `tests/test_c6_security_residuals.py`, `test_c6_model_api_security.py`, and adjusted legacy negative fixtures. Server principal and loopback checks precede mutation; event tenant and exact targets come from trusted principal attributes and missing host scope fails closed; protected file operations fail closed without supported no-follow primitives. |
|
||||
|
||||
## Host and authority boundary
|
||||
|
||||
`task_plane/c6_composition.py` is default-off. Enabling it safely requires the
|
||||
real host to bind all of the following, with mutually consistent identities:
|
||||
|
||||
1. an existing trusted `TaskPlaneService` with explicit identity/evidence trust;
|
||||
2. a pinned Git capability registry OID and manifest digest;
|
||||
3. registered worker and authorization readers;
|
||||
4. authoritative operation/effect readers and a durable-intent reader;
|
||||
5. the single registered `governed_source_patch_apply` pilot whose write path
|
||||
revalidates Root, revocation, scope, budget, generation, WriterLease and CAS.
|
||||
|
||||
No test fixture, `enabled=true`, health response, installed module, or in-memory
|
||||
intent is a substitute for those bindings. Clean Linux package installation and
|
||||
rollback, service-UID imports, actual systemd configuration, live governed E2E,
|
||||
independent runtime observation, and on-site release have not been verified.
|
||||
|
||||
## Focused test evidence available before final integration
|
||||
|
||||
| Command/scope | Observed result | Meaning |
|
||||
|---|---|---|
|
||||
| `pytest tests/task_plane/test_operation_view.py tests/task_plane/test_operation_readback.py tests/task_plane/test_patch_contract_c6.py -q` | 24 passed | Operation/readback/patch component behavior only. |
|
||||
| `pytest tests/task_plane/test_aro_phase_a_c6.py tests/task_plane/test_decision_policy_c6.py -q` | 28 passed | Read-only ARO and decision component behavior only. |
|
||||
| auto-claim focused suite plus existing schedulability tests | 45 passed (workstream result) | Local store/authority-adapter claim and renewal behavior only. |
|
||||
| terminal-NOOP recovery focused suite plus lineage/API tests | 38 passed (workstream result) | Signed terminal NOOP/replay/schema behavior only. |
|
||||
| `pytest tests/test_c6_security_residuals.py tests/test_g7_auth_negative.py tests/test_agent_event_bus.py tests/test_agent_observability_loop.py -q` | 25 passed, 2 skipped | API security behavior, including event tenant/target denial; skips are POSIX descriptor/no-follow cases on Windows. |
|
||||
|
||||
The final whole-suite count is not recorded here. A Windows collection attempt
|
||||
was blocked before execution by unavailable POSIX modules (`fcntl`, `grp`),
|
||||
missing optional packages (`cryptography`, `yaml`), and a concurrent test-module
|
||||
import. This is an environment/integration limitation, not a PASS and not a
|
||||
runtime acceptance result.
|
||||
@@ -0,0 +1,55 @@
|
||||
# V2R2C6 开发候选说明
|
||||
|
||||
本分支以 C5 本地提交 `536a2af0f07962495c88526061ce43fb27994c15` 为基线。
|
||||
分支为 `agent/v2r2c6-autonomy-20260926`。最终提交、tree、补丁和制品的
|
||||
SHA-256 记录在随附交付目录的 `SOURCE_MANIFEST.json`,避免文档自引用提交号。
|
||||
|
||||
这批补丁提供 C6 的源码实现、宿主装配契约、架构和回归测试。当前是默认关闭的
|
||||
开发候选;生产 executor、真实权限和证据适配尚未形成现场纵向验收。
|
||||
|
||||
## 已纳入的输入
|
||||
|
||||
- v15 完整需求、pre-launch 评审、C5 修改与 C6 建议。
|
||||
- 2026-09-26 架构评审交接包,其增量对账见
|
||||
[架构输入对账](V2R2C6_ARCHITECTURE_INPUT_RECONCILIATION.md)。
|
||||
- 各输入的独立哈希见 `V2R2C6_INPUT_PROVENANCE.json`。附件里的流程和历史状态
|
||||
属于参考资料,不替代当前的发布、签名或生产写入授权。
|
||||
|
||||
## 实现范围
|
||||
|
||||
1. 授权规划在固定能力 registry、Root 和独立 runtime observation 上重新求值。
|
||||
计划本身不授予权限;实际写入继续调用既有受治理 Writer 路径。
|
||||
2. 自动领取复用既有 TaskPlane 数据库,在原子事务中重验任务、主体、能力、
|
||||
scope、授权、freeze 和容量;手工与自动领取采用同一 C6 边界。
|
||||
3. v3 领取凭据绑定持久事件;续期和执行检查真实 claim holder。
|
||||
正常 worker 存活心跳与重新注册、撤销、变更能力分别处理。
|
||||
4. 执行前必须持久化 intent 并读回,再检查 effect 和权限。ACK 丢失及宿主
|
||||
重启后存在 intent 时只读回,不盲目重放。测试中的 pilot 是测试适配器。
|
||||
5. 授权锁具备票据/线程属主校验;独立资源可并行处理。跨进程、跨主机写入
|
||||
仍依赖 WriterLease、effect 幂等键和 CAS,不用本地锁冒充全局授权。
|
||||
6. 签名证据、时间有效性、domain 序列、终态拒绝审计、Owner 签名的 terminal
|
||||
NOOP 恢复及跨恢复入口 nonce 防重放已收紧。
|
||||
7. context、observability、event、runtime selection 和路径访问加强服务端
|
||||
身份与租户边界。event target 必须属于可信 principal 明确允许的集合。
|
||||
8. ARO Phase A 实现八维只读审计和六值决策;不开放自动修复或扩权。
|
||||
9. 组件构建从固定 Git 提交解析依赖,分离源码、配置和运行观测;C6 不能
|
||||
经旧 health-only installer 被误当作已完整部署。
|
||||
|
||||
详细映射见 [修改矩阵](V2R2C6_CHANGE_MATRIX.md),结构和契约见
|
||||
[架构说明](V2R2C6_ARCHITECTURE.md),验收边界见
|
||||
[验收记录](V2R2C6_ACCEPTANCE.md)。
|
||||
|
||||
## 合并和启用
|
||||
|
||||
C5 正式发布后先核验其真实提交与本分支基线的祖先关系;若 C5 又有修订,先将
|
||||
已发布的 C5 祖先合入 C6,再运行受影响的测试。不要通过重写 C5、main 或 tag
|
||||
来使历史看起来一致。本次仅创建本地 C6 开发提交,未推送或修改受保护分支。
|
||||
|
||||
启用前需要宿主明确提供 Root/registry/runtime reader、可信 worker receipt、
|
||||
领取事务中的授权/freeze 锁、证据验签、持久 intent/readback、正式 source-patch
|
||||
executor 和 WriterLease/CAS,以及独立 verifier 和业务 acceptor。
|
||||
缺少任一必要适配时保留默认关闭或明确拒绝。Linux 服务安装、服务 UID 下模块
|
||||
加载、重启、回滚、现场 E2E 和业务验收需独立完成。
|
||||
|
||||
新架构参考中的消息 outbox/排序/去重/DLQ、DR 演练、旧入口退役及 ARO 自动修复
|
||||
是后续工作。当前补丁没有用“源码存在”或“测试通过”把这些项目标成完成。
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema": "aios.c6.development-inputs.v1",
|
||||
"base_commit": "536a2af0f07962495c88526061ce43fb27994c15",
|
||||
"base_tree": "9f27f76e7fa5561f90ff8bfe26fd18865b4a5ebb",
|
||||
"base_status": "LOCAL_C5_CANDIDATE_PUBLICATION_NOT_VERIFIED",
|
||||
"branch": "agent/v2r2c6-autonomy-20260926",
|
||||
"document_instructions_are_operational_authority": false,
|
||||
"inputs": [
|
||||
{
|
||||
"role": "owner-requirements-v15",
|
||||
"filename": "AIOS_V2R2C6_COMPLETE_OWNER_REQUIREMENT_HANDOFF_FINAL_v15_20260926.zip",
|
||||
"sha256": "10d9a4e9ab75e5bcd5e10aef8443fc8558755ff21ffaabc7b143810dadd4a27e",
|
||||
"size": 234340
|
||||
},
|
||||
{
|
||||
"role": "pre-launch-review",
|
||||
"filename": "pre-launch-check-aios-v2r2c6-v15-2026-09-26.md",
|
||||
"sha256": "a425c73737cea78e273331bf5c366e8ba5ef9c94e4cb6edba84a19bc7014f274",
|
||||
"size": 14293
|
||||
},
|
||||
{
|
||||
"role": "c5-changes-and-c6-recommendations",
|
||||
"filename": "V2R2C5_CHANGES_AND_V2R2C6_RECOMMENDATIONS_20260926.zip",
|
||||
"sha256": "e4fedc8018284fcfceca24b4b73002bc250068b8ace6eac1de6099d87d4c5840",
|
||||
"size": 196137
|
||||
},
|
||||
{
|
||||
"role": "architecture-review-reference",
|
||||
"filename": "AIOS-architecture-review-handoff-20260926.zip",
|
||||
"sha256": "f5121258ac8b1308bc8b1f50ccea40ef686f5727aede2433df65eaba0372d166",
|
||||
"size": 1680241
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user