Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b95fea0d | ||
|
|
8a07817910 | ||
|
|
a642b04577 | ||
|
|
bb80f8cc2f |
@@ -13,6 +13,7 @@ from hashlib import sha256
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from message_handoff import stable_event_id
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -55,13 +56,19 @@ class AgentEventBus:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
event_id = "evt_" + sha256(
|
event_id = stable_event_id(
|
||||||
json.dumps(
|
source=source, target=target, message_type=kind, payload=payload,
|
||||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||||
sort_keys=True,
|
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||||
ensure_ascii=False,
|
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||||
).encode("utf-8")
|
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||||
).hexdigest()[:16]
|
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||||
|
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||||
|
correlation_id=(payload.get('correlation_id') if isinstance(payload, dict) else None),
|
||||||
|
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||||
|
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||||
|
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||||
|
)
|
||||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||||
self._write("pending", event.to_dict())
|
self._write("pending", event.to_dict())
|
||||||
self._audit("created", event.to_dict())
|
self._audit("created", event.to_dict())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class AgentEventObservabilityBridge:
|
|||||||
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
||||||
self.hooks = hooks or AgentObservabilityHooks()
|
self.hooks = hooks or AgentObservabilityHooks()
|
||||||
|
|
||||||
def consume(self, event: dict):
|
def consume(self, event: dict, *, actor=None, trace=None):
|
||||||
kind = event.get("kind")
|
kind = event.get("kind")
|
||||||
payload = event.get("payload", {})
|
payload = event.get("payload", {})
|
||||||
if kind == "agent_started":
|
if kind == "agent_started":
|
||||||
@@ -16,6 +16,8 @@ class AgentEventObservabilityBridge:
|
|||||||
visible_scope=payload.get("scope", "self"),
|
visible_scope=payload.get("scope", "self"),
|
||||||
entry_agent=payload.get("agent", "unknown"),
|
entry_agent=payload.get("agent", "unknown"),
|
||||||
execution_id=event.get("id"),
|
execution_id=event.get("id"),
|
||||||
|
actor=actor,
|
||||||
|
trace=trace,
|
||||||
)
|
)
|
||||||
if kind == "agent_delegated":
|
if kind == "agent_delegated":
|
||||||
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ class AgentExecutionRecord:
|
|||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
started_at: str = field(default_factory=utc_now)
|
started_at: str = field(default_factory=utc_now)
|
||||||
ended_at: str = field(default_factory=utc_now)
|
ended_at: str = field(default_factory=utc_now)
|
||||||
|
trace_id: str | None = None
|
||||||
|
span_id: str | None = None
|
||||||
|
parent_span_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
claim_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|||||||
@@ -9,25 +9,49 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord
|
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||||
from .agent_observability_store import AgentObservabilityStore
|
from .agent_observability_store import AgentObservabilityStore
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext, authorize_scope
|
||||||
|
|
||||||
store = AgentObservabilityStore()
|
store = AgentObservabilityStore()
|
||||||
dashboard = AgentObservabilityDashboard(store.load())
|
dashboard = AgentObservabilityDashboard(store.load())
|
||||||
|
|
||||||
|
|
||||||
def record_agent_execution(payload: dict[str, Any]):
|
def record_agent_execution(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None):
|
||||||
record = AgentExecutionRecord(**payload)
|
scoped = any(payload.get(name) is not None for name in (
|
||||||
|
"trace_id", "span_id", "tenant_id", "project_id", "conversation_id", "task_id", "run_id"
|
||||||
|
))
|
||||||
|
values = dict(payload)
|
||||||
|
if scoped:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||||
|
trusted = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(trusted, trace.scope)
|
||||||
|
bound = {
|
||||||
|
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||||
|
"parent_span_id": trace.parent_span_id, "correlation_id": trace.correlation_id,
|
||||||
|
"causation_id": trace.causation_id, "tenant_id": trace.scope.tenant_id,
|
||||||
|
"user_id": trace.scope.user_id, "project_id": trace.scope.project_id,
|
||||||
|
"conversation_id": trace.scope.conversation_id, "task_id": trace.task_id,
|
||||||
|
"claim_id": trace.claim_id, "run_id": trace.scope.run_id, "message_id": trace.message_id,
|
||||||
|
}
|
||||||
|
for name, authoritative in bound.items():
|
||||||
|
if values.get(name) not in (None, authoritative):
|
||||||
|
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:" + name)
|
||||||
|
values[name] = authoritative
|
||||||
|
record = AgentExecutionRecord(**values)
|
||||||
return {"status": "ok", "record": store.append(record)}
|
return {"status": "ok", "record": store.append(record)}
|
||||||
|
|
||||||
|
|
||||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None):
|
||||||
records = store.query(user_id, group_id, role)
|
records = store.query(user_id, group_id, role, actor=actor)
|
||||||
|
visible = AgentObservability()
|
||||||
|
visible.records = [AgentExecutionRecord(**record) for record in records]
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"records": records,
|
"records": records,
|
||||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
"summary": visible.summary(None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord
|
from .agent_observability import AgentExecutionRecord
|
||||||
from .agent_observability_store import AgentObservabilityStore
|
from .agent_observability_store import AgentObservabilityStore
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
TraceContext,
|
||||||
|
authorize_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -36,6 +41,7 @@ class AgentRunContext:
|
|||||||
input_tokens: int = 0
|
input_tokens: int = 0
|
||||||
output_tokens: int = 0
|
output_tokens: int = 0
|
||||||
status: str = "running"
|
status: str = "running"
|
||||||
|
trace: TraceContext | None = None
|
||||||
|
|
||||||
def delegate(self, agent_name: str) -> None:
|
def delegate(self, agent_name: str) -> None:
|
||||||
if agent_name not in self.path:
|
if agent_name not in self.path:
|
||||||
@@ -59,7 +65,19 @@ class AgentObservabilityHooks:
|
|||||||
visible_scope: str,
|
visible_scope: str,
|
||||||
entry_agent: str,
|
entry_agent: str,
|
||||||
execution_id: str | None = None,
|
execution_id: str | None = None,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||||
|
trace: TraceContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
if execution_id is not None and execution_id in self.active:
|
||||||
|
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||||
|
if trace is not None:
|
||||||
|
if actor is None:
|
||||||
|
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||||
|
trusted_actor = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(trusted_actor, trace.scope)
|
||||||
|
if trace.scope.user_id is not None and trace.scope.user_id != user_id:
|
||||||
|
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:user_id")
|
||||||
run = AgentRunContext(
|
run = AgentRunContext(
|
||||||
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -67,6 +85,7 @@ class AgentObservabilityHooks:
|
|||||||
entry_agent=entry_agent,
|
entry_agent=entry_agent,
|
||||||
path=[entry_agent],
|
path=[entry_agent],
|
||||||
used_hermes=entry_agent.lower() == "hermes",
|
used_hermes=entry_agent.lower() == "hermes",
|
||||||
|
trace=trace,
|
||||||
)
|
)
|
||||||
self.active[run.execution_id] = run
|
self.active[run.execution_id] = run
|
||||||
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
||||||
@@ -106,6 +125,18 @@ class AgentObservabilityHooks:
|
|||||||
status=status,
|
status=status,
|
||||||
started_at=run.started_at,
|
started_at=run.started_at,
|
||||||
ended_at=ended_at,
|
ended_at=ended_at,
|
||||||
|
trace_id=run.trace.trace_id if run.trace else None,
|
||||||
|
span_id=run.trace.span_id if run.trace else None,
|
||||||
|
parent_span_id=run.trace.parent_span_id if run.trace else None,
|
||||||
|
correlation_id=run.trace.correlation_id if run.trace else None,
|
||||||
|
causation_id=run.trace.causation_id if run.trace else None,
|
||||||
|
tenant_id=run.trace.scope.tenant_id if run.trace else None,
|
||||||
|
project_id=run.trace.scope.project_id if run.trace else None,
|
||||||
|
conversation_id=run.trace.scope.conversation_id if run.trace else None,
|
||||||
|
task_id=run.trace.task_id if run.trace else None,
|
||||||
|
claim_id=run.trace.claim_id if run.trace else None,
|
||||||
|
run_id=run.trace.scope.run_id if run.trace else None,
|
||||||
|
message_id=run.trace.message_id if run.trace else None,
|
||||||
)
|
)
|
||||||
return {"status": "stored", "record": self.store.append(record)}
|
return {"status": "stored", "record": self.store.append(record)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceError,
|
||||||
|
GovernanceScope,
|
||||||
|
authorize_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentObservabilityStore:
|
class AgentObservabilityStore:
|
||||||
@@ -23,6 +29,11 @@ class AgentObservabilityStore:
|
|||||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||||
path = self.records_dir / f"{record.execution_id}.json"
|
path = self.records_dir / f"{record.execution_id}.json"
|
||||||
data = record.to_dict()
|
data = record.to_dict()
|
||||||
|
if path.exists():
|
||||||
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if existing == data:
|
||||||
|
return existing
|
||||||
|
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -33,8 +44,36 @@ class AgentObservabilityStore:
|
|||||||
observability.records.append(AgentExecutionRecord(**raw))
|
observability.records.append(AgentExecutionRecord(**raw))
|
||||||
return observability
|
return observability
|
||||||
|
|
||||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
|
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
records = [record.to_dict() for record in self.load().records]
|
records = [record.to_dict() for record in self.load().records]
|
||||||
|
scoped = [record for record in records if record.get("tenant_id") is not None]
|
||||||
|
legacy = [record for record in records if record.get("tenant_id") is None]
|
||||||
|
if actor is not None:
|
||||||
|
trusted = GovernanceActor.from_value(actor)
|
||||||
|
visible: list[dict[str, Any]] = []
|
||||||
|
for record in scoped:
|
||||||
|
try:
|
||||||
|
authorize_scope(
|
||||||
|
trusted,
|
||||||
|
GovernanceScope(
|
||||||
|
"tenant",
|
||||||
|
tenant_id=record.get("tenant_id"),
|
||||||
|
user_id=record.get("user_id"),
|
||||||
|
project_id=record.get("project_id"),
|
||||||
|
conversation_id=record.get("conversation_id"),
|
||||||
|
run_id=record.get("run_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except GovernanceError:
|
||||||
|
continue
|
||||||
|
visible.append(record)
|
||||||
|
if trusted.privileged:
|
||||||
|
visible.extend(legacy)
|
||||||
|
else:
|
||||||
|
visible.extend(record for record in legacy if record.get("user_id") == trusted.scope.user_id)
|
||||||
|
return visible
|
||||||
|
# Caller-supplied role remains a legacy compatibility path only.
|
||||||
|
records = legacy
|
||||||
if role == "owner":
|
if role == "owner":
|
||||||
return records
|
return records
|
||||||
if role == "group_admin" and group_id:
|
if role == "group_admin" and group_id:
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Owner-controlled, CAS-bound retry extension for exhausted Online Agent Tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||||
|
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
AUTHORIZE_EXTENSION_ID_ENV = "AIOS_AGENT_TASK_AUTHORIZATION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionError(RuntimeError):
|
||||||
|
def __init__(self, code: str, status: int = 409) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_authorization_id() -> str:
|
||||||
|
value = os.environ.get(AUTHORIZE_EXTENSION_ID_ENV, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_MISSING")
|
||||||
|
if KEY_RE.fullmatch(value) is None:
|
||||||
|
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise RetryExtensionError(code, 400)
|
||||||
|
value = value.strip()
|
||||||
|
if pattern is not None and pattern.fullmatch(value) is None:
|
||||||
|
raise RetryExtensionError(code, 400)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetryExtensionRequest:
|
||||||
|
expected_status: str
|
||||||
|
expected_revision: int
|
||||||
|
expected_attempt: int
|
||||||
|
expected_max_attempts: int
|
||||||
|
next_max_attempts: int
|
||||||
|
previous_verification_sha256: str
|
||||||
|
authorization_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "RetryExtensionRequest":
|
||||||
|
allowed = {
|
||||||
|
"expected_status", "expected_revision", "expected_attempt", "expected_max_attempts",
|
||||||
|
"next_max_attempts", "previous_verification_sha256", "authorization_id", "idempotency_key",
|
||||||
|
}
|
||||||
|
if set(value) != allowed:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_FIELDS_INVALID", 400)
|
||||||
|
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||||
|
if status != "FAILED":
|
||||||
|
raise RetryExtensionError("EXPECTED_STATUS_INVALID", 400)
|
||||||
|
ints: dict[str, int] = {}
|
||||||
|
for field in ("expected_revision", "expected_attempt", "expected_max_attempts", "next_max_attempts"):
|
||||||
|
item = value[field]
|
||||||
|
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_INTEGER_INVALID", 400)
|
||||||
|
ints[field] = item
|
||||||
|
if ints["expected_attempt"] != ints["expected_max_attempts"]:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_NOT_EXHAUSTED", 409)
|
||||||
|
if ints["next_max_attempts"] != ints["expected_max_attempts"] + 1:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP", 400)
|
||||||
|
if ints["next_max_attempts"] > 50:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_LIMIT_EXCEEDED", 400)
|
||||||
|
verification = _require_string(value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID")
|
||||||
|
if re.fullmatch(r"[0-9a-f]{64}", verification) is None:
|
||||||
|
raise RetryExtensionError("VERIFICATION_SHA256_INVALID", 400)
|
||||||
|
authorization = _require_string(value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE)
|
||||||
|
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
||||||
|
return cls(
|
||||||
|
status,
|
||||||
|
ints["expected_revision"],
|
||||||
|
ints["expected_attempt"],
|
||||||
|
ints["expected_max_attempts"],
|
||||||
|
ints["next_max_attempts"],
|
||||||
|
verification,
|
||||||
|
authorization,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"expected_status": self.expected_status,
|
||||||
|
"expected_revision": self.expected_revision,
|
||||||
|
"expected_attempt": self.expected_attempt,
|
||||||
|
"expected_max_attempts": self.expected_max_attempts,
|
||||||
|
"next_max_attempts": self.next_max_attempts,
|
||||||
|
"previous_verification_sha256": self.previous_verification_sha256,
|
||||||
|
"authorization_id": self.authorization_id,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionStore:
|
||||||
|
"""Owns the official mutation. Direct/manual DB edits remain forbidden."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
||||||
|
return {str(row["name"]): row for row in connection.execute(f'PRAGMA table_info("{table}")')}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_table(connection: sqlite3.Connection) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||||
|
tables = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
for name in ("task_events", "events"):
|
||||||
|
if name not in tables:
|
||||||
|
continue
|
||||||
|
columns = RetryExtensionStore._columns(connection, name)
|
||||||
|
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
||||||
|
return name, columns
|
||||||
|
raise RetryExtensionError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verification_hash(raw: object) -> str:
|
||||||
|
if raw in (None, ""):
|
||||||
|
value: object = {}
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RetryExtensionError("TASK_VERIFICATION_INVALID", 409) from exc
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
return _sha(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_extension_table(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_retry_extensions (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
authorization_id TEXT NOT NULL,
|
||||||
|
previous_verification_sha256 TEXT NOT NULL,
|
||||||
|
previous_max_attempts INTEGER NOT NULL,
|
||||||
|
next_max_attempts INTEGER NOT NULL,
|
||||||
|
expected_revision INTEGER NOT NULL,
|
||||||
|
applied_revision INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(task_id, idempotency_key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _insert_event(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
task_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
table, columns = RetryExtensionStore._event_table(connection)
|
||||||
|
values: dict[str, Any] = {"task_id": task_id}
|
||||||
|
if "event_type" in columns:
|
||||||
|
values["event_type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||||
|
else:
|
||||||
|
values["type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||||
|
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
if "payload_json" in columns:
|
||||||
|
values["payload_json"] = payload_text
|
||||||
|
elif "payload" in columns:
|
||||||
|
values["payload"] = payload_text
|
||||||
|
else:
|
||||||
|
raise RetryExtensionError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||||
|
if "created_at" in columns:
|
||||||
|
values["created_at"] = _now()
|
||||||
|
if "agent_id" in columns:
|
||||||
|
values["agent_id"] = "owner-control"
|
||||||
|
if "actor_id" in columns:
|
||||||
|
values["actor_id"] = "owner-control"
|
||||||
|
names = list(values)
|
||||||
|
sql = f'INSERT INTO "{table}" ({",".join(f"""\"{name}\"""" for name in names)}) VALUES ({",".join("?" for _ in names)})'
|
||||||
|
connection.execute(sql, tuple(values[name] for name in names))
|
||||||
|
|
||||||
|
def extend(self, task_id: str, request: RetryExtensionRequest) -> dict[str, Any]:
|
||||||
|
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||||
|
required_authorization_id = _required_authorization_id()
|
||||||
|
if request.authorization_id != required_authorization_id:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH", 403)
|
||||||
|
request_payload = request.public()
|
||||||
|
request_sha = _sha(request_payload)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
self._ensure_extension_table(connection)
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM task_retry_extensions WHERE task_id=? AND idempotency_key=?",
|
||||||
|
(task_id, request.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_IDEMPOTENCY_CONFLICT", 409)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"max_attempts": int(replay["next_max_attempts"]),
|
||||||
|
"revision": int(replay["applied_revision"]),
|
||||||
|
"idempotent": True,
|
||||||
|
"authorization_id": replay["authorization_id"],
|
||||||
|
"previous_verification_sha256": replay["previous_verification_sha256"],
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = self._columns(connection, "tasks")
|
||||||
|
required = {
|
||||||
|
"task_id", "status", "attempt", "max_attempts", "revision", "claimed_by",
|
||||||
|
"lease_token_hash", "lease_expires_at", "verification_json",
|
||||||
|
}
|
||||||
|
if not required.issubset(columns):
|
||||||
|
raise RetryExtensionError("TASK_SCHEMA_RETRY_EXTENSION_UNSUPPORTED", 503)
|
||||||
|
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||||
|
if task is None:
|
||||||
|
raise RetryExtensionError("TASK_NOT_FOUND", 404)
|
||||||
|
actual_verification_sha = self._verification_hash(task["verification_json"])
|
||||||
|
if actual_verification_sha != request.previous_verification_sha256:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_VERIFICATION_DRIFT", 409)
|
||||||
|
if (
|
||||||
|
task["status"] != request.expected_status
|
||||||
|
or int(task["revision"]) != request.expected_revision
|
||||||
|
or int(task["attempt"]) != request.expected_attempt
|
||||||
|
or int(task["max_attempts"]) != request.expected_max_attempts
|
||||||
|
):
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||||
|
for lease_field in ("lease_token_hash", "lease_expires_at"):
|
||||||
|
if lease_field in columns and task[lease_field] not in (None, ""):
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_ACTIVE_LEASE_DENY", 409)
|
||||||
|
|
||||||
|
previous_claimed_by = task["claimed_by"]
|
||||||
|
applied_revision = request.expected_revision + 1
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tasks
|
||||||
|
SET status='QUEUED', max_attempts=?, revision=?, claimed_by=NULL,
|
||||||
|
lease_token_hash=NULL, lease_expires_at=NULL
|
||||||
|
WHERE task_id=? AND status=? AND revision=? AND attempt=? AND max_attempts=?
|
||||||
|
AND claimed_by IS ?
|
||||||
|
AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
request.next_max_attempts,
|
||||||
|
applied_revision,
|
||||||
|
task_id,
|
||||||
|
request.expected_status,
|
||||||
|
request.expected_revision,
|
||||||
|
request.expected_attempt,
|
||||||
|
request.expected_max_attempts,
|
||||||
|
previous_claimed_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||||
|
|
||||||
|
event_payload = {
|
||||||
|
"schema": "aios.task-retry-extension.event.v1",
|
||||||
|
"task_id": task_id,
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
"previous_verification_sha256": request.previous_verification_sha256,
|
||||||
|
"previous_max_attempts": request.expected_max_attempts,
|
||||||
|
"next_max_attempts": request.next_max_attempts,
|
||||||
|
"previous_revision": request.expected_revision,
|
||||||
|
"applied_revision": applied_revision,
|
||||||
|
"request_sha256": request_sha,
|
||||||
|
"previous_claimed_by": previous_claimed_by,
|
||||||
|
"stale_terminal_claim_normalized": previous_claimed_by not in (None, ""),
|
||||||
|
}
|
||||||
|
self._insert_event(connection, task_id, event_payload)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO task_retry_extensions(
|
||||||
|
task_id,idempotency_key,request_sha256,authorization_id,previous_verification_sha256,
|
||||||
|
previous_max_attempts,next_max_attempts,expected_revision,applied_revision,created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id,
|
||||||
|
request.idempotency_key,
|
||||||
|
request_sha,
|
||||||
|
request.authorization_id,
|
||||||
|
request.previous_verification_sha256,
|
||||||
|
request.expected_max_attempts,
|
||||||
|
request.next_max_attempts,
|
||||||
|
request.expected_revision,
|
||||||
|
applied_revision,
|
||||||
|
_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "QUEUED",
|
||||||
|
"attempt": request.expected_attempt,
|
||||||
|
"max_attempts": request.next_max_attempts,
|
||||||
|
"revision": applied_revision,
|
||||||
|
"idempotent": False,
|
||||||
|
"authorization_id": request.authorization_id,
|
||||||
|
"previous_verification_sha256": request.previous_verification_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RetryExtensionHandler(BaseHTTPRequestHandler):
|
||||||
|
store: RetryExtensionStore | None = None
|
||||||
|
control_token: str = ""
|
||||||
|
|
||||||
|
def log_message(self, *_args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
||||||
|
data = json.dumps(dict(value), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return (
|
||||||
|
header.startswith("Bearer ")
|
||||||
|
and bool(self.control_token)
|
||||||
|
and hmac.compare_digest(header[7:], self.control_token)
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health":
|
||||||
|
self._json({"status": "ok", "service": "aios-agent-task-retry-extension"}, 200)
|
||||||
|
else:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
match = re.fullmatch(r"/v1/tasks/([^/]+)/retry-extension", self.path)
|
||||||
|
if match is None:
|
||||||
|
self._json({"error": "NOT_FOUND"}, 404)
|
||||||
|
return
|
||||||
|
if not self._authorized():
|
||||||
|
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
||||||
|
return
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError
|
||||||
|
request = RetryExtensionRequest.from_mapping(value)
|
||||||
|
if self.store is None:
|
||||||
|
raise RetryExtensionError("RUNTIME_UNAVAILABLE", 503)
|
||||||
|
result = self.store.extend(match.group(1), request)
|
||||||
|
self._json(result, 200 if result["idempotent"] else 201)
|
||||||
|
except RetryExtensionError as exc:
|
||||||
|
self._json({"error": exc.code}, exc.status)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
|
self._json({"error": "REQUEST_INVALID"}, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def load_control_token(path: Path) -> str:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||||
|
_required_authorization_id()
|
||||||
|
token_path = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||||
|
str(Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials")) / "agent_task_control_token"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
host = os.environ.get("AIOS_TASK_RETRY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("AIOS_TASK_RETRY_PORT", "18941"))
|
||||||
|
RetryExtensionHandler.store = RetryExtensionStore(db)
|
||||||
|
RetryExtensionHandler.control_token = load_control_token(token_path)
|
||||||
|
ThreadingHTTPServer((host, port), RetryExtensionHandler).serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
|
import re
|
||||||
from typing import Any, Callable, Mapping
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
|
||||||
@@ -21,9 +22,14 @@ class CredentialLease:
|
|||||||
scopes: tuple[str, ...]
|
scopes: tuple[str, ...]
|
||||||
provider: str
|
provider: str
|
||||||
state: str = "ACTIVE"
|
state: str = "ACTIVE"
|
||||||
|
scope_type: str = "system"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state, "scope_type": self.scope_type, "tenant_id": self.tenant_id, "user_id": self.user_id, "project_id": self.project_id, "run_id": self.run_id}
|
||||||
|
|
||||||
|
|
||||||
class FileCredentialProvider:
|
class FileCredentialProvider:
|
||||||
@@ -43,11 +49,19 @@ class CredentialBroker:
|
|||||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||||
self._leases: dict[str, CredentialLease] = {}
|
self._leases: dict[str, CredentialLease] = {}
|
||||||
|
|
||||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> dict[str, Any]:
|
||||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||||
|
if scope_type == "tenant":
|
||||||
|
if not tenant_id:
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
elif scope_type in {"system", "global"}:
|
||||||
|
if any((tenant_id, user_id, project_id, run_id)):
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
else:
|
||||||
|
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||||
now = self.now().astimezone(timezone.utc)
|
now = self.now().astimezone(timezone.utc)
|
||||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__, "ACTIVE", scope_type, tenant_id, user_id, project_id, run_id)
|
||||||
self._leases[lease.lease_id] = lease
|
self._leases[lease.lease_id] = lease
|
||||||
return lease.as_dict()
|
return lease.as_dict()
|
||||||
|
|
||||||
@@ -64,7 +78,39 @@ class CredentialBroker:
|
|||||||
lease = self._get(lease_id)
|
lease = self._get(lease_id)
|
||||||
if lease.state != "ACTIVE":
|
if lease.state != "ACTIVE":
|
||||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||||
return self.provider.environment(lease)
|
environment = self.provider.environment(lease)
|
||||||
|
for key, value in environment.items():
|
||||||
|
upper = str(key).upper()
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
sensitive = any(marker in upper for marker in ("CREDENTIAL", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY", "API_KEY"))
|
||||||
|
if re.search(r"(?i)bearer\s+\S+|(?:token|secret|password|api[_-]?key)\s*[:=]", value):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if sensitive and upper.endswith("_FILE"):
|
||||||
|
candidate = Path(value)
|
||||||
|
credential_root = Path("/run/credentials")
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=False)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY") from exc
|
||||||
|
if not candidate.is_absolute() or not (resolved == credential_root or credential_root in resolved.parents):
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
elif sensitive and upper.endswith("_REFERENCE"):
|
||||||
|
if re.fullmatch(r"(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}", value) is None:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
elif sensitive:
|
||||||
|
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
return environment
|
||||||
|
|
||||||
|
def authorize(self, lease_id: str, *, task_id: str, operation: str, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> Mapping[str, str]:
|
||||||
|
lease = self._get(lease_id)
|
||||||
|
if lease.state != "ACTIVE" or lease.task_id != task_id or operation not in lease.scopes:
|
||||||
|
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
requested = (scope_type, tenant_id, user_id, project_id, run_id)
|
||||||
|
actual = (lease.scope_type, lease.tenant_id, lease.user_id, lease.project_id, lease.run_id)
|
||||||
|
if requested != actual:
|
||||||
|
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
return self.environment(lease_id)
|
||||||
|
|
||||||
def status(self, lease_id: str) -> dict[str, Any]:
|
def status(self, lease_id: str) -> dict[str, Any]:
|
||||||
return self._get(lease_id).as_dict()
|
return self._get(lease_id).as_dict()
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
||||||
|
|
||||||
|
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
||||||
|
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import errno
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
||||||
|
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||||
|
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
||||||
|
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
||||||
|
SENSITIVE_VALUE_RE = re.compile(
|
||||||
|
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
||||||
|
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsolatedExecutorError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or not value or value.startswith("/"):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
path = Path(value)
|
||||||
|
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_under(root: Path, relative: str) -> Path:
|
||||||
|
root = root.resolve()
|
||||||
|
path = (root / safe_relative(relative)).resolve(strict=False)
|
||||||
|
if path == root or root not in path.parents:
|
||||||
|
raise IsolatedExecutorError("PATH_ESCAPE")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_exposure(directory: Path) -> str | None:
|
||||||
|
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
||||||
|
try:
|
||||||
|
if not directory.exists():
|
||||||
|
return None
|
||||||
|
if directory.is_symlink():
|
||||||
|
return "CREDENTIAL_MOUNT_SYMLINK"
|
||||||
|
if os.access(directory, os.R_OK):
|
||||||
|
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
||||||
|
if directory.is_dir():
|
||||||
|
for child in directory.iterdir():
|
||||||
|
if child.is_file() and os.access(child, os.R_OK):
|
||||||
|
return "CREDENTIAL_FILE_ACCESSIBLE"
|
||||||
|
except PermissionError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assert_uncredentialed() -> None:
|
||||||
|
credentials = Path("/run/credentials")
|
||||||
|
candidates = {credentials}
|
||||||
|
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
candidates.add(credentials_directory)
|
||||||
|
leaked = sorted(
|
||||||
|
key for key, value in os.environ.items()
|
||||||
|
if value
|
||||||
|
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
||||||
|
)
|
||||||
|
if leaked:
|
||||||
|
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
||||||
|
for directory in candidates:
|
||||||
|
exposure = _credential_exposure(directory)
|
||||||
|
if exposure is not None:
|
||||||
|
raise IsolatedExecutorError(exposure)
|
||||||
|
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
||||||
|
try:
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
||||||
|
return
|
||||||
|
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
||||||
|
probe.settimeout(0.15)
|
||||||
|
try:
|
||||||
|
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
||||||
|
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
||||||
|
finally:
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_source(archive: Path, root: Path) -> None:
|
||||||
|
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
||||||
|
with tarfile.open(archive, "r:gz") as tf:
|
||||||
|
for member in tf.getmembers():
|
||||||
|
path = Path(member.name)
|
||||||
|
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
||||||
|
tf.extractall(root, filter="data")
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
||||||
|
argv = item.get("argv")
|
||||||
|
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
||||||
|
raise IsolatedExecutorError("COMMAND_INVALID")
|
||||||
|
argv = [str(part) for part in argv]
|
||||||
|
if argv[0] not in ALLOWED_PROGRAMS:
|
||||||
|
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
||||||
|
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
||||||
|
index = argv.index("-m")
|
||||||
|
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||||
|
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
||||||
|
if argv[0] == "/usr/bin/git" and any(
|
||||||
|
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
||||||
|
for token in argv[1:]
|
||||||
|
):
|
||||||
|
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
||||||
|
timeout = int(item.get("timeout_seconds") or 300)
|
||||||
|
if not 1 <= timeout <= 1800:
|
||||||
|
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
||||||
|
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
||||||
|
started = time.monotonic()
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=timeout,
|
||||||
|
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"label": str(item.get("label") or "command"),
|
||||||
|
"exit_code": completed.returncode,
|
||||||
|
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
||||||
|
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||||
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||||
|
}
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise IsolatedExecutorError(
|
||||||
|
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
||||||
|
changes = request.get("change_set")
|
||||||
|
if not isinstance(changes, list) or not changes:
|
||||||
|
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
||||||
|
changed: list[str] = []
|
||||||
|
for change in changes:
|
||||||
|
if not isinstance(change, Mapping):
|
||||||
|
raise IsolatedExecutorError("CHANGE_INVALID")
|
||||||
|
relative = safe_relative(change.get("path"))
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
current = path.read_bytes() if path.is_file() else None
|
||||||
|
expected = change.get("expected_sha256")
|
||||||
|
if expected is not None and (current is None or sha256(current) != expected):
|
||||||
|
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
||||||
|
operation = change.get("operation")
|
||||||
|
if operation == "write_file":
|
||||||
|
content = change.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
elif operation == "replace_text":
|
||||||
|
if current is None:
|
||||||
|
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
||||||
|
old = str(change.get("old"))
|
||||||
|
new = str(change.get("new"))
|
||||||
|
count = int(change.get("count", 1))
|
||||||
|
text = current.decode("utf-8")
|
||||||
|
if text.count(old) != count:
|
||||||
|
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
||||||
|
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||||
|
elif operation == "delete_file":
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
||||||
|
changed.append(relative)
|
||||||
|
return sorted(set(changed))
|
||||||
|
|
||||||
|
|
||||||
|
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
||||||
|
files = []
|
||||||
|
for relative in changed:
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
if path.exists():
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
||||||
|
files.append({
|
||||||
|
"path": relative,
|
||||||
|
"deleted": False,
|
||||||
|
"sha256": sha256(data),
|
||||||
|
"content_b64": base64.b64encode(data).decode("ascii"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def execute_request(request_path: Path, result_path: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||||
|
if request.get("schema") != "aios.isolated-execution.request.v1":
|
||||||
|
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
||||||
|
request_id = request.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
||||||
|
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
||||||
|
source_archive = Path(str(request.get("source_archive") or ""))
|
||||||
|
if not source_archive.is_absolute():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
||||||
|
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
||||||
|
root = Path(temporary) / "workspace"
|
||||||
|
root.mkdir()
|
||||||
|
extract_source(source_archive, root)
|
||||||
|
changed = apply_changes(request, root)
|
||||||
|
commands = [
|
||||||
|
run_command(item, root)
|
||||||
|
for item in request.get("verification", [])
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"schema": "aios.isolated-execution.result.v1",
|
||||||
|
"request_id": request_id,
|
||||||
|
"request_sha256": sha256(canonical(request)),
|
||||||
|
"changed_files": changed_bundle(root, changed),
|
||||||
|
"verification": commands,
|
||||||
|
"credential_isolation": {
|
||||||
|
"credential_env_absent": True,
|
||||||
|
"credential_mount_inaccessible": True,
|
||||||
|
"governed_bridge_unreachable": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result["result_sha256"] = sha256(canonical(result))
|
||||||
|
temporary_result = result_path.with_suffix(".tmp")
|
||||||
|
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||||
|
os.replace(temporary_result, result_path)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(root: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
requests = root / "requests"
|
||||||
|
results = root / "results"
|
||||||
|
failed = root / "failed"
|
||||||
|
for path in (requests, results, failed):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
while True:
|
||||||
|
for request in sorted(requests.glob("*.json")):
|
||||||
|
if request.is_symlink():
|
||||||
|
continue
|
||||||
|
result = results / request.name
|
||||||
|
try:
|
||||||
|
execute_request(request, result)
|
||||||
|
request.unlink()
|
||||||
|
except Exception as exc:
|
||||||
|
(failed / request.name).write_text(
|
||||||
|
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
request.unlink(missing_ok=True)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
||||||
|
parser.add_argument("--request")
|
||||||
|
parser.add_argument("--result")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.request and args.result:
|
||||||
|
execute_request(Path(args.request), Path(args.result))
|
||||||
|
else:
|
||||||
|
serve(Path(args.root))
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Durable checkpoint journal for the generic Online Agent worker.
|
||||||
|
|
||||||
|
The JSON task state remains a fast cache. This SQLite journal is the durable,
|
||||||
|
append-only evidence used for restart/resume/replay decisions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
PHASE_ORDER = {
|
||||||
|
"claimed": 10,
|
||||||
|
"workspace_prepared": 20,
|
||||||
|
"verification_passed": 30,
|
||||||
|
"source_capture_done": 40,
|
||||||
|
"commit_done": 40,
|
||||||
|
"reconciliation_required": 45,
|
||||||
|
"result_pending": 50,
|
||||||
|
"result_submitted": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_json(value: object) -> str:
|
||||||
|
return hashlib.sha256(canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CheckpointBinding:
|
||||||
|
task_id: str
|
||||||
|
attempt: int
|
||||||
|
execution_digest: str
|
||||||
|
job_id: str
|
||||||
|
repository: str
|
||||||
|
base_oid: str
|
||||||
|
expected_branch_oid: str
|
||||||
|
target_branch: str
|
||||||
|
generation: int
|
||||||
|
|
||||||
|
def material(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"attempt": self.attempt,
|
||||||
|
"execution_digest": self.execution_digest,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"repository": self.repository,
|
||||||
|
"base_oid": self.base_oid,
|
||||||
|
"expected_branch_oid": self.expected_branch_oid,
|
||||||
|
"target_branch": self.target_branch,
|
||||||
|
"generation": self.generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binding_sha256(self) -> str:
|
||||||
|
return sha256_json(self.material())
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointStore:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints(
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
attempt INTEGER NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
phase_order INTEGER NOT NULL,
|
||||||
|
execution_digest TEXT NOT NULL,
|
||||||
|
job_id TEXT NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
base_oid TEXT NOT NULL,
|
||||||
|
expected_branch_oid TEXT NOT NULL,
|
||||||
|
target_branch TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
binding_sha256 TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(task_id, attempt, sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checkpoints_task_latest
|
||||||
|
ON checkpoints(task_id, attempt DESC, sequence DESC);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_exact_replay
|
||||||
|
ON checkpoints(task_id, attempt, phase, payload_sha256);
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoint_heads(
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
attempt INTEGER NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
phase_order INTEGER NOT NULL,
|
||||||
|
execution_digest TEXT NOT NULL,
|
||||||
|
binding_sha256 TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _phase_order(phase: str) -> int:
|
||||||
|
try:
|
||||||
|
return PHASE_ORDER[phase]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise CheckpointError("CHECKPOINT_PHASE_INVALID") from exc
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
binding: CheckpointBinding,
|
||||||
|
phase: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
order = self._phase_order(phase)
|
||||||
|
payload_value = dict(payload)
|
||||||
|
payload_sha = sha256_json(payload_value)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
replay = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=? AND attempt=? AND phase=? AND payload_sha256=?
|
||||||
|
""",
|
||||||
|
(binding.task_id, binding.attempt, phase, payload_sha),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
connection.commit()
|
||||||
|
return dict(replay)
|
||||||
|
|
||||||
|
head = connection.execute(
|
||||||
|
"SELECT * FROM checkpoint_heads WHERE task_id=?",
|
||||||
|
(binding.task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if head is not None:
|
||||||
|
head_attempt = int(head["attempt"])
|
||||||
|
head_order = int(head["phase_order"])
|
||||||
|
if binding.attempt < head_attempt:
|
||||||
|
raise CheckpointError("CHECKPOINT_ATTEMPT_REGRESSION")
|
||||||
|
if binding.attempt == head_attempt:
|
||||||
|
if head["execution_digest"] != binding.execution_digest:
|
||||||
|
raise CheckpointError("CHECKPOINT_EXECUTION_DIGEST_CONFLICT")
|
||||||
|
if head["binding_sha256"] != binding.binding_sha256:
|
||||||
|
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||||
|
if order < head_order and phase != "reconciliation_required":
|
||||||
|
raise CheckpointError("CHECKPOINT_PHASE_REGRESSION")
|
||||||
|
sequence = int(head["sequence"]) + 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
|
||||||
|
created_at = now()
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO checkpoints(
|
||||||
|
task_id,attempt,sequence,phase,phase_order,execution_digest,job_id,
|
||||||
|
repository,base_oid,expected_branch_oid,target_branch,generation,
|
||||||
|
binding_sha256,payload_json,payload_sha256,created_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
binding.task_id,
|
||||||
|
binding.attempt,
|
||||||
|
sequence,
|
||||||
|
phase,
|
||||||
|
order,
|
||||||
|
binding.execution_digest,
|
||||||
|
binding.job_id,
|
||||||
|
binding.repository,
|
||||||
|
binding.base_oid,
|
||||||
|
binding.expected_branch_oid,
|
||||||
|
binding.target_branch,
|
||||||
|
binding.generation,
|
||||||
|
binding.binding_sha256,
|
||||||
|
canonical(payload_value).decode("utf-8"),
|
||||||
|
payload_sha,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO checkpoint_heads(
|
||||||
|
task_id,attempt,sequence,phase,phase_order,execution_digest,
|
||||||
|
binding_sha256,payload_sha256,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(task_id) DO UPDATE SET
|
||||||
|
attempt=excluded.attempt,
|
||||||
|
sequence=excluded.sequence,
|
||||||
|
phase=excluded.phase,
|
||||||
|
phase_order=excluded.phase_order,
|
||||||
|
execution_digest=excluded.execution_digest,
|
||||||
|
binding_sha256=excluded.binding_sha256,
|
||||||
|
payload_sha256=excluded.payload_sha256,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
binding.task_id,
|
||||||
|
binding.attempt,
|
||||||
|
sequence,
|
||||||
|
phase,
|
||||||
|
order,
|
||||||
|
binding.execution_digest,
|
||||||
|
binding.binding_sha256,
|
||||||
|
payload_sha,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM checkpoints WHERE task_id=? AND attempt=? AND sequence=?",
|
||||||
|
(binding.task_id, binding.attempt, sequence),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def latest(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=?
|
||||||
|
ORDER BY attempt DESC, sequence DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
value = dict(row)
|
||||||
|
value["payload"] = json.loads(value.pop("payload_json"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def latest_for_attempt(self, task_id: str, attempt: int) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM checkpoints
|
||||||
|
WHERE task_id=? AND attempt=?
|
||||||
|
ORDER BY sequence DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(task_id, attempt),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
value = dict(row)
|
||||||
|
value["payload"] = json.loads(value.pop("payload_json"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def verify_latest_binding(self, binding: CheckpointBinding) -> dict[str, Any] | None:
|
||||||
|
value = self.latest(binding.task_id)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if int(value["attempt"]) > binding.attempt:
|
||||||
|
raise CheckpointError("CHECKPOINT_FUTURE_ATTEMPT")
|
||||||
|
# A previous attempt may be reused only when the immutable execution/scope binding matches.
|
||||||
|
if value["execution_digest"] != binding.execution_digest:
|
||||||
|
return None
|
||||||
|
expected = CheckpointBinding(
|
||||||
|
task_id=value["task_id"],
|
||||||
|
attempt=int(value["attempt"]),
|
||||||
|
execution_digest=value["execution_digest"],
|
||||||
|
job_id=value["job_id"],
|
||||||
|
repository=value["repository"],
|
||||||
|
base_oid=value["base_oid"],
|
||||||
|
expected_branch_oid=value["expected_branch_oid"],
|
||||||
|
target_branch=value["target_branch"],
|
||||||
|
generation=int(value["generation"]),
|
||||||
|
)
|
||||||
|
current_scope = binding.material()
|
||||||
|
historical_scope = expected.material()
|
||||||
|
current_scope.pop("attempt", None)
|
||||||
|
historical_scope.pop("attempt", None)
|
||||||
|
if sha256_json(current_scope) != sha256_json(historical_scope):
|
||||||
|
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Canonical governed execution projection for Online Agent Tasks.
|
||||||
|
|
||||||
|
The durable governed binding is authoritative. Natural-language task fields and
|
||||||
|
worker configuration are never allowed to invent repository, ref, generation, or
|
||||||
|
idempotency coordinates.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||||
|
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||||
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||||
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
|
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||||
|
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||||
|
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||||
|
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture"})
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionProjectionError(RuntimeError):
|
||||||
|
"""A canonical fail-closed projection or validation error."""
|
||||||
|
|
||||||
|
|
||||||
|
class OnlineTaskProjectionClient:
|
||||||
|
"""Official HTTP projection client using only a control credential reference."""
|
||||||
|
|
||||||
|
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.control_token_path = Path(control_token_path)
|
||||||
|
|
||||||
|
def _token(self) -> str:
|
||||||
|
path = self.control_token_path
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
projected_task: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
binding: "GovernedExecutionBinding",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = dict(projected_task)
|
||||||
|
payload = _canonical(body).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self.api_base + "/v1/tasks",
|
||||||
|
method="POST",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + self._token(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read(8192).decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
error = json.loads(raw).get("error")
|
||||||
|
except (AttributeError, json.JSONDecodeError):
|
||||||
|
error = "HTTP_ERROR"
|
||||||
|
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||||
|
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||||
|
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||||
|
if status not in {200, 201} or not isinstance(result, dict):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
metadata = result.get("metadata")
|
||||||
|
if not isinstance(metadata, Mapping):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
returned_execution = validate_execution_envelope(
|
||||||
|
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||||
|
submitted_execution = validate_execution_envelope(
|
||||||
|
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _error(code: str) -> None:
|
||||||
|
raise ExecutionProjectionError(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: object, code: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
_error(code)
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: object, code: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
_error(code)
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_id(value: object) -> str:
|
||||||
|
value = _string(value, "TASK_ID_INVALID")
|
||||||
|
if TASK_RE.fullmatch(value) is None:
|
||||||
|
_error("TASK_ID_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _oid(value: object, code: str) -> str:
|
||||||
|
value = _string(value, code)
|
||||||
|
if OID_RE.fullmatch(value) is None:
|
||||||
|
_error(code)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _branch(value: object) -> str:
|
||||||
|
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||||
|
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||||
|
_error("TARGET_BRANCH_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _generation(value: object) -> int:
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||||
|
_error("GENERATION_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_signals(value: object) -> tuple[str, ...]:
|
||||||
|
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
result: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
text = " ".join(item.split()).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||||
|
text = text[:512].rstrip()
|
||||||
|
if text not in result:
|
||||||
|
result.append(text)
|
||||||
|
if len(result) == 16:
|
||||||
|
break
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _capabilities(value: object) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
_error("CAPABILITY_SCOPE_INVALID")
|
||||||
|
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||||
|
if any(len(item) > 120 for item in result):
|
||||||
|
_error("CAPABILITY_SCOPE_INVALID")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernedExecutionBinding:
|
||||||
|
task_id: str
|
||||||
|
job_id: str
|
||||||
|
repository: str
|
||||||
|
base_oid: str
|
||||||
|
expected_branch_oid: str
|
||||||
|
target_branch: str
|
||||||
|
branch_mode: str
|
||||||
|
generation: int
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||||
|
data = dict(value)
|
||||||
|
task_id = _task_id(data.get("task_id"))
|
||||||
|
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||||
|
if TASK_RE.fullmatch(job_id) is None:
|
||||||
|
_error("JOB_ID_INVALID")
|
||||||
|
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||||
|
if repository != "aios/aios":
|
||||||
|
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||||
|
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||||
|
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||||
|
target = _branch(data.get("target_branch"))
|
||||||
|
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||||
|
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||||
|
_error("BRANCH_MODE_INVALID")
|
||||||
|
generation = _generation(data.get("generation"))
|
||||||
|
if mode == "CREATE_NEW" and expected != base_oid:
|
||||||
|
_error("EXPECTED_BASE_CONFLICT")
|
||||||
|
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if KEY_RE.fullmatch(key) is None:
|
||||||
|
_error("IDEMPOTENCY_KEY_INVALID")
|
||||||
|
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||||
|
return GovernedExecutionBinding.from_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_execution_envelope(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
binding: GovernedExecutionBinding | None = None,
|
||||||
|
worker_capabilities: Sequence[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
envelope = dict(value)
|
||||||
|
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||||
|
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||||
|
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||||
|
if binding is not None and candidate != binding:
|
||||||
|
comparisons = (
|
||||||
|
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||||
|
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||||
|
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||||
|
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||||
|
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||||
|
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||||
|
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||||
|
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||||
|
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||||
|
)
|
||||||
|
for actual, expected, code in comparisons:
|
||||||
|
if actual != expected:
|
||||||
|
_error(code)
|
||||||
|
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||||
|
if action not in SUPPORTED_ACTIONS:
|
||||||
|
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||||
|
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||||
|
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||||
|
_error("CAPABILITY_MISMATCH_DENY")
|
||||||
|
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||||
|
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||||
|
_error("WORKSPACE_POLICY_DENY")
|
||||||
|
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||||
|
governed_coordinates = (
|
||||||
|
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||||
|
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||||
|
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||||
|
)
|
||||||
|
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||||
|
if actual_coordinate != expected_coordinate:
|
||||||
|
_error(code)
|
||||||
|
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if KEY_RE.fullmatch(commit_key) is None:
|
||||||
|
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||||
|
_error("WRITER_LEASE_POLICY_DENY")
|
||||||
|
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||||
|
if governance.get(field, False) is not False:
|
||||||
|
_error("POLICY_DENY")
|
||||||
|
changes = envelope.get("change_set")
|
||||||
|
verification = envelope.get("verification")
|
||||||
|
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||||
|
_error("EXECUTION_OPERATIONS_INVALID")
|
||||||
|
if action == "source_capture":
|
||||||
|
if changes or verification:
|
||||||
|
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||||
|
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||||
|
if envelope.get("source_signals") != signals:
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
elif not changes:
|
||||||
|
_error("CHANGE_SET_INVALID")
|
||||||
|
declared = str(envelope.get("execution_digest") or "")
|
||||||
|
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||||
|
actual = _digest(material)
|
||||||
|
if declared != actual:
|
||||||
|
_error("EXECUTION_DIGEST_INVALID")
|
||||||
|
return {**material, "execution_digest": actual}
|
||||||
|
|
||||||
|
|
||||||
|
def project_execution_envelope(
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
required_capabilities: Sequence[str] = (),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request = dict(execution_request or {})
|
||||||
|
action = str(request.get("action") or "source_capture")
|
||||||
|
if action not in SUPPORTED_ACTIONS:
|
||||||
|
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||||
|
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||||
|
governance = {
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"idempotency_key": binding.idempotency_key,
|
||||||
|
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||||
|
"writer_lease": action in MUTATION_ACTIONS,
|
||||||
|
"main_write": False,
|
||||||
|
"production_write": False,
|
||||||
|
"tag_write": False,
|
||||||
|
"baseline_write": False,
|
||||||
|
"force_push": False,
|
||||||
|
"protected_ref_bypass": False,
|
||||||
|
}
|
||||||
|
supplied_governance = request.get("governance")
|
||||||
|
if supplied_governance is not None:
|
||||||
|
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||||
|
material: dict[str, Any] = {
|
||||||
|
"schema": EXECUTION_SCHEMA,
|
||||||
|
**binding.public(),
|
||||||
|
"action": action,
|
||||||
|
"adapter": str(request.get("adapter") or ("deterministic" if action == "deterministic" else "source_capture")),
|
||||||
|
"capability_scope": list(capabilities),
|
||||||
|
"change_set": list(request.get("change_set") or []),
|
||||||
|
"verification": list(request.get("verification") or []),
|
||||||
|
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||||
|
"governance": governance,
|
||||||
|
"workspace": {
|
||||||
|
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||||
|
"read_only_repository": True,
|
||||||
|
"cleanup_policy": "always",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
envelope = {**material, "execution_digest": _digest(material)}
|
||||||
|
return validate_execution_envelope(envelope, binding=binding)
|
||||||
|
|
||||||
|
|
||||||
|
def project_online_task(
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
task_request: Mapping[str, Any],
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request = dict(task_request)
|
||||||
|
required = _capabilities(request.get("required_capabilities"))
|
||||||
|
execution = project_execution_envelope(binding, execution_request, required_capabilities=required)
|
||||||
|
metadata = dict(request.get("metadata") or {})
|
||||||
|
if "execution" in metadata:
|
||||||
|
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||||
|
if metadata["execution"] != execution:
|
||||||
|
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||||
|
metadata["execution"] = execution
|
||||||
|
metadata["execution_projection"] = {
|
||||||
|
"schema": PROJECTION_SCHEMA,
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"execution_digest": execution["execution_digest"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**request,
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
"required_capabilities": list(required),
|
||||||
|
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_execution(
|
||||||
|
task: Mapping[str, Any],
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
*,
|
||||||
|
worker_capabilities: Sequence[str] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
metadata = task.get("metadata")
|
||||||
|
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||||
|
value = validate_execution_envelope(
|
||||||
|
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
worker_capabilities=worker_capabilities,
|
||||||
|
)
|
||||||
|
return value, "persisted"
|
||||||
|
projected = project_execution_envelope(
|
||||||
|
binding,
|
||||||
|
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||||
|
required_capabilities=task.get("required_capabilities") or [],
|
||||||
|
)
|
||||||
|
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||||
|
return projected, "recovered_projection"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Durable, authority-bound deterministic execution plans for Online Agent Tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_online_agent_execution import (
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
project_execution_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_key(task_id: str) -> str:
|
||||||
|
readable = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in task_id).strip("-")[:72] or "task"
|
||||||
|
return readable + "-" + hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionPlanRecord:
|
||||||
|
task_id: str
|
||||||
|
job_id: str
|
||||||
|
source_head_oid: str
|
||||||
|
generation: int
|
||||||
|
idempotency_key: str
|
||||||
|
plan_digest: str
|
||||||
|
execution: dict[str, Any]
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": "aios.online-agent.plan-record.v1",
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"source_head_oid": self.source_head_oid,
|
||||||
|
"generation": self.generation,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"plan_digest": self.plan_digest,
|
||||||
|
"execution": dict(self.execution),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanStore:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _path(self, task_id: str) -> Path:
|
||||||
|
return self.root / (_task_key(task_id) + ".json")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _lock(self, task_id: str):
|
||||||
|
path = self.root / (_task_key(task_id) + ".lock")
|
||||||
|
with path.open("a+b") as handle:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _source_head(binding: GovernedExecutionBinding) -> str:
|
||||||
|
return binding.expected_branch_oid if binding.branch_mode == "CONTINUE_EXISTING" else binding.base_oid
|
||||||
|
|
||||||
|
def _read(self, task_id: str) -> ExecutionPlanRecord | None:
|
||||||
|
path = self._path(task_id)
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.plan-record.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
execution = value.get("execution")
|
||||||
|
if not isinstance(execution, Mapping):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
return ExecutionPlanRecord(
|
||||||
|
task_id=str(value.get("task_id") or ""),
|
||||||
|
job_id=str(value.get("job_id") or ""),
|
||||||
|
source_head_oid=str(value.get("source_head_oid") or ""),
|
||||||
|
generation=int(value.get("generation") or 0),
|
||||||
|
idempotency_key=str(value.get("idempotency_key") or ""),
|
||||||
|
plan_digest=str(value.get("plan_digest") or ""),
|
||||||
|
execution=dict(execution),
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with self._lock(binding.task_id):
|
||||||
|
return self._register_locked(binding, request)
|
||||||
|
|
||||||
|
def _register_locked(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if request.get("schema") != "aios.online-agent.plan.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SCHEMA_INVALID")
|
||||||
|
if request.get("task_id") != binding.task_id or request.get("job_id") != binding.job_id:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if int(request.get("generation") or 0) != binding.generation:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_GENERATION_CONFLICT")
|
||||||
|
source_head = str(request.get("source_head_oid") or "")
|
||||||
|
if source_head != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_CONFLICT")
|
||||||
|
key = str(request.get("idempotency_key") or "")
|
||||||
|
if len(key) < 8:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_INVALID")
|
||||||
|
execution_request = request.get("execution")
|
||||||
|
if not isinstance(execution_request, Mapping) or execution_request.get("action") != "deterministic":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_DETERMINISTIC_REQUIRED")
|
||||||
|
envelope = project_execution_envelope(binding, execution_request)
|
||||||
|
material = {
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"source_head_oid": source_head,
|
||||||
|
"generation": binding.generation,
|
||||||
|
"idempotency_key": key,
|
||||||
|
"execution": dict(execution_request),
|
||||||
|
}
|
||||||
|
plan_digest = _digest(material)
|
||||||
|
existing = self._read(binding.task_id)
|
||||||
|
expected_previous = request.get("expected_previous_plan_digest")
|
||||||
|
if existing is not None:
|
||||||
|
if existing.idempotency_key == key:
|
||||||
|
if existing.plan_digest != plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_CONFLICT")
|
||||||
|
return {**existing.public(), "idempotent": True, "execution_digest": envelope["execution_digest"]}
|
||||||
|
if expected_previous != existing.plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
if existing.source_head_oid != source_head:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_STALE_REPLACEMENT_DENY")
|
||||||
|
elif expected_previous not in (None, ""):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
|
||||||
|
record = ExecutionPlanRecord(
|
||||||
|
task_id=binding.task_id,
|
||||||
|
job_id=binding.job_id,
|
||||||
|
source_head_oid=source_head,
|
||||||
|
generation=binding.generation,
|
||||||
|
idempotency_key=key,
|
||||||
|
plan_digest=plan_digest,
|
||||||
|
execution=dict(execution_request),
|
||||||
|
)
|
||||||
|
path = self._path(binding.task_id)
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(record.public(), sort_keys=True, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
return {**record.public(), "idempotent": False, "execution_digest": envelope["execution_digest"]}
|
||||||
|
|
||||||
|
def resolve(self, binding: GovernedExecutionBinding) -> dict[str, Any] | None:
|
||||||
|
record = self._read(binding.task_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
record.task_id != binding.task_id
|
||||||
|
or record.job_id != binding.job_id
|
||||||
|
or record.generation != binding.generation
|
||||||
|
):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if record.source_head_oid != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_DRIFT")
|
||||||
|
envelope = project_execution_envelope(binding, record.execution)
|
||||||
|
return {
|
||||||
|
"execution": dict(record.execution),
|
||||||
|
"plan_digest": record.plan_digest,
|
||||||
|
"execution_digest": envelope["execution_digest"],
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,456 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
UTC = dt.timezone.utc
|
||||||
|
_ALLOWED_CONFIDENTIALITY = frozenset({"public", "tenant", "project", "private", "run"})
|
||||||
|
_PRIVILEGED = frozenset({"*", "admin", "system.admin", "message.admin", "task.admin"})
|
||||||
|
|
||||||
|
|
||||||
|
class HandoffError(RuntimeError):
|
||||||
|
code = "HANDOFF_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageScopeMismatch(HandoffError):
|
||||||
|
code = "MESSAGE_SCOPE_MISMATCH_DENY"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfidentialityScopeDenied(HandoffError):
|
||||||
|
code = "MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageExpired(HandoffError):
|
||||||
|
code = "MESSAGE_EXPIRED"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceGap(HandoffError):
|
||||||
|
code = "MESSAGE_SEQUENCE_GAP_DETECT"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceConflict(HandoffError):
|
||||||
|
code = "MESSAGE_SEQUENCE_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(HandoffError):
|
||||||
|
code = "MESSAGE_IDEMPOTENCY_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> dt.datetime:
|
||||||
|
return dt.datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: dt.datetime | str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed.astimezone(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _tuple_scope(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(",")
|
||||||
|
return tuple(sorted({str(item).strip() for item in value if str(item).strip()}))
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET_KEY = re.compile(r"(?i)(?:token|secret|password|private[_-]?key|api[_-]?key)")
|
||||||
|
_SECRET_VALUE = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{8,}|(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+")
|
||||||
|
_SAFE_REFERENCE = re.compile(r"^(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_payload_secret_safe(value: Any, *, key: str = "") -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for child_key, child in value.items():
|
||||||
|
assert_payload_secret_safe(child, key=str(child_key))
|
||||||
|
return
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
for child in value:
|
||||||
|
assert_payload_secret_safe(child, key=key)
|
||||||
|
return
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if isinstance(value, str):
|
||||||
|
if _SECRET_VALUE.search(value):
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
if _SECRET_KEY.search(key) and value and _SAFE_REFERENCE.fullmatch(value) is None:
|
||||||
|
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class DeliveryContext:
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_actor(cls, actor: Any) -> "DeliveryContext":
|
||||||
|
def get(name: str) -> Any:
|
||||||
|
if isinstance(actor, Mapping):
|
||||||
|
return actor.get(name)
|
||||||
|
return getattr(actor, name, None)
|
||||||
|
return cls(
|
||||||
|
tenant_id=_clean(get("tenant_id")),
|
||||||
|
user_id=_clean(get("user_id")),
|
||||||
|
project_id=_clean(get("project_id")),
|
||||||
|
conversation_id=_clean(get("conversation_id")),
|
||||||
|
run_id=_clean(get("run_id")),
|
||||||
|
capabilities=_tuple_scope(get("capabilities") or get("capability_scope")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privileged(self) -> bool:
|
||||||
|
return bool(set(self.capabilities) & _PRIVILEGED)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class MessageEnvelope:
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
message_type: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
sequence: int
|
||||||
|
idempotency_key: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
capability_scope: tuple[str, ...] = ()
|
||||||
|
confidentiality_scope: str = "private"
|
||||||
|
expires_at: str | None = None
|
||||||
|
created_at: str = dataclasses.field(default_factory=lambda: _iso(_utcnow()) or "")
|
||||||
|
envelope_id: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
assert_payload_secret_safe(self.payload)
|
||||||
|
for field in ("source", "target", "message_type", "idempotency_key", "tenant_id", "user_id",
|
||||||
|
"project_id", "conversation_id", "task_id", "run_id", "correlation_id", "causation_id"):
|
||||||
|
object.__setattr__(self, field, _clean(getattr(self, field)) if field not in {
|
||||||
|
"source", "target", "message_type", "idempotency_key"
|
||||||
|
} else (_clean(getattr(self, field)) or ""))
|
||||||
|
object.__setattr__(self, "capability_scope", _tuple_scope(self.capability_scope))
|
||||||
|
object.__setattr__(self, "confidentiality_scope", (_clean(self.confidentiality_scope) or "private").lower())
|
||||||
|
object.__setattr__(self, "expires_at", _iso(self.expires_at))
|
||||||
|
object.__setattr__(self, "created_at", _iso(self.created_at) or "")
|
||||||
|
if not self.source or not self.target or not self.message_type or not self.idempotency_key:
|
||||||
|
raise ValueError("MESSAGE_ENVELOPE_IDENTITY_REQUIRED")
|
||||||
|
if not isinstance(self.sequence, int) or self.sequence < 1:
|
||||||
|
raise ValueError("MESSAGE_SEQUENCE_MUST_BE_POSITIVE")
|
||||||
|
if self.confidentiality_scope not in _ALLOWED_CONFIDENTIALITY:
|
||||||
|
raise ValueError("MESSAGE_CONFIDENTIALITY_SCOPE_INVALID")
|
||||||
|
if self.confidentiality_scope != "public" and self.tenant_id is None:
|
||||||
|
raise ValueError("MESSAGE_TENANT_REQUIRED")
|
||||||
|
if self.confidentiality_scope in {"project", "private", "run"} and self.project_id is None:
|
||||||
|
raise ValueError("MESSAGE_PROJECT_REQUIRED")
|
||||||
|
if self.confidentiality_scope in {"private", "run"} and self.user_id is None:
|
||||||
|
raise ValueError("MESSAGE_USER_REQUIRED")
|
||||||
|
if self.confidentiality_scope == "run" and self.run_id is None:
|
||||||
|
raise ValueError("MESSAGE_RUN_REQUIRED")
|
||||||
|
expected = stable_event_id(
|
||||||
|
source=self.source, target=self.target, message_type=self.message_type, payload=self.payload,
|
||||||
|
tenant_id=self.tenant_id, user_id=self.user_id, project_id=self.project_id,
|
||||||
|
conversation_id=self.conversation_id, task_id=self.task_id, run_id=self.run_id,
|
||||||
|
correlation_id=self.correlation_id, causation_id=self.causation_id,
|
||||||
|
sequence=self.sequence, idempotency_key=self.idempotency_key,
|
||||||
|
)
|
||||||
|
if self.envelope_id and self.envelope_id != expected:
|
||||||
|
raise ValueError("MESSAGE_ENVELOPE_ID_MISMATCH")
|
||||||
|
object.__setattr__(self, "envelope_id", expected)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload_sha256(self) -> str:
|
||||||
|
return _sha(self.payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_key(self) -> str:
|
||||||
|
return _sha({
|
||||||
|
"tenant_id": self.tenant_id, "source": self.source, "target": self.target,
|
||||||
|
"conversation_id": self.conversation_id, "task_id": self.task_id, "run_id": self.run_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def is_expired(self, *, now: dt.datetime | None = None) -> bool:
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return dt.datetime.fromisoformat(self.expires_at) <= (now or _utcnow()).astimezone(UTC)
|
||||||
|
|
||||||
|
def as_record(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"envelope_id": self.envelope_id, "stream_key": self.stream_key, "source": self.source,
|
||||||
|
"target": self.target, "message_type": self.message_type, "payload_json": _canonical(self.payload),
|
||||||
|
"payload_sha256": self.payload_sha256, "tenant_id": self.tenant_id, "user_id": self.user_id,
|
||||||
|
"project_id": self.project_id, "conversation_id": self.conversation_id, "task_id": self.task_id,
|
||||||
|
"run_id": self.run_id, "correlation_id": self.correlation_id,
|
||||||
|
"causation_id": self.causation_id, "sequence": self.sequence,
|
||||||
|
"capability_scope_json": _canonical(self.capability_scope),
|
||||||
|
"confidentiality_scope": self.confidentiality_scope, "idempotency_key": self.idempotency_key,
|
||||||
|
"expires_at": self.expires_at, "created_at": self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stable_event_id(*, source: Any, target: Any, message_type: Any, payload: Any,
|
||||||
|
tenant_id: Any = None, user_id: Any = None, project_id: Any = None,
|
||||||
|
conversation_id: Any = None, task_id: Any = None, run_id: Any = None,
|
||||||
|
correlation_id: Any = None, causation_id: Any = None,
|
||||||
|
sequence: Any = None, idempotency_key: Any = None) -> str:
|
||||||
|
assert_payload_secret_safe(payload)
|
||||||
|
material = {
|
||||||
|
"source": _clean(source), "target": _clean(target), "message_type": _clean(message_type),
|
||||||
|
"payload_sha256": _sha(payload), "tenant_id": _clean(tenant_id), "user_id": _clean(user_id),
|
||||||
|
"project_id": _clean(project_id), "conversation_id": _clean(conversation_id),
|
||||||
|
"task_id": _clean(task_id), "run_id": _clean(run_id), "causation_id": _clean(causation_id),
|
||||||
|
"sequence": sequence, "idempotency_key": _clean(idempotency_key),
|
||||||
|
}
|
||||||
|
# Preserve historical envelope IDs for legacy rows that predate correlation_id.
|
||||||
|
if _clean(correlation_id) is not None:
|
||||||
|
material["correlation_id"] = _clean(correlation_id)
|
||||||
|
return _sha(material)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_envelope_scope(envelope: MessageEnvelope, actor: Any) -> MessageEnvelope:
|
||||||
|
ctx = DeliveryContext.from_actor(actor)
|
||||||
|
if ctx.privileged:
|
||||||
|
return envelope
|
||||||
|
if ctx.tenant_id is None:
|
||||||
|
if envelope.tenant_id is not None:
|
||||||
|
raise MessageScopeMismatch(MessageScopeMismatch.code)
|
||||||
|
return envelope
|
||||||
|
updates: dict[str, Any] = {}
|
||||||
|
for field in ("tenant_id", "user_id", "project_id", "conversation_id", "run_id"):
|
||||||
|
trusted = getattr(ctx, field)
|
||||||
|
requested = getattr(envelope, field)
|
||||||
|
if requested is not None and requested != trusted:
|
||||||
|
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:{field}")
|
||||||
|
if requested is None and trusted is not None:
|
||||||
|
updates[field] = trusted
|
||||||
|
if not updates:
|
||||||
|
return envelope
|
||||||
|
data = dataclasses.asdict(envelope)
|
||||||
|
data.update(updates)
|
||||||
|
data["envelope_id"] = ""
|
||||||
|
return MessageEnvelope(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_delivery(actor: Any, envelope: MessageEnvelope) -> None:
|
||||||
|
ctx = DeliveryContext.from_actor(actor)
|
||||||
|
if ctx.privileged:
|
||||||
|
return
|
||||||
|
if not set(envelope.capability_scope).issubset(set(ctx.capabilities)):
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:capability")
|
||||||
|
if envelope.confidentiality_scope == "public":
|
||||||
|
return
|
||||||
|
if ctx.tenant_id != envelope.tenant_id:
|
||||||
|
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:tenant_id")
|
||||||
|
if envelope.confidentiality_scope in {"project", "private", "run"} and ctx.project_id != envelope.project_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:project_id")
|
||||||
|
if envelope.confidentiality_scope in {"private", "run"} and ctx.user_id != envelope.user_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:user_id")
|
||||||
|
if envelope.confidentiality_scope == "run" and ctx.run_id != envelope.run_id:
|
||||||
|
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:run_id")
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||||
|
envelope_id TEXT PRIMARY KEY,
|
||||||
|
stream_key TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
task_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
correlation_id TEXT,
|
||||||
|
causation_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||||
|
capability_scope_json TEXT NOT NULL,
|
||||||
|
confidentiality_scope TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
delivered_at TEXT,
|
||||||
|
acked_at TEXT,
|
||||||
|
UNIQUE(stream_key, idempotency_key),
|
||||||
|
UNIQUE(stream_key, sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||||
|
ON message_handoff(target, status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||||
|
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DurableHandoffStore:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(message_handoff)")}
|
||||||
|
if "correlation_id" not in columns:
|
||||||
|
conn.execute("ALTER TABLE message_handoff ADD COLUMN correlation_id TEXT")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_message_handoff_lineage "
|
||||||
|
"ON message_handoff(correlation_id, causation_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _from_row(row: sqlite3.Row) -> MessageEnvelope:
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=row["source"], target=row["target"], message_type=row["message_type"],
|
||||||
|
payload=json.loads(row["payload_json"]), tenant_id=row["tenant_id"], user_id=row["user_id"],
|
||||||
|
project_id=row["project_id"], conversation_id=row["conversation_id"], task_id=row["task_id"],
|
||||||
|
run_id=row["run_id"], correlation_id=row["correlation_id"],
|
||||||
|
causation_id=row["causation_id"], sequence=int(row["sequence"]),
|
||||||
|
capability_scope=tuple(json.loads(row["capability_scope_json"])),
|
||||||
|
confidentiality_scope=row["confidentiality_scope"], idempotency_key=row["idempotency_key"],
|
||||||
|
expires_at=row["expires_at"], created_at=row["created_at"], envelope_id=row["envelope_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def put(self, envelope: MessageEnvelope) -> tuple[str, MessageEnvelope]:
|
||||||
|
if envelope.is_expired():
|
||||||
|
raise MessageExpired(MessageExpired.code)
|
||||||
|
record = envelope.as_record()
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
replay = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE stream_key=? AND idempotency_key=?",
|
||||||
|
(envelope.stream_key, envelope.idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
existing = self._from_row(replay)
|
||||||
|
if existing.envelope_id != envelope.envelope_id or existing.payload_sha256 != envelope.payload_sha256:
|
||||||
|
raise IdempotencyConflict(IdempotencyConflict.code)
|
||||||
|
conn.commit()
|
||||||
|
return "REPLAY", existing
|
||||||
|
seq = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE stream_key=? AND sequence=?",
|
||||||
|
(envelope.stream_key, envelope.sequence),
|
||||||
|
).fetchone()
|
||||||
|
if seq is not None:
|
||||||
|
existing = self._from_row(seq)
|
||||||
|
if existing.envelope_id == envelope.envelope_id:
|
||||||
|
conn.commit()
|
||||||
|
return "REPLAY", existing
|
||||||
|
raise SequenceConflict(SequenceConflict.code)
|
||||||
|
maximum = int(conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(sequence),0) FROM message_handoff WHERE stream_key=?",
|
||||||
|
(envelope.stream_key,),
|
||||||
|
).fetchone()[0])
|
||||||
|
expected = maximum + 1
|
||||||
|
if envelope.sequence != expected:
|
||||||
|
raise SequenceGap(f"{SequenceGap.code}:expected={expected}:actual={envelope.sequence}")
|
||||||
|
columns = tuple(record)
|
||||||
|
placeholders = ",".join("?" for _ in columns)
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({placeholders})",
|
||||||
|
tuple(record[column] for column in columns),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return "CREATED", envelope
|
||||||
|
|
||||||
|
def get(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM message_handoff WHERE envelope_id=?", (envelope_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(envelope_id)
|
||||||
|
envelope = self._from_row(row)
|
||||||
|
if envelope.is_expired():
|
||||||
|
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope_id,))
|
||||||
|
conn.commit()
|
||||||
|
raise MessageExpired(MessageExpired.code)
|
||||||
|
authorize_delivery(actor, envelope)
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
def pending(self, *, target: str, actor: Any, limit: int = 100) -> list[MessageEnvelope]:
|
||||||
|
if not 1 <= limit <= 1000:
|
||||||
|
raise ValueError("MESSAGE_PENDING_LIMIT_INVALID")
|
||||||
|
visible: list[MessageEnvelope] = []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM message_handoff WHERE target=? AND status='PENDING' ORDER BY created_at, sequence LIMIT ?",
|
||||||
|
(target, limit),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
envelope = self._from_row(row)
|
||||||
|
if envelope.is_expired():
|
||||||
|
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope.envelope_id,))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
authorize_delivery(actor, envelope)
|
||||||
|
except HandoffError:
|
||||||
|
continue
|
||||||
|
visible.append(envelope)
|
||||||
|
conn.commit()
|
||||||
|
return visible
|
||||||
|
|
||||||
|
def mark_delivered(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
envelope = self.get(envelope_id, actor=actor)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE message_handoff SET status='DELIVERED', delivered_at=? "
|
||||||
|
"WHERE envelope_id=? AND status='PENDING'",
|
||||||
|
(_iso(_utcnow()), envelope_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
def acknowledge(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||||
|
envelope = self.get(envelope_id, actor=actor)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE message_handoff SET status='ACKED', acked_at=? WHERE envelope_id=?",
|
||||||
|
(_iso(_utcnow()), envelope_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
class HandoffFabric:
|
||||||
|
def __init__(self, store: DurableHandoffStore) -> None:
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def publish(self, envelope: MessageEnvelope, *, actor: Any | None = None) -> tuple[str, MessageEnvelope]:
|
||||||
|
return self.store.put(bind_envelope_scope(envelope, actor) if actor is not None else envelope)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from message_handoff import MessageEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def _get(value: Any, *names: str, default: Any = None) -> Any:
|
||||||
|
for name in names:
|
||||||
|
if isinstance(value, Mapping) and name in value:
|
||||||
|
return value[name]
|
||||||
|
if hasattr(value, name):
|
||||||
|
return getattr(value, name)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def agent_event_to_envelope(event: Any, *, target: str | None = None, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
payload = _get(event, "payload", "data", default={}) or {}
|
||||||
|
target = target or _get(event, "target", "recipient")
|
||||||
|
if not target:
|
||||||
|
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(_get(event, "source", "producer", "agent_id", default="agent-event")),
|
||||||
|
target=str(target),
|
||||||
|
message_type=str(_get(event, "event_type", "type", "message_type", default="agent.event")),
|
||||||
|
payload=dict(payload),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or str(_get(event, "id", "event_id", default="")) or f"agent-event:{sequence}",
|
||||||
|
causation_id=_get(event, "causation_id"),
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def taskplane_event_to_envelope(event: Any, *, target: str, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
data = _get(event, "data", "payload", default={}) or {}
|
||||||
|
event_id = str(_get(event, "id", "event_id", default=""))
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(_get(event, "source", default="task-plane")),
|
||||||
|
target=target,
|
||||||
|
message_type=str(_get(event, "type", "event_type", default="task.event")),
|
||||||
|
payload=dict(data),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or event_id or f"task-event:{sequence}",
|
||||||
|
causation_id=event_id or None,
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_message_to_envelope(message: Any, *, target: str | None = None, sequence: int = 1,
|
||||||
|
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||||
|
payload = _get(message, "payload", "data", "body", "content", default={})
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
payload = {"value": payload}
|
||||||
|
target = target or _get(message, "target", "recipient", "receiver")
|
||||||
|
if not target:
|
||||||
|
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||||
|
source = _get(message, "source", "sender", "agent_id", default="legacy-message-bus")
|
||||||
|
kind = _get(message, "message_type", "type", "kind", default="legacy.message")
|
||||||
|
identifier = _get(message, "id", "message_id", "event_id")
|
||||||
|
return MessageEnvelope(
|
||||||
|
source=str(source), target=str(target), message_type=str(kind), payload=dict(payload),
|
||||||
|
sequence=sequence,
|
||||||
|
idempotency_key=idempotency_key or (str(identifier) if identifier else f"legacy:{sequence}"),
|
||||||
|
causation_id=str(identifier) if identifier else None,
|
||||||
|
**scope,
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -43,6 +44,9 @@ class SearchGatewayAgentAdapter:
|
|||||||
domain_profiles: list[str] | None = None,
|
domain_profiles: list[str] | None = None,
|
||||||
user_scope: str = "public",
|
user_scope: str = "public",
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||||
|
trace: TraceContext | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {
|
payload = {
|
||||||
"query": query,
|
"query": query,
|
||||||
@@ -59,7 +63,11 @@ class SearchGatewayAgentAdapter:
|
|||||||
"session_profile": context.session_profile,
|
"session_profile": context.session_profile,
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
}
|
}
|
||||||
return self.orchestrator.execute(payload)
|
if actor is None and trace is None:
|
||||||
|
# Preserve adapter compatibility; the governed orchestrator itself
|
||||||
|
# still fails closed when trusted context is absent.
|
||||||
|
return self.orchestrator.execute(payload)
|
||||||
|
return self.orchestrator.execute(payload, actor=actor, trace=trace)
|
||||||
|
|
||||||
|
|
||||||
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceError,
|
||||||
|
GovernanceStore,
|
||||||
|
TraceContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -156,13 +164,25 @@ class SearchPolicyGuard:
|
|||||||
|
|
||||||
|
|
||||||
class SearchGateway:
|
class SearchGateway:
|
||||||
def __init__(self, providers: list[SearchProvider] | None = None):
|
def __init__(self, providers: list[SearchProvider] | None = None, *, governance_store: GovernanceStore | None = None):
|
||||||
self.providers = providers or []
|
self.providers = providers or []
|
||||||
|
self.governance_store = governance_store
|
||||||
self.profile_resolver = SearchProfileResolver()
|
self.profile_resolver = SearchProfileResolver()
|
||||||
self.planner = QueryPlanner()
|
self.planner = QueryPlanner()
|
||||||
self.guard = SearchPolicyGuard()
|
self.guard = SearchPolicyGuard()
|
||||||
|
|
||||||
def search(self, request: SearchRequest) -> SearchResponse:
|
def search(self, request: SearchRequest, *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> SearchResponse:
|
||||||
|
trusted_actor: GovernanceActor | None = None
|
||||||
|
if self.governance_store is not None:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
trusted_actor = GovernanceActor.from_value(actor)
|
||||||
|
if request.requester != trusted_actor.identity:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="requester")
|
||||||
|
if request.correlation_id is not None and request.correlation_id not in {
|
||||||
|
trace.trace_id, trace.correlation_id
|
||||||
|
}:
|
||||||
|
raise GovernanceError("TOOL_SCOPE_PROPAGATION", field="correlation_id")
|
||||||
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
||||||
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
||||||
expanded_queries = self.planner.expand(request, profile)
|
expanded_queries = self.planner.expand(request, profile)
|
||||||
@@ -184,14 +204,60 @@ class SearchGateway:
|
|||||||
attempts: list[SearchProviderAttempt] = []
|
attempts: list[SearchProviderAttempt] = []
|
||||||
providers = self._ordered_providers(profile)
|
providers = self._ordered_providers(profile)
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
|
if request.authorized_sources and provider.name not in request.authorized_sources:
|
||||||
|
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_DENY"))
|
||||||
|
continue
|
||||||
|
if request.user_scope != "public" and provider.name not in request.authorized_sources:
|
||||||
|
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_REQUIRED"))
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
|
if self.governance_store is not None:
|
||||||
|
assert trusted_actor is not None and trace is not None
|
||||||
|
input_digest = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{"query": request.query, "intent": request.intent, "provider": provider.name},
|
||||||
|
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
invocation_key = hashlib.sha256(
|
||||||
|
f"{trace.trace_id}:{trace.span_id}:{provider.name}:{input_digest}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
authorization = self.governance_store.authorize_tool(
|
||||||
|
actor=trusted_actor,
|
||||||
|
tool_name=f"search.{provider.name}",
|
||||||
|
trace=trace,
|
||||||
|
requested_scope=trace.scope,
|
||||||
|
input_digest=input_digest,
|
||||||
|
invocation_key=invocation_key,
|
||||||
|
)
|
||||||
|
if authorization.get("idempotent"):
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_REPLAY")
|
||||||
provider_evidence, attempt = provider.search(request, expanded_queries)
|
provider_evidence, attempt = provider.search(request, expanded_queries)
|
||||||
|
if self.governance_store is not None:
|
||||||
|
result_digest = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"evidence": [item.to_dict() for item in provider_evidence],
|
||||||
|
"attempt": attempt.to_dict(),
|
||||||
|
},
|
||||||
|
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
self.governance_store.record_tool_receipt(
|
||||||
|
invocation_key=invocation_key,
|
||||||
|
result_digest=result_digest,
|
||||||
|
actor=trusted_actor,
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
evidence.extend(provider_evidence)
|
evidence.extend(provider_evidence)
|
||||||
attempts.append(attempt)
|
attempts.append(attempt)
|
||||||
if provider_evidence:
|
if provider_evidence:
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except GovernanceError:
|
||||||
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Provider exceptions may contain URLs, tokens, or local paths.
|
||||||
|
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason="PROVIDER_FAILURE"))
|
||||||
|
|
||||||
if evidence:
|
if evidence:
|
||||||
return SearchResponse(
|
return SearchResponse(
|
||||||
|
|||||||
@@ -7,27 +7,29 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
APIRouter = None
|
APIRouter = None
|
||||||
|
|
||||||
from .search_gateway import SearchGateway, SearchRequest
|
from .search_gateway import SearchRequest
|
||||||
|
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||||
from .search_gateway_dashboard import SearchGatewayDashboard
|
from .search_gateway_dashboard import SearchGatewayDashboard
|
||||||
|
|
||||||
|
|
||||||
gateway = SearchGateway()
|
orchestrator = SearchGatewayOrchestrator()
|
||||||
dashboard = SearchGatewayDashboard()
|
dashboard = SearchGatewayDashboard()
|
||||||
|
|
||||||
|
|
||||||
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
|
def search_gateway_request(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||||
request = SearchRequest(**payload)
|
return orchestrator.execute(payload, actor=actor, trace=trace)
|
||||||
return gateway.search(request).to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def get_search_gateway_status() -> dict[str, Any]:
|
def get_search_gateway_status() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"module": "search-gateway",
|
"module": "search-gateway",
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"providers_configured": len(gateway.providers),
|
"providers_configured": len(orchestrator.gateway.providers),
|
||||||
"policy_guard": "enabled",
|
"policy_guard": "enabled",
|
||||||
"profile_customization": "enabled",
|
"profile_customization": "enabled",
|
||||||
"fallback_required_when_provider_unavailable": True,
|
"fallback_required_when_provider_unavailable": True,
|
||||||
|
"governance": "required",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import os
|
||||||
|
|
||||||
from .search_audit_store import SearchAuditStore
|
from .search_audit_store import SearchAuditStore
|
||||||
from .search_event_bridge import SearchEventBridge
|
from .search_event_bridge import SearchEventBridge
|
||||||
from .search_gateway import SearchGateway, SearchRequest
|
from .search_gateway import SearchGateway, SearchRequest
|
||||||
from .search_provider_registry import SearchProviderRegistry
|
from .search_provider_registry import SearchProviderRegistry
|
||||||
|
from .trace_tool_credential_governance import (
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceScope,
|
||||||
|
GovernanceStore,
|
||||||
|
ToolDescriptor,
|
||||||
|
TraceContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchGatewayOrchestrator:
|
class SearchGatewayOrchestrator:
|
||||||
def __init__(self):
|
def __init__(self, *, governance_store: GovernanceStore | None = None):
|
||||||
self.registry = SearchProviderRegistry()
|
self.registry = SearchProviderRegistry()
|
||||||
self.audit = SearchAuditStore()
|
self.audit = SearchAuditStore()
|
||||||
self.events = SearchEventBridge()
|
self.events = SearchEventBridge()
|
||||||
self.gateway = SearchGateway(self.registry.ordered())
|
self.governance = governance_store or GovernanceStore(
|
||||||
|
os.environ.get("AIOS_SEARCH_GOVERNANCE_DB", "runtime/search-governance.sqlite3")
|
||||||
|
)
|
||||||
|
registrar = GovernanceActor(
|
||||||
|
"search-runtime-bootstrap", GovernanceScope("system"), ("system.admin",)
|
||||||
|
)
|
||||||
|
for provider in self.registry.ordered():
|
||||||
|
self.governance.register_tool(
|
||||||
|
ToolDescriptor(
|
||||||
|
f"search.{provider.name}", "tool", GovernanceScope("global"), ("tool.search",)
|
||||||
|
),
|
||||||
|
registrar,
|
||||||
|
)
|
||||||
|
self.gateway = SearchGateway(self.registry.ordered(), governance_store=self.governance)
|
||||||
|
|
||||||
def execute(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def execute(self, payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||||
|
if actor is None or trace is None:
|
||||||
|
from .trace_tool_credential_governance import GovernanceError
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
self.governance.record_trace(trace, actor)
|
||||||
request = SearchRequest(**payload)
|
request = SearchRequest(**payload)
|
||||||
self.events.search_started(request.to_dict())
|
self.events.search_started(request.to_dict())
|
||||||
response = self.gateway.search(request)
|
response = self.gateway.search(request, actor=actor, trace=trace)
|
||||||
result = response.to_dict()
|
result = response.to_dict()
|
||||||
for attempt in result.get("attempts", []):
|
for attempt in result.get("attempts", []):
|
||||||
self.events.provider_attempted(result["request_id"], attempt)
|
self.events.provider_attempted(result["request_id"], attempt)
|
||||||
@@ -35,6 +60,7 @@ class SearchGatewayOrchestrator:
|
|||||||
"registry": self.registry.status(),
|
"registry": self.registry.status(),
|
||||||
"audit": "enabled",
|
"audit": "enabled",
|
||||||
"events": "enabled",
|
"events": "enabled",
|
||||||
|
"governance": "required",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .policy import PolicyEngine
|
from .policy import PolicyEngine
|
||||||
from .store import TaskPlaneStore
|
from .store import TaskPlaneStore
|
||||||
|
from .scope import bind_task_scope
|
||||||
from .worker_registry import WorkerRegistry
|
from .worker_registry import WorkerRegistry
|
||||||
|
|
||||||
|
|
||||||
@@ -68,28 +69,54 @@ class TaskPlaneService:
|
|||||||
self.delegation = DelegationEngine(store)
|
self.delegation = DelegationEngine(store)
|
||||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||||
|
|
||||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
def _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord:
|
||||||
|
task = self.store.get_task(task_id, self.policy.actor_context(actor))
|
||||||
|
self.policy.assert_allowed(action, actor, task=task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
body: dict[str, Any],
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
*,
|
||||||
|
actor_identity: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
if body.get("parent_task_id"):
|
if body.get("parent_task_id"):
|
||||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||||
issuer = body.get("issuer", {})
|
issuer = body.get("issuer", {})
|
||||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||||
|
actor = actor_identity or declared_actor
|
||||||
self.policy.assert_allowed("create", actor)
|
self.policy.assert_allowed("create", actor)
|
||||||
try:
|
try:
|
||||||
plan = TaskPlan.from_dict(body)
|
plan = TaskPlan.from_dict(body)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid task plan") from exc
|
raise ValidationError("invalid task plan") from exc
|
||||||
|
if actor_identity is not None:
|
||||||
|
plan = bind_task_scope(
|
||||||
|
plan, self.policy.actor_context(actor_identity), public_create=True
|
||||||
|
)
|
||||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
context = None
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor)
|
self.policy.assert_allowed("read", actor)
|
||||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
context = self.policy.actor_context(actor)
|
||||||
|
return [
|
||||||
|
task.to_dict(include_task=False)
|
||||||
|
for task in self.store.list_tasks(context)
|
||||||
|
]
|
||||||
|
|
||||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
|
context = None
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
self.policy.assert_allowed("read", actor)
|
||||||
return self.store.get_task(task_id).to_dict()
|
context = self.policy.actor_context(actor)
|
||||||
|
task = self.store.get_task(task_id, context)
|
||||||
|
if actor is not None:
|
||||||
|
self.policy.assert_allowed("read", actor, task=task)
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
@@ -163,9 +190,8 @@ class TaskPlaneService:
|
|||||||
if not worker_id:
|
if not worker_id:
|
||||||
raise ValidationError("worker_id required")
|
raise ValidationError("worker_id required")
|
||||||
|
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "claim")
|
||||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
||||||
self.policy.assert_allowed("claim", actor, task=task)
|
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||||
)
|
)
|
||||||
@@ -184,10 +210,9 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "heartbeat")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("heartbeat", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
@@ -206,10 +231,9 @@ class TaskPlaneService:
|
|||||||
message = body.get("message")
|
message = body.get("message")
|
||||||
if progress is None:
|
if progress is None:
|
||||||
raise ValidationError("progress required")
|
raise ValidationError("progress required")
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "progress")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("progress", actor, task=task)
|
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
if message not in {None, ""} or contains_secret_text(stage):
|
if message not in {None, ""} or contains_secret_text(stage):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -234,7 +258,7 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "result")
|
||||||
|
|
||||||
status = body.get("status", "COMPLETED")
|
status = body.get("status", "COMPLETED")
|
||||||
outputs = body.get("outputs", [])
|
outputs = body.get("outputs", [])
|
||||||
@@ -247,7 +271,6 @@ class TaskPlaneService:
|
|||||||
raise ValidationError("evidence items must be objects")
|
raise ValidationError("evidence items must be objects")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
self.policy.assert_allowed("result", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
@@ -326,8 +349,7 @@ class TaskPlaneService:
|
|||||||
reason = body.get("reason", "failed")
|
reason = body.get("reason", "failed")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
self.policy.assert_allowed("fail", actor)
|
self.policy.assert_allowed("fail", actor)
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "fail")
|
||||||
self.policy.assert_allowed("fail", actor, task=task)
|
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
@@ -339,8 +361,7 @@ class TaskPlaneService:
|
|||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
||||||
self.policy.assert_allowed("cancel", actor)
|
self.policy.assert_allowed("cancel", actor)
|
||||||
reason = body.get("reason", "cancelled")
|
reason = body.get("reason", "cancelled")
|
||||||
task = self.store.get_task(task_id)
|
task = self._authorized_task(task_id, actor, "cancel")
|
||||||
self.policy.assert_allowed("cancel", actor, task=task)
|
|
||||||
updated = self.store.cancel_task_with_event(
|
updated = self.store.cancel_task_with_event(
|
||||||
task_id, task.revision, reason=reason
|
task_id, task.revision, reason=reason
|
||||||
)
|
)
|
||||||
@@ -349,8 +370,7 @@ class TaskPlaneService:
|
|||||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
actor = body.get("actor_identity") or "owner"
|
actor = body.get("actor_identity") or "owner"
|
||||||
self.policy.assert_allowed("delegate", actor)
|
self.policy.assert_allowed("delegate", actor)
|
||||||
parent = self.store.get_task(task_id)
|
parent = self._authorized_task(task_id, actor, "delegate")
|
||||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
|
||||||
if "task" in body:
|
if "task" in body:
|
||||||
try:
|
try:
|
||||||
child_plan = TaskPlan.from_dict(body["task"])
|
child_plan = TaskPlan.from_dict(body["task"])
|
||||||
@@ -364,25 +384,31 @@ class TaskPlaneService:
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid child task plan") from exc
|
raise ValidationError("invalid child task plan") from exc
|
||||||
child_plan.parent_task_id = task_id
|
child_plan.parent_task_id = task_id
|
||||||
|
child_plan = bind_task_scope(
|
||||||
|
child_plan, self.policy.actor_context(actor), public_create=False
|
||||||
|
)
|
||||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||||
return child.to_dict()
|
return child.to_dict()
|
||||||
|
|
||||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "audit")
|
||||||
self.store.get_task(task_id)
|
else:
|
||||||
|
self.store.get_task(task_id)
|
||||||
return self.store.list_events(task_id)
|
return self.store.list_events(task_id)
|
||||||
|
|
||||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "audit")
|
||||||
self.store.get_task(task_id)
|
else:
|
||||||
|
self.store.get_task(task_id)
|
||||||
return self.evidence.list(task_id)
|
return self.evidence.list(task_id)
|
||||||
|
|
||||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
self._authorized_task(task_id, actor, "read")
|
||||||
self.store.get_task(task_id)
|
else:
|
||||||
|
self.store.get_task(task_id)
|
||||||
return self.store.list_children(task_id)
|
return self.store.list_children(task_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -506,7 +532,11 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
):
|
):
|
||||||
raise ForbiddenError("issuer identity mismatch")
|
raise ForbiddenError("issuer identity mismatch")
|
||||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
return 201, self.service.create_task(
|
||||||
|
body or {},
|
||||||
|
headers.get("idempotency-key"),
|
||||||
|
actor_identity=identity,
|
||||||
|
)
|
||||||
if body.get("actor_identity") not in {None, identity}:
|
if body.get("actor_identity") not in {None, identity}:
|
||||||
raise ForbiddenError("actor identity mismatch")
|
raise ForbiddenError("actor identity mismatch")
|
||||||
body["actor_identity"] = identity
|
body["actor_identity"] = identity
|
||||||
|
|||||||
@@ -106,3 +106,8 @@ class DurableOutboxDispatcher:
|
|||||||
receipt=str(receipt) if receipt else None,
|
receipt=str(receipt) if receipt else None,
|
||||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
error=None if receipt else f"{adapter.name} receipt unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# R4.2 P0-B compatibility adapter.
|
||||||
|
def to_handoff_envelope(event, *, target, sequence=1, idempotency_key=None, **scope):
|
||||||
|
from message_handoff_adapters import taskplane_event_to_envelope
|
||||||
|
return taskplane_event_to_envelope(event, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .scope import normalize_task_scope
|
||||||
|
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"secret",
|
"secret",
|
||||||
@@ -66,6 +68,12 @@ TASK_PLAN_KEYS = {
|
|||||||
"allowed_input_paths",
|
"allowed_input_paths",
|
||||||
"allowed_output_paths",
|
"allowed_output_paths",
|
||||||
"output_policy",
|
"output_policy",
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +245,12 @@ class TaskPlan:
|
|||||||
schema_version: int = 1
|
schema_version: int = 1
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
task_type: str = "code.change"
|
task_type: str = "code.change"
|
||||||
|
scope_type: str = "legacy"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
issuer: dict[str, Any] = field(default_factory=dict)
|
issuer: dict[str, Any] = field(default_factory=dict)
|
||||||
intent: str | None = None
|
intent: str | None = None
|
||||||
required_capabilities: list[str] = field(default_factory=list)
|
required_capabilities: list[str] = field(default_factory=list)
|
||||||
@@ -311,10 +325,17 @@ class TaskPlan:
|
|||||||
raise ValueError("credential-secret requires metadata-only logging")
|
raise ValueError("credential-secret requires metadata-only logging")
|
||||||
if output_policy != "hash-count-status-only":
|
if output_policy != "hash-count-status-only":
|
||||||
raise ValueError("credential-secret requires restricted output")
|
raise ValueError("credential-secret requires restricted output")
|
||||||
|
scope = normalize_task_scope(data)
|
||||||
return cls(
|
return cls(
|
||||||
schema_version=schema_version,
|
schema_version=schema_version,
|
||||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
||||||
|
scope_type=scope.scope_type,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
user_id=scope.user_id,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
conversation_id=scope.conversation_id,
|
||||||
|
run_id=scope.run_id,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
intent=data.get("intent"),
|
intent=data.get("intent"),
|
||||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
||||||
@@ -348,6 +369,12 @@ class TaskPlan:
|
|||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"task_id": self.task_id,
|
"task_id": self.task_id,
|
||||||
"task_type": self.task_type,
|
"task_type": self.task_type,
|
||||||
|
"scope_type": self.scope_type,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
"issuer": self.issuer,
|
"issuer": self.issuer,
|
||||||
"intent": self.intent,
|
"intent": self.intent,
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
from .errors import ForbiddenError, LockedError
|
from .errors import ForbiddenError, LockedError
|
||||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
||||||
|
from .scope import TaskActorContext, actor_context as build_actor_context
|
||||||
|
|
||||||
|
|
||||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||||
@@ -93,6 +94,10 @@ class PolicyEngine:
|
|||||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||||
identity_capabilities: dict[str, set[str]] | None = None,
|
identity_capabilities: dict[str, set[str]] | None = None,
|
||||||
|
identity_actor_contexts: Mapping[
|
||||||
|
str, TaskActorContext | Mapping[str, Any]
|
||||||
|
]
|
||||||
|
| None = None,
|
||||||
admin_identities: set[str] | None = None,
|
admin_identities: set[str] | None = None,
|
||||||
readonly_worker_execution_enabled: bool = False,
|
readonly_worker_execution_enabled: bool = False,
|
||||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
||||||
@@ -109,12 +114,26 @@ class PolicyEngine:
|
|||||||
identity: set(capabilities)
|
identity: set(capabilities)
|
||||||
for identity, capabilities in (identity_capabilities or {}).items()
|
for identity, capabilities in (identity_capabilities or {}).items()
|
||||||
}
|
}
|
||||||
|
self.identity_actor_contexts = dict(identity_actor_contexts or {})
|
||||||
self.admin_identities = set(admin_identities or ())
|
self.admin_identities = set(admin_identities or ())
|
||||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
||||||
self.readonly_worker_profiles = {
|
self.readonly_worker_profiles = {
|
||||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def actor_context(self, identity: str) -> TaskActorContext:
|
||||||
|
configured = self.identity_actor_contexts.get(
|
||||||
|
identity, self.identity_actor_contexts.get("*")
|
||||||
|
)
|
||||||
|
capabilities = set(
|
||||||
|
self.identity_capabilities.get(
|
||||||
|
identity, self.identity_capabilities.get("*", set())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if identity in self.admin_identities:
|
||||||
|
capabilities.add("task.admin")
|
||||||
|
return build_actor_context(identity, configured, capabilities)
|
||||||
|
|
||||||
def _readonly_worker_allows(
|
def _readonly_worker_allows(
|
||||||
self, action: str, actor: str, task: TaskRecord | None
|
self, action: str, actor: str, task: TaskRecord | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Trusted TaskPlane scope model and SQL authorization helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .errors import ForbiddenError, ValidationError
|
||||||
|
|
||||||
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||||
|
PRIVILEGED_CAPABILITIES = frozenset(
|
||||||
|
{"*", "admin", "system.admin", "task.admin", "task.read.all"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _value(value: Any, field: str) -> Any:
|
||||||
|
if hasattr(value, "task_plan"):
|
||||||
|
value = value.task_plan
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return value.get(field)
|
||||||
|
return getattr(value, field, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
result = str(value).strip()
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskScope:
|
||||||
|
scope_type: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskActorContext:
|
||||||
|
identity: str
|
||||||
|
scope_type: str = "legacy"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
capabilities: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_scope(value: Any) -> TaskScope:
|
||||||
|
fields = {field: _text(_value(value, field)) for field in SCOPE_FIELDS}
|
||||||
|
declared = _text(_value(value, "scope_type"))
|
||||||
|
if declared is None:
|
||||||
|
declared = "tenant" if fields["tenant_id"] else "legacy"
|
||||||
|
if declared == "tenant":
|
||||||
|
if fields["tenant_id"] is None:
|
||||||
|
raise ValidationError(
|
||||||
|
"tenant scope requires tenant_id", code="TASK_SCOPE_TENANT_ID_REQUIRED"
|
||||||
|
)
|
||||||
|
elif declared == "legacy":
|
||||||
|
if any(fields.values()):
|
||||||
|
raise ValidationError(
|
||||||
|
"legacy scope cannot contain scoped ownership",
|
||||||
|
code="TASK_SCOPE_LEGACY_WITH_SCOPED_FIELDS",
|
||||||
|
)
|
||||||
|
elif declared == "system":
|
||||||
|
if any(fields[field] for field in ("tenant_id", "user_id", "project_id", "conversation_id")):
|
||||||
|
raise ValidationError(
|
||||||
|
"system scope cannot contain tenant ownership",
|
||||||
|
code="TASK_SCOPE_SYSTEM_WITH_TENANT_FIELDS",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValidationError("invalid task scope type", code="TASK_SCOPE_TYPE_INVALID")
|
||||||
|
return TaskScope(declared, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_is_privileged(actor: TaskActorContext) -> bool:
|
||||||
|
return bool(actor.capabilities & PRIVILEGED_CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_context(
|
||||||
|
identity: str,
|
||||||
|
configured: TaskActorContext | Mapping[str, Any] | None,
|
||||||
|
capabilities: set[str] | frozenset[str],
|
||||||
|
) -> TaskActorContext:
|
||||||
|
if isinstance(configured, TaskActorContext):
|
||||||
|
context = configured
|
||||||
|
else:
|
||||||
|
data = dict(configured or {})
|
||||||
|
data.pop("identity", None)
|
||||||
|
data.pop("capabilities", None)
|
||||||
|
context = TaskActorContext(identity=str(identity), **data)
|
||||||
|
scope = normalize_task_scope(context)
|
||||||
|
return replace(
|
||||||
|
context,
|
||||||
|
identity=str(identity),
|
||||||
|
scope_type=scope.scope_type,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
user_id=scope.user_id,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
conversation_id=scope.conversation_id,
|
||||||
|
run_id=scope.run_id,
|
||||||
|
capabilities=frozenset(str(item) for item in capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_task_scope(plan: Any, actor: TaskActorContext, *, public_create: bool) -> Any:
|
||||||
|
requested = normalize_task_scope(plan)
|
||||||
|
if actor_is_privileged(actor):
|
||||||
|
trusted = normalize_task_scope(actor)
|
||||||
|
if requested.scope_type == "legacy" and trusted.scope_type != "legacy":
|
||||||
|
plan.scope_type = trusted.scope_type
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
setattr(plan, field, getattr(actor, field))
|
||||||
|
return plan
|
||||||
|
trusted = normalize_task_scope(actor)
|
||||||
|
if public_create and trusted.scope_type == "legacy":
|
||||||
|
raise ForbiddenError(
|
||||||
|
"authenticated identity has no tenant scope",
|
||||||
|
code="TASK_ACTOR_SCOPE_UNAVAILABLE",
|
||||||
|
)
|
||||||
|
if trusted.scope_type != "tenant":
|
||||||
|
if requested.scope_type == "legacy":
|
||||||
|
plan.scope_type = trusted.scope_type
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
setattr(plan, field, getattr(actor, field))
|
||||||
|
return plan
|
||||||
|
if requested.scope_type != trusted.scope_type:
|
||||||
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||||
|
return plan
|
||||||
|
if requested.scope_type not in {"legacy", "tenant"}:
|
||||||
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||||
|
updates: dict[str, Any] = {"scope_type": "tenant"}
|
||||||
|
for field in SCOPE_FIELDS:
|
||||||
|
declared = getattr(requested, field)
|
||||||
|
authoritative = getattr(actor, field)
|
||||||
|
if declared is not None and declared != authoritative:
|
||||||
|
raise ForbiddenError(
|
||||||
|
"task scope mismatch",
|
||||||
|
code="TASK_SCOPE_MISMATCH_DENY",
|
||||||
|
details={"field": field},
|
||||||
|
)
|
||||||
|
updates[field] = authoritative
|
||||||
|
for field, item in updates.items():
|
||||||
|
setattr(plan, field, item)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def actor_scope_sql(actor: TaskActorContext | None) -> tuple[str, tuple[Any, ...]]:
|
||||||
|
if actor is None or actor_is_privileged(actor):
|
||||||
|
return "", ()
|
||||||
|
scope = normalize_task_scope(actor)
|
||||||
|
if scope.scope_type == "legacy":
|
||||||
|
return "scope_type = 'legacy'", ()
|
||||||
|
if scope.scope_type == "system":
|
||||||
|
return "scope_type = 'system'", ()
|
||||||
|
clauses = ["scope_type = 'tenant'", "tenant_id = ?"]
|
||||||
|
params: list[Any] = [scope.tenant_id]
|
||||||
|
for field in ("user_id", "project_id", "conversation_id", "run_id"):
|
||||||
|
value = getattr(scope, field)
|
||||||
|
if value is None:
|
||||||
|
clauses.append(f"{field} IS NULL")
|
||||||
|
else:
|
||||||
|
clauses.append(f"({field} IS NULL OR {field} = ?)")
|
||||||
|
params.append(value)
|
||||||
|
return " AND ".join(clauses), tuple(params)
|
||||||
|
|
||||||
|
|
||||||
|
def task_scope_values(value: Any) -> tuple[str | None, ...]:
|
||||||
|
scope = normalize_task_scope(value)
|
||||||
|
return (
|
||||||
|
scope.scope_type,
|
||||||
|
scope.tenant_id,
|
||||||
|
scope.user_id,
|
||||||
|
scope.project_id,
|
||||||
|
scope.conversation_id,
|
||||||
|
scope.run_id,
|
||||||
|
)
|
||||||
@@ -32,6 +32,7 @@ from .models import (
|
|||||||
redact_value,
|
redact_value,
|
||||||
validate_identifier,
|
validate_identifier,
|
||||||
)
|
)
|
||||||
|
from .scope import TaskActorContext, actor_scope_sql, task_scope_values
|
||||||
|
|
||||||
|
|
||||||
EventEnvelope = tuple[str, str, str, str, str, float]
|
EventEnvelope = tuple[str, str, str, str, str, float]
|
||||||
@@ -134,6 +135,12 @@ class TaskPlaneStore:
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
task_json TEXT NOT NULL,
|
task_json TEXT NOT NULL,
|
||||||
task_type TEXT NOT NULL,
|
task_type TEXT NOT NULL,
|
||||||
|
scope_type TEXT NOT NULL DEFAULT 'legacy',
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
root_task_id TEXT NOT NULL,
|
root_task_id TEXT NOT NULL,
|
||||||
parent_task_id TEXT,
|
parent_task_id TEXT,
|
||||||
correlation_id TEXT,
|
correlation_id TEXT,
|
||||||
@@ -272,6 +279,21 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._ensure_columns(
|
||||||
|
"tasks",
|
||||||
|
{
|
||||||
|
"scope_type": "TEXT NOT NULL DEFAULT 'legacy'",
|
||||||
|
"tenant_id": "TEXT",
|
||||||
|
"user_id": "TEXT",
|
||||||
|
"project_id": "TEXT",
|
||||||
|
"conversation_id": "TEXT",
|
||||||
|
"run_id": "TEXT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE tasks SET scope_type='legacy' "
|
||||||
|
"WHERE scope_type IS NULL OR TRIM(scope_type)=''"
|
||||||
|
)
|
||||||
self._ensure_columns(
|
self._ensure_columns(
|
||||||
"workers",
|
"workers",
|
||||||
{
|
{
|
||||||
@@ -299,6 +321,14 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_tenant_project "
|
||||||
|
"ON tasks(scope_type, tenant_id, project_id)"
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_owner "
|
||||||
|
"ON tasks(scope_type, tenant_id, user_id, conversation_id, run_id)"
|
||||||
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -347,6 +377,42 @@ class TaskPlaneStore:
|
|||||||
raise NotFoundError("task")
|
raise NotFoundError("task")
|
||||||
task_json = json.loads(row["task_json"])
|
task_json = json.loads(row["task_json"])
|
||||||
task_plan = TaskPlan.from_dict(task_json)
|
task_plan = TaskPlan.from_dict(task_json)
|
||||||
|
stored_scope = tuple(
|
||||||
|
row[field]
|
||||||
|
for field in (
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
json_has_scope = any(
|
||||||
|
field in task_json
|
||||||
|
for field in (
|
||||||
|
"scope_type",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"project_id",
|
||||||
|
"conversation_id",
|
||||||
|
"run_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if json_has_scope and stored_scope != task_scope_values(task_plan):
|
||||||
|
raise ConflictError(
|
||||||
|
"task scope persistence mismatch",
|
||||||
|
code="TASK_SCOPE_PERSISTENCE_MISMATCH",
|
||||||
|
)
|
||||||
|
if not json_has_scope:
|
||||||
|
(
|
||||||
|
task_plan.scope_type,
|
||||||
|
task_plan.tenant_id,
|
||||||
|
task_plan.user_id,
|
||||||
|
task_plan.project_id,
|
||||||
|
task_plan.conversation_id,
|
||||||
|
task_plan.run_id,
|
||||||
|
) = stored_scope
|
||||||
task_plan.parent_task_id = row["parent_task_id"]
|
task_plan.parent_task_id = row["parent_task_id"]
|
||||||
task_plan.correlation_id = row["correlation_id"]
|
task_plan.correlation_id = row["correlation_id"]
|
||||||
return TaskRecord(
|
return TaskRecord(
|
||||||
@@ -389,6 +455,7 @@ class TaskPlaneStore:
|
|||||||
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
||||||
task_plan.task_id = task_id
|
task_plan.task_id = task_id
|
||||||
task_json = redact_value(task_plan.to_dict(redact=False))
|
task_json = redact_value(task_plan.to_dict(redact=False))
|
||||||
|
scope_values = task_scope_values(task_plan)
|
||||||
now = _now()
|
now = _now()
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
@@ -397,7 +464,11 @@ class TaskPlaneStore:
|
|||||||
(idempotency_key,),
|
(idempotency_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
existing_plan = TaskPlan.from_dict(
|
||||||
|
json.loads(existing["task_json"])
|
||||||
|
)
|
||||||
|
existing_json = redact_value(existing_plan.to_dict(redact=False))
|
||||||
|
if _canonical(existing_json) != _canonical(task_json):
|
||||||
raise ConflictError("idempotency key payload mismatch")
|
raise ConflictError("idempotency key payload mismatch")
|
||||||
return self.get_task(existing["task_id"])
|
return self.get_task(existing["task_id"])
|
||||||
root_task_id = task_id
|
root_task_id = task_id
|
||||||
@@ -412,14 +483,18 @@ class TaskPlaneStore:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tasks
|
INSERT INTO tasks
|
||||||
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
(task_id, revision, status, task_json, task_type,
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
scope_type, tenant_id, user_id, project_id, conversation_id, run_id,
|
||||||
|
root_task_id, parent_task_id, correlation_id, idempotency_key,
|
||||||
|
created_at, updated_at, failure_reason)
|
||||||
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
task_id,
|
task_id,
|
||||||
"CREATED",
|
"CREATED",
|
||||||
json.dumps(task_json, ensure_ascii=False),
|
json.dumps(task_json, ensure_ascii=False),
|
||||||
task_plan.task_type,
|
task_plan.task_type,
|
||||||
|
*scope_values,
|
||||||
root_task_id,
|
root_task_id,
|
||||||
task_plan.parent_task_id,
|
task_plan.parent_task_id,
|
||||||
task_plan.correlation_id,
|
task_plan.correlation_id,
|
||||||
@@ -451,21 +526,30 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
return self.get_task(task.task_id)
|
return self.get_task(task.task_id)
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> TaskRecord:
|
def get_task(
|
||||||
|
self, task_id: str, actor_context: TaskActorContext | None = None
|
||||||
|
) -> TaskRecord:
|
||||||
task_id = _validated_id(task_id, "task_id")
|
task_id = _validated_id(task_id, "task_id")
|
||||||
|
predicate, scope_params = actor_scope_sql(actor_context)
|
||||||
|
sql = "SELECT * FROM tasks WHERE task_id=?"
|
||||||
|
if predicate:
|
||||||
|
sql += f" AND ({predicate})"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(sql, (task_id, *scope_params)).fetchone()
|
||||||
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError(f"task {task_id} not found")
|
raise NotFoundError(f"task {task_id} not found")
|
||||||
return self._row_to_task(row)
|
return self._row_to_task(row)
|
||||||
|
|
||||||
def list_tasks(self) -> list[TaskRecord]:
|
def list_tasks(
|
||||||
|
self, actor_context: TaskActorContext | None = None
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
predicate, scope_params = actor_scope_sql(actor_context)
|
||||||
|
sql = "SELECT * FROM tasks"
|
||||||
|
if predicate:
|
||||||
|
sql += f" WHERE ({predicate})"
|
||||||
|
sql += " ORDER BY created_at DESC"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(sql, scope_params).fetchall()
|
||||||
"SELECT * FROM tasks ORDER BY created_at DESC"
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_task(row) for row in rows]
|
return [self._row_to_task(row) for row in rows]
|
||||||
|
|
||||||
def get_task_budget(self, task_id: str) -> Budget:
|
def get_task_budget(self, task_id: str) -> Budget:
|
||||||
|
|||||||
@@ -0,0 +1,759 @@
|
|||||||
|
"""Scope-bound governance for traces, Tool/MCP calls, and credentials.
|
||||||
|
|
||||||
|
This module deliberately stores references and audit metadata only. Secret
|
||||||
|
material remains in the configured credential provider boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||||
|
PRIVILEGED_CAPABILITIES = frozenset({"*", "admin", "system.admin", "governance.admin"})
|
||||||
|
REFERENCE_RE = re.compile(r"^(systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||||
|
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$")
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceError(RuntimeError):
|
||||||
|
def __init__(self, code: str, *, field: str | None = None) -> None:
|
||||||
|
super().__init__(code if field is None else f"{code}:{field}")
|
||||||
|
self.code = code
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_tuple(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(",")
|
||||||
|
return tuple(sorted({_clean(item) for item in value if _clean(item)}))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier(value: Any, code: str) -> str:
|
||||||
|
text = _clean(value)
|
||||||
|
if text is None or IDENTIFIER_RE.fullmatch(text) is None:
|
||||||
|
raise GovernanceError(code)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GovernanceScope:
|
||||||
|
"""Explicit resource scope; system/global resources never get fake tenants."""
|
||||||
|
|
||||||
|
scope_type: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
project_id: str | None = None
|
||||||
|
conversation_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "scope_type", (_clean(self.scope_type) or "").lower())
|
||||||
|
for name in SCOPE_FIELDS:
|
||||||
|
object.__setattr__(self, name, _clean(getattr(self, name)))
|
||||||
|
if self.scope_type == "tenant":
|
||||||
|
if self.tenant_id is None:
|
||||||
|
raise GovernanceError("GOVERNANCE_TENANT_ID_REQUIRED")
|
||||||
|
elif self.scope_type in {"system", "global"}:
|
||||||
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||||
|
raise GovernanceError("GOVERNANCE_NON_TENANT_SCOPE_INVALID")
|
||||||
|
elif self.scope_type == "legacy":
|
||||||
|
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||||
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_INVALID")
|
||||||
|
else:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_TYPE_INVALID")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value: Any) -> "GovernanceScope":
|
||||||
|
def get(name: str) -> Any:
|
||||||
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||||
|
|
||||||
|
declared = _clean(get("scope_type"))
|
||||||
|
tenant_id = _clean(get("tenant_id"))
|
||||||
|
return cls(
|
||||||
|
scope_type=declared or ("tenant" if tenant_id else "legacy"),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=get("user_id"),
|
||||||
|
project_id=get("project_id"),
|
||||||
|
conversation_id=get("conversation_id"),
|
||||||
|
run_id=get("run_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GovernanceActor:
|
||||||
|
identity: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "identity", _identifier(self.identity, "GOVERNANCE_ACTOR_INVALID"))
|
||||||
|
object.__setattr__(self, "capabilities", _scope_tuple(self.capabilities))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value: Any) -> "GovernanceActor":
|
||||||
|
if isinstance(value, cls):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise GovernanceError("GOVERNANCE_ACTOR_INVALID")
|
||||||
|
return cls(
|
||||||
|
identity=value.get("identity") or value.get("actor_id") or value.get("user_id"),
|
||||||
|
scope=GovernanceScope.from_value(value),
|
||||||
|
capabilities=_scope_tuple(value.get("capabilities") or value.get("capability_scope")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privileged(self) -> bool:
|
||||||
|
return bool(set(self.capabilities) & PRIVILEGED_CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_scope(actor: GovernanceActor | Mapping[str, Any], resource: GovernanceScope | Mapping[str, Any]) -> None:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
resource = resource if isinstance(resource, GovernanceScope) else GovernanceScope.from_value(resource)
|
||||||
|
if actor.privileged:
|
||||||
|
return
|
||||||
|
if resource.scope_type in {"system", "global"}:
|
||||||
|
raise GovernanceError("GOVERNANCE_SYSTEM_SCOPE_AUTHZ_DENY")
|
||||||
|
if resource.scope_type == "legacy":
|
||||||
|
if actor.scope.scope_type != "legacy":
|
||||||
|
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_AUTHZ_DENY")
|
||||||
|
return
|
||||||
|
if actor.scope.scope_type != "tenant":
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field="scope_type")
|
||||||
|
for name in SCOPE_FIELDS:
|
||||||
|
expected = getattr(resource, name)
|
||||||
|
actual = getattr(actor.scope, name)
|
||||||
|
if expected is not None and expected != actual:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field=name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TraceContext:
|
||||||
|
trace_id: str
|
||||||
|
span_id: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
parent_span_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
claim_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "trace_id", _identifier(self.trace_id, "TRACE_ID_INVALID"))
|
||||||
|
object.__setattr__(self, "span_id", _identifier(self.span_id, "TRACE_SPAN_ID_INVALID"))
|
||||||
|
for name in ("parent_span_id", "correlation_id", "causation_id", "task_id", "claim_id", "message_id"):
|
||||||
|
value = _clean(getattr(self, name))
|
||||||
|
if value is not None and IDENTIFIER_RE.fullmatch(value) is None:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_IDENTIFIER_INVALID", field=name)
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
|
||||||
|
def child(self, *, span_id: str, actor: GovernanceActor | Mapping[str, Any], **lineage: Any) -> "TraceContext":
|
||||||
|
authorize_scope(actor, self.scope)
|
||||||
|
for name in ("trace_id", "task_id", "claim_id", "message_id", "correlation_id"):
|
||||||
|
supplied = _clean(lineage.get(name))
|
||||||
|
expected = getattr(self, name)
|
||||||
|
if supplied is not None and expected is not None and supplied != expected:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field=name)
|
||||||
|
requested_scope = lineage.pop("scope", self.scope)
|
||||||
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||||
|
if requested_scope != self.scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
return TraceContext(
|
||||||
|
trace_id=self.trace_id,
|
||||||
|
span_id=span_id,
|
||||||
|
parent_span_id=self.span_id,
|
||||||
|
correlation_id=self.correlation_id,
|
||||||
|
causation_id=_clean(lineage.get("causation_id")) or self.span_id,
|
||||||
|
task_id=self.task_id,
|
||||||
|
claim_id=self.claim_id,
|
||||||
|
message_id=self.message_id,
|
||||||
|
scope=self.scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"span_id": self.span_id,
|
||||||
|
"parent_span_id": self.parent_span_id,
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"causation_id": self.causation_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"claim_id": self.claim_id,
|
||||||
|
"message_id": self.message_id,
|
||||||
|
**self.scope.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bind_trace_lineage(
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
task: Any,
|
||||||
|
message: Any,
|
||||||
|
trace_id: str,
|
||||||
|
span_id: str,
|
||||||
|
parent: TraceContext | None = None,
|
||||||
|
) -> TraceContext:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
task_scope = GovernanceScope.from_value(task)
|
||||||
|
message_scope = GovernanceScope.from_value(message)
|
||||||
|
if task_scope != message_scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
authorize_scope(actor, task_scope)
|
||||||
|
|
||||||
|
def get(value: Any, name: str) -> Any:
|
||||||
|
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||||
|
|
||||||
|
task_id = _clean(get(task, "task_id"))
|
||||||
|
message_task = _clean(get(message, "task_id"))
|
||||||
|
if task_id and message_task and task_id != message_task:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||||
|
context = TraceContext(
|
||||||
|
trace_id=trace_id,
|
||||||
|
span_id=span_id,
|
||||||
|
parent_span_id=parent.span_id if parent else None,
|
||||||
|
correlation_id=_clean(get(task, "correlation_id")) or _clean(get(message, "correlation_id")),
|
||||||
|
causation_id=_clean(get(message, "causation_id")) or (parent.span_id if parent else None),
|
||||||
|
task_id=task_id or message_task,
|
||||||
|
claim_id=_clean(get(task, "claim_id")) or _clean(get(message, "claim_id")),
|
||||||
|
message_id=_clean(get(message, "envelope_id")) or _clean(get(message, "id")),
|
||||||
|
scope=task_scope,
|
||||||
|
)
|
||||||
|
if parent is not None:
|
||||||
|
if parent.trace_id != context.trace_id or parent.scope != context.scope:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY")
|
||||||
|
if parent.task_id and context.task_id and parent.task_id != context.task_id:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolDescriptor:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
required_capabilities: tuple[str, ...] = ()
|
||||||
|
credential_ref: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "name", _identifier(self.name, "TOOL_NAME_INVALID"))
|
||||||
|
object.__setattr__(self, "kind", (_clean(self.kind) or "").lower())
|
||||||
|
if self.kind not in {"tool", "mcp"}:
|
||||||
|
raise GovernanceError("TOOL_KIND_INVALID")
|
||||||
|
object.__setattr__(self, "required_capabilities", _scope_tuple(self.required_capabilities))
|
||||||
|
if self.credential_ref is not None and REFERENCE_RE.fullmatch(self.credential_ref) is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CredentialGrant:
|
||||||
|
grant_id: str
|
||||||
|
credential_ref: str
|
||||||
|
scope: GovernanceScope
|
||||||
|
operations: tuple[str, ...]
|
||||||
|
task_id: str | None = None
|
||||||
|
tool_name: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "grant_id", _identifier(self.grant_id, "CREDENTIAL_GRANT_ID_INVALID"))
|
||||||
|
if REFERENCE_RE.fullmatch(self.credential_ref or "") is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||||
|
object.__setattr__(self, "operations", _scope_tuple(self.operations))
|
||||||
|
if not self.operations:
|
||||||
|
raise GovernanceError("CREDENTIAL_OPERATIONS_REQUIRED")
|
||||||
|
if self.expires_at is not None:
|
||||||
|
parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_EXPIRY_INVALID")
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
task_id: str | None = None,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
authorize_scope(actor, self.scope)
|
||||||
|
except GovernanceError as exc:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field=exc.field) from exc
|
||||||
|
if operation not in self.operations:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="operation")
|
||||||
|
if self.task_id is not None and self.task_id != _clean(task_id):
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="task_id")
|
||||||
|
if self.tool_name is not None and self.tool_name != _clean(tool_name):
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="tool_name")
|
||||||
|
if self.expires_at is not None:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
expiry = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
if current >= expiry:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="expires_at")
|
||||||
|
return self.credential_ref
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"grant_id": self.grant_id,
|
||||||
|
"credential_ref": self.credential_ref,
|
||||||
|
"operations": list(self.operations),
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"tool_name": self.tool_name,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
**self.scope.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS governance_schema_metadata (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||||
|
schema_version INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_tools (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
scope_json TEXT NOT NULL,
|
||||||
|
required_capabilities_json TEXT NOT NULL,
|
||||||
|
credential_ref TEXT,
|
||||||
|
descriptor_sha256 TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_trace_spans (
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
span_id TEXT NOT NULL,
|
||||||
|
parent_span_id TEXT,
|
||||||
|
context_json TEXT NOT NULL,
|
||||||
|
context_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(trace_id, span_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_credential_grants (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
grant_json TEXT NOT NULL,
|
||||||
|
grant_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governed_tool_invocations (
|
||||||
|
invocation_key TEXT PRIMARY KEY,
|
||||||
|
request_sha256 TEXT NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
claim_id TEXT NOT NULL,
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
span_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
receipt_sha256 TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS governance_audit_evidence (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
evidence_id TEXT NOT NULL UNIQUE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actor_identity TEXT NOT NULL,
|
||||||
|
trace_id TEXT,
|
||||||
|
span_id TEXT,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
previous_sha256 TEXT NOT NULL,
|
||||||
|
evidence_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceStore:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connection() as connection:
|
||||||
|
has_metadata = connection.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='governance_schema_metadata'"
|
||||||
|
).fetchone()
|
||||||
|
if has_metadata:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and int(row[0]) != 1:
|
||||||
|
raise GovernanceError("GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED")
|
||||||
|
connection.executescript(_SCHEMA)
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,1)"
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self):
|
||||||
|
connection = self._connect()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor: GovernanceActor,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
trace: TraceContext | None,
|
||||||
|
details: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
previous = connection.execute(
|
||||||
|
"SELECT evidence_sha256 FROM governance_audit_evidence ORDER BY sequence DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
previous_sha = str(previous[0]) if previous else ""
|
||||||
|
created_at = _now()
|
||||||
|
material = {
|
||||||
|
"action": action,
|
||||||
|
"actor_identity": actor.identity,
|
||||||
|
"trace_id": trace.trace_id if trace else None,
|
||||||
|
"span_id": trace.span_id if trace else None,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
"outcome": outcome,
|
||||||
|
"details": dict(details),
|
||||||
|
"previous_sha256": previous_sha,
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
digest = _sha(material)
|
||||||
|
evidence_id = "gev-" + digest[:32]
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO governance_audit_evidence
|
||||||
|
(evidence_id,action,actor_identity,trace_id,span_id,resource_id,outcome,
|
||||||
|
details_json,previous_sha256,evidence_sha256,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
evidence_id, action, actor.identity, material["trace_id"], material["span_id"],
|
||||||
|
resource_id, outcome, _canonical(material["details"]), previous_sha, digest, created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"evidence_id": evidence_id, "evidence_sha256": digest, **material}
|
||||||
|
|
||||||
|
def record_trace(self, trace: TraceContext, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
authorize_scope(actor, trace.scope)
|
||||||
|
context = trace.as_dict()
|
||||||
|
digest = _sha(context)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.span_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONFLICT")
|
||||||
|
if existing is not None:
|
||||||
|
return {"context": context, "context_sha256": digest, "idempotent": True}
|
||||||
|
if trace.parent_span_id:
|
||||||
|
parent = connection.execute(
|
||||||
|
"SELECT context_json FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.parent_span_id),
|
||||||
|
).fetchone()
|
||||||
|
if parent is None:
|
||||||
|
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="parent_span_id")
|
||||||
|
parent_context = json.loads(parent[0])
|
||||||
|
if GovernanceScope.from_value(parent_context) != trace.scope:
|
||||||
|
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_trace_spans VALUES (?,?,?,?,?,?)",
|
||||||
|
(trace.trace_id, trace.span_id, trace.parent_span_id, _canonical(context), digest, _now()),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TRACE_RECORDED", actor=actor,
|
||||||
|
resource_id=f"{trace.trace_id}:{trace.span_id}", outcome="ALLOW", trace=trace,
|
||||||
|
details={"context_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"context": context, "context_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def register_tool(self, descriptor: ToolDescriptor, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
if not actor.privileged:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
public = {
|
||||||
|
"name": descriptor.name,
|
||||||
|
"kind": descriptor.kind,
|
||||||
|
"required_capabilities": list(descriptor.required_capabilities),
|
||||||
|
"credential_ref": descriptor.credential_ref,
|
||||||
|
**descriptor.scope.as_dict(),
|
||||||
|
}
|
||||||
|
digest = _sha(public)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT descriptor_sha256 FROM governed_tools WHERE name=?", (descriptor.name,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("TOOL_DESCRIPTOR_CONFLICT")
|
||||||
|
if existing is not None:
|
||||||
|
return {"descriptor": public, "descriptor_sha256": digest, "idempotent": True}
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_tools VALUES (?,?,?,?,?,?)",
|
||||||
|
(descriptor.name, descriptor.kind, _canonical(descriptor.scope.as_dict()),
|
||||||
|
_canonical(descriptor.required_capabilities), descriptor.credential_ref, digest),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_REGISTERED", actor=actor, resource_id=descriptor.name,
|
||||||
|
outcome="ALLOW", trace=None, details={"descriptor_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"descriptor": public, "descriptor_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def authorize_tool(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
tool_name: str,
|
||||||
|
trace: TraceContext,
|
||||||
|
requested_scope: GovernanceScope | Mapping[str, Any],
|
||||||
|
input_digest: str,
|
||||||
|
invocation_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", input_digest):
|
||||||
|
raise GovernanceError("TOOL_INPUT_DIGEST_INVALID")
|
||||||
|
if trace.task_id is None or trace.claim_id is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="active_claim")
|
||||||
|
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||||
|
if requested_scope != trace.scope:
|
||||||
|
raise GovernanceError("TOOL_SCOPE_PROPAGATION")
|
||||||
|
authorize_scope(actor, requested_scope)
|
||||||
|
with self._connection() as connection:
|
||||||
|
persisted_trace = connection.execute(
|
||||||
|
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||||
|
(trace.trace_id, trace.span_id),
|
||||||
|
).fetchone()
|
||||||
|
if persisted_trace is None or persisted_trace[0] != _sha(trace.as_dict()):
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="trace")
|
||||||
|
row = connection.execute("SELECT * FROM governed_tools WHERE name=?", (tool_name,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
descriptor_scope = GovernanceScope.from_value(json.loads(row["scope_json"]))
|
||||||
|
if descriptor_scope.scope_type == "tenant" and descriptor_scope != requested_scope:
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
required = set(json.loads(row["required_capabilities_json"]))
|
||||||
|
if not required.issubset(set(actor.capabilities)):
|
||||||
|
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||||
|
request_sha = _sha({
|
||||||
|
"tool_name": tool_name, "input_digest": input_digest,
|
||||||
|
"task_id": trace.task_id, "claim_id": trace.claim_id,
|
||||||
|
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||||
|
"scope": requested_scope.as_dict(),
|
||||||
|
})
|
||||||
|
replay = connection.execute(
|
||||||
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||||
|
(invocation_key,),
|
||||||
|
).fetchone()
|
||||||
|
if replay is not None:
|
||||||
|
if replay["request_sha256"] != request_sha:
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_IDEMPOTENCY_CONFLICT")
|
||||||
|
if replay["status"] != "VERIFIED":
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||||
|
return {
|
||||||
|
"tool_name": tool_name, "trace_id": trace.trace_id,
|
||||||
|
"span_id": trace.span_id, "invocation_key": invocation_key,
|
||||||
|
"status": "VERIFIED", "receipt_sha256": replay["receipt_sha256"],
|
||||||
|
"idempotent": True,
|
||||||
|
}
|
||||||
|
created_at = _now()
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO governed_tool_invocations
|
||||||
|
(invocation_key,request_sha256,tool_name,task_id,claim_id,trace_id,span_id,
|
||||||
|
status,receipt_sha256,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(invocation_key, request_sha, tool_name, trace.task_id, trace.claim_id,
|
||||||
|
trace.trace_id, trace.span_id, "AUTHORIZED", None, created_at, created_at),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_AUTHORIZED", actor=actor, resource_id=tool_name,
|
||||||
|
outcome="ALLOW", trace=trace,
|
||||||
|
details={"input_sha256": input_digest, "scope_sha256": _sha(requested_scope.as_dict()),
|
||||||
|
"invocation_key": invocation_key},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"trace_id": trace.trace_id,
|
||||||
|
"span_id": trace.span_id,
|
||||||
|
"invocation_key": invocation_key,
|
||||||
|
"status": "AUTHORIZED",
|
||||||
|
"credential_required": row["credential_ref"] is not None,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_tool_receipt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invocation_key: str,
|
||||||
|
result_digest: str,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
trace: TraceContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", result_digest):
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_DIGEST_INVALID")
|
||||||
|
authorize_scope(actor, trace.scope)
|
||||||
|
with self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||||
|
(invocation_key,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or row["trace_id"] != trace.trace_id or row["span_id"] != trace.span_id:
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_BINDING_INVALID")
|
||||||
|
if row["status"] == "VERIFIED":
|
||||||
|
if row["receipt_sha256"] != result_digest:
|
||||||
|
raise GovernanceError("TOOL_RECEIPT_CONFLICT")
|
||||||
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "idempotent": True}
|
||||||
|
if row["status"] != "AUTHORIZED":
|
||||||
|
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE governed_tool_invocations SET status='VERIFIED',receipt_sha256=?,updated_at=? "
|
||||||
|
"WHERE invocation_key=? AND status='AUTHORIZED'",
|
||||||
|
(result_digest, _now(), invocation_key),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="TOOL_RESULT_VERIFIED", actor=actor,
|
||||||
|
resource_id=row["tool_name"], outcome="ALLOW", trace=trace,
|
||||||
|
details={"invocation_key": invocation_key, "result_sha256": result_digest},
|
||||||
|
)
|
||||||
|
return {"invocation_key": invocation_key, "status": "VERIFIED", "evidence": evidence}
|
||||||
|
|
||||||
|
def store_grant(self, grant: CredentialGrant, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
if not actor.privileged:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
public = grant.public()
|
||||||
|
digest = _sha(public)
|
||||||
|
with self._connection() as connection:
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT grant_sha256 FROM governed_credential_grants WHERE grant_id=?", (grant.grant_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None and existing[0] != digest:
|
||||||
|
raise GovernanceError("CREDENTIAL_GRANT_CONFLICT")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO governed_credential_grants VALUES (?,?,?,?)",
|
||||||
|
(grant.grant_id, _canonical(public), digest, _now()),
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="CREDENTIAL_GRANT_STORED", actor=actor,
|
||||||
|
resource_id=grant.grant_id, outcome="ALLOW", trace=None,
|
||||||
|
details={"grant_sha256": digest},
|
||||||
|
)
|
||||||
|
return {"grant": public, "grant_sha256": digest, "evidence": evidence}
|
||||||
|
|
||||||
|
def authorize_credential(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
grant_id: str,
|
||||||
|
actor: GovernanceActor | Mapping[str, Any],
|
||||||
|
trace: TraceContext,
|
||||||
|
operation: str,
|
||||||
|
task_id: str | None,
|
||||||
|
tool_name: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
actor = GovernanceActor.from_value(actor)
|
||||||
|
with self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT grant_json FROM governed_credential_grants WHERE grant_id=?", (grant_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||||
|
raw = json.loads(row[0])
|
||||||
|
grant = CredentialGrant(
|
||||||
|
grant_id=raw["grant_id"], credential_ref=raw["credential_ref"],
|
||||||
|
scope=GovernanceScope.from_value(raw), operations=tuple(raw["operations"]),
|
||||||
|
task_id=raw.get("task_id"), tool_name=raw.get("tool_name"),
|
||||||
|
expires_at=raw.get("expires_at"),
|
||||||
|
)
|
||||||
|
if trace.scope != grant.scope:
|
||||||
|
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="trace_scope")
|
||||||
|
reference = grant.authorize(
|
||||||
|
actor, operation=operation, task_id=task_id, tool_name=tool_name, now=now
|
||||||
|
)
|
||||||
|
evidence = self._audit(
|
||||||
|
connection, action="CREDENTIAL_AUTHORIZED", actor=actor,
|
||||||
|
resource_id=grant_id, outcome="ALLOW", trace=trace,
|
||||||
|
details={"operation": operation, "task_id": task_id, "tool_name": tool_name,
|
||||||
|
"credential_ref_sha256": _sha(reference)},
|
||||||
|
)
|
||||||
|
return {"credential_ref": reference, "evidence": evidence}
|
||||||
|
|
||||||
|
def verify_audit_chain(self, required_actions: Iterable[str] = ()) -> bool:
|
||||||
|
ordered_actions: list[str] = []
|
||||||
|
previous = ""
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute("SELECT * FROM governance_audit_evidence ORDER BY sequence").fetchall()
|
||||||
|
for row in rows:
|
||||||
|
details = json.loads(row["details_json"])
|
||||||
|
material = {
|
||||||
|
"action": row["action"], "actor_identity": row["actor_identity"],
|
||||||
|
"trace_id": row["trace_id"], "span_id": row["span_id"],
|
||||||
|
"resource_id": row["resource_id"], "outcome": row["outcome"],
|
||||||
|
"details": details, "previous_sha256": row["previous_sha256"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
if row["previous_sha256"] != previous or row["evidence_sha256"] != _sha(material):
|
||||||
|
return False
|
||||||
|
previous = row["evidence_sha256"]
|
||||||
|
ordered_actions.append(row["action"])
|
||||||
|
position = 0
|
||||||
|
for required in required_actions:
|
||||||
|
try:
|
||||||
|
position = ordered_actions.index(required, position) + 1
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CredentialGrant", "GovernanceActor", "GovernanceError", "GovernanceScope",
|
||||||
|
"GovernanceStore", "ToolDescriptor", "TraceContext", "authorize_scope",
|
||||||
|
"bind_trace_lineage",
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- R4.2 P0-B durable Message/Handoff Fabric.
|
||||||
|
-- Forward-only. The existing 20260707 message_outbox remains a compatibility source.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||||
|
envelope_id TEXT PRIMARY KEY,
|
||||||
|
stream_key TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
conversation_id TEXT,
|
||||||
|
task_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
causation_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||||
|
capability_scope_json TEXT NOT NULL,
|
||||||
|
confidentiality_scope TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
delivered_at TEXT,
|
||||||
|
acked_at TEXT,
|
||||||
|
UNIQUE(stream_key, idempotency_key),
|
||||||
|
UNIQUE(stream_key, sequence)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||||
|
ON message_handoff(target, status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||||
|
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS owner-controlled Task retry-extension API
|
||||||
|
After=aios-agent-task-api.service
|
||||||
|
Wants=aios-agent-task-api.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-agent-task
|
||||||
|
Group=aios-agent-task
|
||||||
|
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||||
|
EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env
|
||||||
|
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||||
|
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||||
|
Environment=AIOS_TASK_RETRY_HOST=127.0.0.1
|
||||||
|
Environment=AIOS_TASK_RETRY_PORT=18941
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_retry_extension.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||||
|
ReadWritePaths=/var/lib/aios-agent-task
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS uncredentialed deterministic Planner/Executor
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-executor
|
||||||
|
Group=aios-executor-exchange
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_isolated_executor.py --root /var/lib/aios-online-agent-executor
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateNetwork=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||||
|
IPAddressDeny=any
|
||||||
|
IPAddressAllow=localhost
|
||||||
|
InaccessiblePaths=/run/credentials
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-executor
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS governed Online Agent Worker
|
||||||
|
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-17.2-durable
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker
|
||||||
|
Environment=AIOS_WORKER_CHECKPOINT_DB=/var/lib/aios-online-agent-worker/checkpoints.sqlite3
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-worker
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
g aios-executor-exchange -
|
||||||
|
u aios-engineering-executor - "AIOS isolated engineering executor" /var/lib/aios-online-agent-executor /usr/sbin/nologin
|
||||||
|
m aios-engineering-executor aios-executor-exchange
|
||||||
|
m aios-engineering-worker aios-executor-exchange
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
d /var/lib/aios-online-agent-executor 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/requests 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/results 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/failed 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/sources 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# R4.2 P0-A TaskPlane Scope Isolation Design
|
||||||
|
|
||||||
|
Status: approved candidate design. This document is not evidence of a protected-main merge or production deployment.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
- Task: `r42-p0a-taskplane-scope-isolation-20260816`
|
||||||
|
- Governed Job: `job-3fd743b51971c6007923699c1233f84c`
|
||||||
|
- Repository: `aios/aios`
|
||||||
|
- Base: `0eb6e1a864edfdaeef37b438991707a110e21558`
|
||||||
|
- Target branch: `agent/r42-p0a-taskplane-scope-isolation-20260816`
|
||||||
|
- Generation: `1`
|
||||||
|
|
||||||
|
The implementation reuses this exact boundary. It does not create a replacement Task, Job, branch, generation, or idempotency key.
|
||||||
|
|
||||||
|
## Model and invariants
|
||||||
|
|
||||||
|
`TaskPlan` gains first-class `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` fields. The fields participate in accepted-key validation, parsing, serialization, persistence, and authorization.
|
||||||
|
|
||||||
|
Historical rows without scope data normalize to `scope_type=legacy`; no tenant is inferred. Tenant-scoped tasks require a non-empty `tenant_id`. System/global resources use an explicit `scope_type=system` invariant and do not receive a fabricated tenant. A task may add user, project, conversation, and run ownership within its tenant.
|
||||||
|
|
||||||
|
## Trusted ActorContext
|
||||||
|
|
||||||
|
TaskPlane defines a small immutable `TaskActorContext` used only for authorization. The HTTP authenticator returns an identity. `PolicyEngine` resolves that identity through server-owned policy configuration into the effective context and capabilities. Request fields such as `tenant_id`, `user_id`, `group_id`, and `role` are declarations only and never expand authority.
|
||||||
|
|
||||||
|
Public create, list, and get paths always use the resolved context. Internal compatibility calls may omit a context and retain explicit legacy behavior, but the HTTP adapter never uses that path.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
SQLite remains the current runtime. `_create_schema()` performs an idempotent forward migration that adds the six scope columns and indexes, then backfills only missing `scope_type` values to `legacy`.
|
||||||
|
|
||||||
|
New writes persist scope in both the canonical `task_json` representation and first-class columns. Reads accept historical JSON without scope, using legacy columns, but reject a new-row mismatch between JSON and columns. SQL list/get predicates are parameterized and applied before row hydration.
|
||||||
|
|
||||||
|
PostgreSQL remains a separate migration target and is not claimed by this change.
|
||||||
|
|
||||||
|
## Authorization flow
|
||||||
|
|
||||||
|
1. HTTP bearer authentication yields an identity.
|
||||||
|
2. `PolicyEngine` verifies capability and resolves the identity to `TaskActorContext`.
|
||||||
|
3. Create binds the requested scope to the trusted context and rejects mismatches.
|
||||||
|
4. List/get pass the trusted context to `TaskPlaneStore`.
|
||||||
|
5. Store applies tenant and ownership predicates in SQL; privileged capabilities may explicitly cross scope.
|
||||||
|
|
||||||
|
Tenant actors cannot read legacy tasks, another tenant, or mismatched owned resources. Legacy actors can only read legacy tasks. System actors require explicit privileged capability for cross-scope access.
|
||||||
|
|
||||||
|
## Failure behavior
|
||||||
|
|
||||||
|
Missing tenant scope, forged scope, unknown scope types, JSON/column divergence, and unauthorized list/get operations fail closed with canonical TaskPlane errors. Public get does not reveal whether an out-of-scope task exists.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests exercise real model round trips, SQLite migration/restart, Service/Policy/Store wiring, tenant A versus tenant B list/get denial, ownership filtering, forged create payload rejection, legacy isolation, privileged access, and SQL parameterization. Existing TaskPlane API, RBAC, recovery/security, and security-contract suites remain mandatory.
|
||||||
|
|
||||||
|
The candidate is complete only after real `TASK_CLAIMED`, `TASK_HEARTBEAT`, exact WriterLease, governed commit, OID convergence, result submission, and `TASK_VERIFIED`. Protected main and production remain unchanged.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# R4.2 P0-C Trace / Tool-MCP / Credential Governance
|
||||||
|
|
||||||
|
## Status boundary
|
||||||
|
|
||||||
|
This change is a candidate implementation on the governed P0-C branch. It does
|
||||||
|
not promote protected `main`, deploy Production, or assert that all R4.2 target
|
||||||
|
architecture is complete.
|
||||||
|
|
||||||
|
## Scope model
|
||||||
|
|
||||||
|
Governance resources use an explicit `scope_type`:
|
||||||
|
|
||||||
|
- `tenant` requires `tenant_id` and may narrow to user, project,
|
||||||
|
conversation, and run;
|
||||||
|
- `system` and `global` prohibit tenant ownership fields;
|
||||||
|
- `legacy` preserves unscoped historical records without inventing ownership.
|
||||||
|
|
||||||
|
System and global tools therefore never receive a synthetic tenant merely to
|
||||||
|
satisfy a tenant-oriented schema.
|
||||||
|
|
||||||
|
## Trace lineage
|
||||||
|
|
||||||
|
`TaskPlan` and `MessageEnvelope` scope is bound to a `TraceContext`. The trace
|
||||||
|
records `trace_id`, `span_id`, parent span, correlation, causation, task,
|
||||||
|
message, and the canonical P0-A scope. Child spans must keep the parent's trace,
|
||||||
|
task, and scope. Privileged visibility does not permit cross-scope trace
|
||||||
|
splicing.
|
||||||
|
|
||||||
|
`MessageEnvelope.correlation_id` is persisted and identity-bound. Existing
|
||||||
|
handoff databases are upgraded in place with an idempotent column migration;
|
||||||
|
legacy rows retain their prior event IDs because a null correlation field is
|
||||||
|
not added to the historical identity material.
|
||||||
|
|
||||||
|
Observability keeps its legacy presentation fields, but new governed records
|
||||||
|
also carry immutable trace and scope fields. Scoped records require a trusted
|
||||||
|
server actor for queries. A repeated execution ID is an idempotent replay only
|
||||||
|
when the complete record matches; conflicting overwrites are denied.
|
||||||
|
|
||||||
|
## Tool and MCP governance
|
||||||
|
|
||||||
|
`ToolDescriptor` binds a tool or MCP server to:
|
||||||
|
|
||||||
|
- an explicit resource scope;
|
||||||
|
- required actor capabilities;
|
||||||
|
- an optional credential reference.
|
||||||
|
|
||||||
|
Invocation authorization requires exact propagation of the trace scope and a
|
||||||
|
capability superset. Unknown tools, cross-tenant calls, scope substitution, and
|
||||||
|
non-privileged access to system/global resources fail closed. Every successful
|
||||||
|
registration and invocation appends hash-chained audit evidence.
|
||||||
|
|
||||||
|
## Credential governance
|
||||||
|
|
||||||
|
Credentials are represented only by references whose schemes are
|
||||||
|
`systemd-credential`, `vault`, or `secret-manager`. Raw bearer tokens, passwords,
|
||||||
|
and arbitrary values are rejected. Grants are bound to scope, operation, task,
|
||||||
|
tool, and expiry. Public/audit records contain a reference hash where disclosure
|
||||||
|
is unnecessary; secret material is never persisted by this module.
|
||||||
|
|
||||||
|
The existing runtime credential broker remains backward compatible for system
|
||||||
|
leases, adds explicit tenant/system/global invariants, and rejects providers
|
||||||
|
that attempt to return raw token/secret/password environment values instead of
|
||||||
|
file/reference indirection.
|
||||||
|
|
||||||
|
## Durable evidence
|
||||||
|
|
||||||
|
The SQLite governance store is restart-safe and idempotently initializes four
|
||||||
|
tables: governed tools, trace spans, credential grants, and audit evidence.
|
||||||
|
Audit entries form a SHA-256 chain. Acceptance requires the chain to verify and
|
||||||
|
contain the required actions; missing evidence is not treated as success.
|
||||||
|
|
||||||
|
## Acceptance tests
|
||||||
|
|
||||||
|
The candidate must pass:
|
||||||
|
|
||||||
|
- `TRACE_LINEAGE_CONTINUITY`;
|
||||||
|
- `TRACE_SCOPE_MISMATCH_DENY`;
|
||||||
|
- `TOOL_MCP_AUTHZ_DENY`;
|
||||||
|
- `TOOL_SCOPE_PROPAGATION`;
|
||||||
|
- `CREDENTIAL_SCOPE_DENY`;
|
||||||
|
- `CREDENTIAL_SECRET_EXPOSURE_DENY`;
|
||||||
|
- `AUDIT_EVIDENCE_COMPLETENESS`;
|
||||||
|
- P0-A scope and P0-B handoff regression suites;
|
||||||
|
- Python compilation, `git diff --check`, and a diff-scoped secret scan.
|
||||||
|
|
||||||
|
The repository-wide baseline currently has seven unrelated failures in the
|
||||||
|
isolated host environment (five stale continuation-contract fixtures and two
|
||||||
|
host-permission fixtures). The same seven failures reproduce at the untouched
|
||||||
|
`a642b045775b9154a00ae78543037fbef4fb447b` base; they are not suppressed or
|
||||||
|
modified by P0-C.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# R4.2 P1 — Durable Online Agent Execution
|
||||||
|
|
||||||
|
Status: candidate implementation. This document does not imply protected-main promotion or Production deployment.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
|
||||||
|
The generic worker uses two local durable layers:
|
||||||
|
|
||||||
|
1. `tasks/<task_id>.json`: atomic fast-state cache for compatibility and quick restart.
|
||||||
|
2. `checkpoints.sqlite3`: append-only attempt/phase journal with `synchronous=FULL`.
|
||||||
|
|
||||||
|
Checkpoint identity is bound to:
|
||||||
|
|
||||||
|
- task_id
|
||||||
|
- attempt
|
||||||
|
- execution_digest
|
||||||
|
- job_id
|
||||||
|
- repository
|
||||||
|
- base_oid
|
||||||
|
- expected_branch_oid
|
||||||
|
- target_branch
|
||||||
|
- generation
|
||||||
|
|
||||||
|
A checkpoint from another repository/branch/generation is never reused.
|
||||||
|
|
||||||
|
Phases are monotonic within an attempt:
|
||||||
|
|
||||||
|
`claimed → workspace_prepared → verification_passed → source_capture_done|commit_done → result_pending → result_submitted`
|
||||||
|
|
||||||
|
`reconciliation_required` is an explicit recovery phase.
|
||||||
|
|
||||||
|
## Attempt-aware result idempotency
|
||||||
|
|
||||||
|
Result submission idempotency keys include the official Task attempt and the execution digest. The same
|
||||||
|
attempt reuses the same key after a timeout/restart; a later official attempt cannot collide with an
|
||||||
|
earlier result.
|
||||||
|
|
||||||
|
Before submitting a result the worker durably writes `result_pending`, including the result hash and
|
||||||
|
idempotency key. After a successful or exact-idempotency-reconciled API response it writes
|
||||||
|
`result_submitted`.
|
||||||
|
|
||||||
|
A 409 is accepted only if the Task API already contains the exact same result body and the Task is
|
||||||
|
VERIFYING/SUCCEEDED. A conflicting result remains a hard failure.
|
||||||
|
|
||||||
|
## Resume rules
|
||||||
|
|
||||||
|
- `source_capture_done` and `result_pending` may be reused across later official attempts only when the
|
||||||
|
execution/scope binding is identical. This avoids repeating read-only source capture.
|
||||||
|
- A deterministic task that crashed before governed commit replays from a new clean detached worktree.
|
||||||
|
This is safe because there is no external side effect yet.
|
||||||
|
- `commit_done` is never recommitted. Recovery requires exact Job receipt OID convergence plus exactly
|
||||||
|
one ACTIVE WriterLease whose `current_head_oid` equals the governed commit.
|
||||||
|
- delayed Git visibility remains `RECONCILIATION_REQUIRED`, preserving an explicit recovery entry.
|
||||||
|
|
||||||
|
## Crash cleanup
|
||||||
|
|
||||||
|
On worker startup, stale `task-*` workspaces under the configured workspace root are removed before
|
||||||
|
polling begins. The path boundary is verified before deletion.
|
||||||
|
|
||||||
|
## Contracts
|
||||||
|
|
||||||
|
P1 gates include:
|
||||||
|
|
||||||
|
- CHECKPOINT_DURABLE_WRITE
|
||||||
|
- RESUME_FROM_CHECKPOINT
|
||||||
|
- RETRY_IDEMPOTENT_NO_DUPLICATE_SIDE_EFFECT
|
||||||
|
- CRASH_RECOVERY_CONVERGENCE
|
||||||
|
- WORKER_RESTART_RESUME
|
||||||
|
- CHECKPOINT_SCOPE_ISOLATION
|
||||||
|
- RESULT_SUBMISSION_REPLAY_SAFE
|
||||||
|
- WORKSPACE_CLEANUP_RECOVERY
|
||||||
|
- LEASE_RENEWAL_RECOVERY
|
||||||
|
|
||||||
|
WriterLease and governed Job authority remain in the existing Bridge/GitOps durable stores; the worker
|
||||||
|
checkpoint DB is not a competing authority for Git state.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# R4.2 P0-A TaskPlane Scope Isolation
|
||||||
|
|
||||||
|
Status: candidate implementation. This is not proof of protected-main promotion or production deployment.
|
||||||
|
|
||||||
|
Task scope is now represented by `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` in the model and SQLite persistence. Historical rows become `legacy`; their tenant is never guessed. Tenant tasks require `tenant_id`. System resources use `scope_type=system` without a fabricated tenant.
|
||||||
|
|
||||||
|
The HTTP adapter uses the authenticated identity. `PolicyEngine` resolves that identity through server-owned `identity_actor_contexts`; request payload fields do not grant scope. Public create binds the trusted scope, and public list/get apply parameterized scope predicates in `TaskPlaneStore`.
|
||||||
|
|
||||||
|
SQLite is the current runtime database. PostgreSQL remains a separate migration and runtime-acceptance gate.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# R4.2 P0-B — Durable Message / Handoff Fabric
|
||||||
|
|
||||||
|
Status: candidate implementation; not evidence of protected-main merge or Production deployment.
|
||||||
|
|
||||||
|
`MessageEnvelope` is the single durable handoff identity and carries source/target/type, tenant/user/
|
||||||
|
project/conversation/task/run scope, causation, sequence, capability scope, confidentiality scope,
|
||||||
|
idempotency key, expiry and delivery state.
|
||||||
|
|
||||||
|
Identity includes **target + scope + sequence + idempotency key + payload hash**, fixing the existing
|
||||||
|
AgentEventBus target collision. Server ActorContext is authoritative; caller scope may narrow but may
|
||||||
|
not widen/forge trusted tenant/user/project/conversation/run fields.
|
||||||
|
|
||||||
|
`DurableHandoffStore` provides SQLite durability in the current runtime. Each logical stream has
|
||||||
|
unique `(stream_key, idempotency_key)` and `(stream_key, sequence)`. Exact retry returns `REPLAY`;
|
||||||
|
changed payload under the same key is rejected; sequence gaps are rejected; expired messages are not
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
Confidentiality scopes:
|
||||||
|
|
||||||
|
- public: no tenant requirement
|
||||||
|
- tenant: same tenant
|
||||||
|
- project: same tenant + project
|
||||||
|
- private: same tenant + project + user
|
||||||
|
- run: same tenant + project + user + run
|
||||||
|
|
||||||
|
Required `capability_scope` must be present on the authenticated delivery actor unless the actor is
|
||||||
|
explicitly administrative.
|
||||||
|
|
||||||
|
Compatibility adapters convert AgentEventBus events, TaskPlane CloudEvents and legacy Agent OS
|
||||||
|
MessageBus messages into the durable envelope. Existing message_outbox remains intact as a migration
|
||||||
|
source; P0-B does not delete or silently rewrite it.
|
||||||
|
|
||||||
|
Required gates:
|
||||||
|
`MESSAGE_SCOPE_MISMATCH_DENY`, `MESSAGE_TARGET_COLLISION_DENY`, `MESSAGE_IDEMPOTENCY_REPLAY`,
|
||||||
|
`MESSAGE_SEQUENCE_GAP_DETECT`, `MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE`,
|
||||||
|
`HANDOFF_CROSS_TENANT_DENY`, plus existing P0-A TaskPlane scope/security regressions.
|
||||||
|
|
||||||
|
SQLite remains current runtime persistence. PostgreSQL remains a separate migration gate.
|
||||||
@@ -48,3 +48,8 @@ class MessageBus:
|
|||||||
messages = list(self.queue)
|
messages = list(self.queue)
|
||||||
self.queue.clear()
|
self.queue.clear()
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
# R4.2 P0-B compatibility adapter.
|
||||||
|
def to_handoff_envelope(message, *, target=None, sequence=1, idempotency_key=None, **scope):
|
||||||
|
from message_handoff_adapters import legacy_message_to_envelope
|
||||||
|
return legacy_message_to_envelope(message, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class ShadowClientRBACPublicInterfaceTests(unittest.TestCase):
|
|||||||
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
||||||
execution_enabled=False,
|
execution_enabled=False,
|
||||||
identity_capabilities={"shadow-client": set(capabilities)},
|
identity_capabilities={"shadow-client": set(capabilities)},
|
||||||
|
identity_actor_contexts={
|
||||||
|
"shadow-client": {"scope_type": "system"}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
service = self.TaskPlaneService(store, policy=policy)
|
service = self.TaskPlaneService(store, policy=policy)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = os.getcwd()
|
||||||
|
if os.path.join(ROOT, "active-runtime") not in sys.path:
|
||||||
|
sys.path.append(os.path.join(ROOT, "active-runtime"))
|
||||||
|
|
||||||
|
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||||
|
from app.task_plane.errors import ForbiddenError, NotFoundError, ValidationError
|
||||||
|
from app.task_plane.models import TaskPlan
|
||||||
|
from app.task_plane.policy import PolicyEngine
|
||||||
|
from app.task_plane.store import TaskPlaneStore
|
||||||
|
|
||||||
|
|
||||||
|
def _policy() -> PolicyEngine:
|
||||||
|
return PolicyEngine(
|
||||||
|
identity_capabilities={
|
||||||
|
"actor-a": {"task.creator", "task.read"},
|
||||||
|
"actor-b": {"task.creator", "task.read"},
|
||||||
|
"reader-a": {"task.read"},
|
||||||
|
"reader-b": {"task.read"},
|
||||||
|
"owner": {"task.creator", "task.read", "task.read.all"},
|
||||||
|
"unbound": {"task.creator", "task.read"},
|
||||||
|
},
|
||||||
|
identity_actor_contexts={
|
||||||
|
"actor-a": {
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a",
|
||||||
|
"run_id": "run-a",
|
||||||
|
},
|
||||||
|
"reader-a": {
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a",
|
||||||
|
"run_id": "run-a",
|
||||||
|
},
|
||||||
|
"actor-b": {
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-b",
|
||||||
|
"user_id": "user-b",
|
||||||
|
"project_id": "project-b",
|
||||||
|
},
|
||||||
|
"reader-b": {
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-b",
|
||||||
|
"user_id": "user-b",
|
||||||
|
"project_id": "project-b",
|
||||||
|
},
|
||||||
|
"owner": {"scope_type": "system"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service() -> TaskPlaneService:
|
||||||
|
return TaskPlaneService(TaskPlaneStore(":memory:"), policy=_policy())
|
||||||
|
|
||||||
|
|
||||||
|
def test_taskplan_scope_fields_round_trip_as_first_class_values() -> None:
|
||||||
|
plan = TaskPlan.from_dict(
|
||||||
|
{
|
||||||
|
"task_id": "task-scope-roundtrip",
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a",
|
||||||
|
"run_id": "run-a",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = plan.to_dict()
|
||||||
|
assert TaskPlan.from_dict(payload).to_dict() == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_scope_requires_tenant_id_and_system_scope_does_not() -> None:
|
||||||
|
with pytest.raises(ValidationError) as exc:
|
||||||
|
TaskPlan.from_dict({"task_id": "task-missing-tenant", "scope_type": "tenant"})
|
||||||
|
assert exc.value.error_code == "TASK_SCOPE_TENANT_ID_REQUIRED"
|
||||||
|
system = TaskPlan.from_dict(
|
||||||
|
{"task_id": "task-system", "scope_type": "system", "run_id": "system-run"}
|
||||||
|
)
|
||||||
|
assert system.tenant_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ACTOR_BINDING_AUTHZ_MERGE_uses_server_scope() -> None:
|
||||||
|
service = _service()
|
||||||
|
created = service.create_task(
|
||||||
|
{"task_id": "task-a", "task_type": "code.change"},
|
||||||
|
actor_identity="actor-a",
|
||||||
|
)
|
||||||
|
assert created["plan"]["tenant_id"] == "tenant-a"
|
||||||
|
assert created["plan"]["user_id"] == "user-a"
|
||||||
|
assert created["plan"]["project_id"] == "project-a"
|
||||||
|
declared = service.create_task(
|
||||||
|
{
|
||||||
|
"task_id": "task-a-declared",
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a",
|
||||||
|
"run_id": "run-a",
|
||||||
|
},
|
||||||
|
actor_identity="actor-a",
|
||||||
|
)
|
||||||
|
assert declared["plan"]["scope_type"] == "tenant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_TASK_SCOPE_MISMATCH_DENY_rejects_forged_client_scope() -> None:
|
||||||
|
service = _service()
|
||||||
|
with pytest.raises(ForbiddenError) as exc:
|
||||||
|
service.create_task(
|
||||||
|
{
|
||||||
|
"task_id": "task-forged",
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-b",
|
||||||
|
"user_id": "user-b",
|
||||||
|
"project_id": "project-b",
|
||||||
|
},
|
||||||
|
actor_identity="actor-a",
|
||||||
|
)
|
||||||
|
assert exc.value.error_code == "TASK_SCOPE_MISMATCH_DENY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_create_fails_closed_without_server_actor_scope() -> None:
|
||||||
|
service = _service()
|
||||||
|
with pytest.raises(ForbiddenError) as exc:
|
||||||
|
service.create_task(
|
||||||
|
{"task_id": "task-unbound"}, actor_identity="unbound"
|
||||||
|
)
|
||||||
|
assert exc.value.error_code == "TASK_ACTOR_SCOPE_UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_TASKPLANE_LIST_AUTHZ_DENY_and_TASK_OWNERSHIP_FILTER() -> None:
|
||||||
|
service = _service()
|
||||||
|
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||||
|
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
||||||
|
assert [item["task_id"] for item in service.list_tasks("reader-a")] == ["task-a"]
|
||||||
|
assert [item["task_id"] for item in service.list_tasks("reader-b")] == ["task-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_TASKPLANE_GET_AUTHZ_DENY_does_not_disclose_cross_tenant_task() -> None:
|
||||||
|
service = _service()
|
||||||
|
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.get_task("task-a", "reader-b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_authentication_drives_server_scope_and_cross_tenant_404() -> None:
|
||||||
|
service = _service()
|
||||||
|
|
||||||
|
class Handler(TaskPlaneRequestHandler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Handler.service = service
|
||||||
|
Handler.authenticator = lambda token: {
|
||||||
|
"token-a": "actor-a",
|
||||||
|
"token-b": "reader-b",
|
||||||
|
}.get(token)
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
base = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base + "/tasks",
|
||||||
|
data=json.dumps({"task_id": "task-http-a"}).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer token-a",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=3) as response:
|
||||||
|
created = json.loads(response.read())
|
||||||
|
assert created["plan"]["tenant_id"] == "tenant-a"
|
||||||
|
|
||||||
|
denied = urllib.request.Request(
|
||||||
|
base + "/tasks/task-http-a",
|
||||||
|
headers={"Authorization": "Bearer token-b"},
|
||||||
|
)
|
||||||
|
with pytest.raises(urllib.error.HTTPError) as exc:
|
||||||
|
urllib.request.urlopen(denied, timeout=3)
|
||||||
|
assert exc.value.code == 404
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutating_endpoint_is_scoped_before_cancel() -> None:
|
||||||
|
policy = PolicyEngine(
|
||||||
|
execution_enabled=True,
|
||||||
|
identity_capabilities={
|
||||||
|
"actor-a": {"task.creator", "task.read"},
|
||||||
|
"actor-b": {"task.cancel"},
|
||||||
|
},
|
||||||
|
identity_actor_contexts={
|
||||||
|
"actor-a": {"scope_type": "tenant", "tenant_id": "tenant-a"},
|
||||||
|
"actor-b": {"scope_type": "tenant", "tenant_id": "tenant-b"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
||||||
|
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
service.cancel("task-a", {"actor_identity": "actor-b"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_privileged_server_actor_can_cross_scope() -> None:
|
||||||
|
service = _service()
|
||||||
|
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||||
|
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
||||||
|
assert {item["task_id"] for item in service.list_tasks("owner")} == {
|
||||||
|
"task-a",
|
||||||
|
"task-b",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_sql_parameters_do_not_interpolate_tenant_values() -> None:
|
||||||
|
service = _service()
|
||||||
|
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||||
|
policy = PolicyEngine(
|
||||||
|
identity_capabilities={"attacker": {"task.read"}},
|
||||||
|
identity_actor_contexts={
|
||||||
|
"attacker": {
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-a' OR 1=1 --",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
attacker_service = TaskPlaneService(service.store, policy=policy)
|
||||||
|
assert attacker_service.list_tasks("attacker") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_sqlite_rows_migrate_to_legacy_without_tenant_guess(tmp_path) -> None:
|
||||||
|
path = tmp_path / "legacy.sqlite3"
|
||||||
|
connection = sqlite3.connect(path)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, status TEXT NOT NULL,
|
||||||
|
task_json TEXT NOT NULL, task_type TEXT NOT NULL, root_task_id TEXT NOT NULL,
|
||||||
|
parent_task_id TEXT, correlation_id TEXT, idempotency_key TEXT UNIQUE,
|
||||||
|
created_at REAL NOT NULL, updated_at REAL NOT NULL, failure_reason TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
"task-legacy",
|
||||||
|
1,
|
||||||
|
"CREATED",
|
||||||
|
json.dumps({"task_id": "task-legacy"}),
|
||||||
|
"code.change",
|
||||||
|
"task-legacy",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"legacy-key",
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
store = TaskPlaneStore(str(path))
|
||||||
|
task = store.get_task("task-legacy")
|
||||||
|
assert task.task_plan.scope_type == "legacy"
|
||||||
|
assert task.task_plan.tenant_id is None
|
||||||
|
replay = store.create_task(
|
||||||
|
TaskPlan.from_dict({"task_id": "task-legacy"}),
|
||||||
|
idempotency_key="legacy-key",
|
||||||
|
)
|
||||||
|
assert replay.task_id == "task-legacy"
|
||||||
|
assert tuple(
|
||||||
|
store.conn.execute(
|
||||||
|
"SELECT scope_type,tenant_id FROM tasks WHERE task_id='task-legacy'"
|
||||||
|
).fetchone()
|
||||||
|
) == ("legacy", None)
|
||||||
|
tenant_context = _policy().actor_context("reader-a")
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
store.get_task("task-legacy", tenant_context)
|
||||||
|
store.close()
|
||||||
@@ -20,3 +20,12 @@ def test_unified_event_flow():
|
|||||||
|
|
||||||
dashboard = AgentEventDashboard(bus).overview()
|
dashboard = AgentEventDashboard(bus).overview()
|
||||||
assert dashboard["audit"]["enabled"] is True
|
assert dashboard["audit"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_changes_event_identity(tmp_path):
|
||||||
|
bus = AgentEventBus(tmp_path)
|
||||||
|
first = bus.publish('test', 'same', 'user-a', 'same summary', {'value': 1})
|
||||||
|
second = bus.publish('test', 'same', 'user-b', 'same summary', {'value': 1})
|
||||||
|
first_id = first.get('id') if isinstance(first, dict) else getattr(first, 'id', first)
|
||||||
|
second_id = second.get('id') if isinstance(second, dict) else getattr(second, 'id', second)
|
||||||
|
assert first_id != second_id
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import hashlib, json, sqlite3, sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||||
|
from aios_agent_task_retry_extension import RetryExtensionRequest, RetryExtensionStore, RetryExtensionError
|
||||||
|
|
||||||
|
AUTHORIZATION_ID="owner-scheme-a-20260817"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _authorization_id(monkeypatch):
|
||||||
|
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", AUTHORIZATION_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def canon(v):
|
||||||
|
return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(",",":")).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def db(tmp_path):
|
||||||
|
p=tmp_path/"state.sqlite3"
|
||||||
|
c=sqlite3.connect(p)
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE tasks(
|
||||||
|
task_id TEXT PRIMARY KEY,status TEXT,attempt INTEGER,max_attempts INTEGER,revision INTEGER,
|
||||||
|
claimed_by TEXT,lease_token_hash TEXT,lease_expires_at TEXT,verification_json TEXT,result_json TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE task_events(
|
||||||
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,task_id TEXT,event_type TEXT,agent_id TEXT,
|
||||||
|
payload_json TEXT,created_at TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
verification={"decision":"NEEDS_REWORK","reason":"BASE_OID_BINDING_INVALID"}
|
||||||
|
c.execute("INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("task-a","FAILED",10,10,41,None,None,None,json.dumps(verification),json.dumps({"status":"FAILED"})))
|
||||||
|
c.commit(); c.close()
|
||||||
|
return p,verification
|
||||||
|
|
||||||
|
|
||||||
|
def request(v):
|
||||||
|
return RetryExtensionRequest.from_mapping({
|
||||||
|
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
||||||
|
"next_max_attempts":11,"previous_verification_sha256":canon(v),
|
||||||
|
"authorization_id":AUTHORIZATION_ID,"idempotency_key":"retry-task-a-attempt11",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_cas_and_audit(tmp_path):
|
||||||
|
p,v=db(tmp_path)
|
||||||
|
out=RetryExtensionStore(p).extend("task-a",request(v))
|
||||||
|
assert out["status"]=="QUEUED" and out["max_attempts"]==11 and out["revision"]==42
|
||||||
|
c=sqlite3.connect(p)
|
||||||
|
assert c.execute("select status,attempt,max_attempts,revision from tasks").fetchone()==("QUEUED",10,11,42)
|
||||||
|
event=c.execute("select event_type,payload_json from task_events").fetchone()
|
||||||
|
assert event[0]=="TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||||
|
assert json.loads(event[1])["authorization_id"]=="owner-scheme-a-20260817"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_idempotent_replay(tmp_path):
|
||||||
|
p,v=db(tmp_path); store=RetryExtensionStore(p); req=request(v)
|
||||||
|
first=store.extend("task-a",req); second=store.extend("task-a",req)
|
||||||
|
assert first["idempotent"] is False and second["idempotent"] is True
|
||||||
|
c=sqlite3.connect(p)
|
||||||
|
assert c.execute("select count(*) from task_retry_extensions").fetchone()[0]==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_normalizes_stale_terminal_claim_without_active_lease(tmp_path):
|
||||||
|
p,v=db(tmp_path)
|
||||||
|
c=sqlite3.connect(p)
|
||||||
|
c.execute("update tasks set claimed_by='previous-worker'")
|
||||||
|
c.commit(); c.close()
|
||||||
|
out=RetryExtensionStore(p).extend("task-a",request(v))
|
||||||
|
assert out["status"]=="QUEUED"
|
||||||
|
c=sqlite3.connect(p)
|
||||||
|
assert c.execute(
|
||||||
|
"select claimed_by,lease_token_hash,lease_expires_at from tasks"
|
||||||
|
).fetchone()==(None,None,None)
|
||||||
|
payload=json.loads(c.execute("select payload_json from task_events").fetchone()[0])
|
||||||
|
assert payload["previous_claimed_by"]=="previous-worker"
|
||||||
|
assert payload["stale_terminal_claim_normalized"] is True
|
||||||
|
assert c.execute("select count(*) from task_events").fetchone()[0]==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_verification_drift_denied(tmp_path):
|
||||||
|
p,v=db(tmp_path)
|
||||||
|
bad=request({"decision":"NEEDS_REWORK","reason":"other"})
|
||||||
|
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_VERIFICATION_DRIFT"):
|
||||||
|
RetryExtensionStore(p).extend("task-a",bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_authorization_id_mismatch_rejected(tmp_path, monkeypatch):
|
||||||
|
p,v=db(tmp_path)
|
||||||
|
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", "other-owner-id")
|
||||||
|
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH"):
|
||||||
|
RetryExtensionStore(p).extend("task-a",request(v))
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_bounded_one_step():
|
||||||
|
v={"decision":"NEEDS_REWORK"}
|
||||||
|
data={
|
||||||
|
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
||||||
|
"next_max_attempts":12,"previous_verification_sha256":canon(v),
|
||||||
|
"authorization_id":"owner-scheme-a-20260817","idempotency_key":"retry-task-a-attempt11",
|
||||||
|
}
|
||||||
|
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP"):
|
||||||
|
RetryExtensionRequest.from_mapping(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_extension_unit_is_unprivileged_and_requires_owner_binding():
|
||||||
|
unit=(ROOT/"deploy/systemd/aios-agent-task-retry-extension.service").read_text()
|
||||||
|
assert "User=aios-agent-task" in unit
|
||||||
|
assert "Group=aios-agent-task" in unit
|
||||||
|
assert "LoadCredential=agent_task_control_token:" in unit
|
||||||
|
assert "EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env" in unit
|
||||||
|
assert "User=root" not in unit
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import hashlib,json,os,tarfile,sys,tempfile
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||||
|
import aios_isolated_executor as mod
|
||||||
|
|
||||||
|
def make_archive(tmp_path):
|
||||||
|
root=tmp_path/"src"; root.mkdir()
|
||||||
|
(root/"hello.py").write_text("VALUE = 1\n")
|
||||||
|
archive=tmp_path/"source.tgz"
|
||||||
|
with tarfile.open(archive,"w:gz") as tf: tf.add(root/"hello.py",arcname="hello.py")
|
||||||
|
return archive
|
||||||
|
|
||||||
|
def test_isolated_executor_applies_hash_bound_change(tmp_path,monkeypatch):
|
||||||
|
archive=make_archive(tmp_path)
|
||||||
|
monkeypatch.delenv("CREDENTIALS_DIRECTORY",raising=False)
|
||||||
|
monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||||
|
request={
|
||||||
|
"schema":"aios.isolated-execution.request.v1",
|
||||||
|
"request_id":"task-req-0000000000000000",
|
||||||
|
"source_archive":str(archive),
|
||||||
|
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||||
|
"change_set":[{
|
||||||
|
"path":"hello.py","operation":"replace_text","old":"VALUE = 1","new":"VALUE = 2","count":1,
|
||||||
|
"expected_sha256":mod.sha256(b"VALUE = 1\n")
|
||||||
|
}],
|
||||||
|
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","hello.py"],"cwd":".","timeout_seconds":30}]
|
||||||
|
}
|
||||||
|
req=tmp_path/"req.json"; res=tmp_path/"res.json"
|
||||||
|
req.write_text(json.dumps(request))
|
||||||
|
mod.execute_request(req,res)
|
||||||
|
out=json.loads(res.read_text())
|
||||||
|
assert out["request_id"]=="task-req-0000000000000000"
|
||||||
|
assert out["credential_isolation"]["credential_env_absent"] is True
|
||||||
|
changed=out["changed_files"][0]
|
||||||
|
assert changed["path"]=="hello.py" and changed["deleted"] is False
|
||||||
|
|
||||||
|
def test_isolated_executor_source_drift_denied(tmp_path,monkeypatch):
|
||||||
|
archive=make_archive(tmp_path); monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||||
|
request={
|
||||||
|
"schema":"aios.isolated-execution.request.v1","request_id":"task-req-0000000000000001",
|
||||||
|
"source_archive":str(archive),
|
||||||
|
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||||
|
"change_set":[{"path":"hello.py","operation":"write_file","content":"x\n","expected_sha256":"0"*64}],
|
||||||
|
"verification":[]
|
||||||
|
}
|
||||||
|
req=tmp_path/"req.json"; res=tmp_path/"res.json"; req.write_text(json.dumps(request))
|
||||||
|
with pytest.raises(mod.IsolatedExecutorError,match="SOURCE_DRIFT"):
|
||||||
|
mod.execute_request(req,res)
|
||||||
|
|
||||||
|
def test_credential_boundary_eacces_is_accepted(monkeypatch):
|
||||||
|
class DeniedDirectory:
|
||||||
|
def exists(self): return True
|
||||||
|
def is_symlink(self): return False
|
||||||
|
def is_dir(self): return True
|
||||||
|
def iterdir(self): raise PermissionError(13,"permission denied")
|
||||||
|
monkeypatch.setattr(mod.os,"access",lambda *_args,**_kwargs:False)
|
||||||
|
assert mod._credential_exposure(DeniedDirectory()) is None
|
||||||
|
|
||||||
|
def test_systemd_unit_has_no_credentials_and_private_network():
|
||||||
|
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
||||||
|
assert "PrivateNetwork=true" in unit
|
||||||
|
assert "InaccessiblePaths=/run/credentials" in unit
|
||||||
|
assert "LoadCredential" not in unit
|
||||||
|
assert "SupplementaryGroups=gitops" not in unit
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||||
|
if str(APP) not in sys.path:
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
from message_handoff import (
|
||||||
|
ConfidentialityScopeDenied,
|
||||||
|
DeliveryContext,
|
||||||
|
DurableHandoffStore,
|
||||||
|
HandoffFabric,
|
||||||
|
IdempotencyConflict,
|
||||||
|
MessageEnvelope,
|
||||||
|
MessageExpired,
|
||||||
|
MessageScopeMismatch,
|
||||||
|
SequenceGap,
|
||||||
|
authorize_delivery,
|
||||||
|
bind_envelope_scope,
|
||||||
|
stable_event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def actor(**overrides):
|
||||||
|
values = dict(
|
||||||
|
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
||||||
|
conversation_id="conversation-a", run_id="run-a",
|
||||||
|
capabilities=("task.read", "message.read"),
|
||||||
|
)
|
||||||
|
values.update(overrides)
|
||||||
|
return DeliveryContext(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(**overrides):
|
||||||
|
values = dict(
|
||||||
|
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
||||||
|
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
||||||
|
conversation_id="conversation-a", task_id="task-a", run_id="run-a",
|
||||||
|
sequence=1, idempotency_key="idem-1", capability_scope=("message.read",),
|
||||||
|
confidentiality_scope="private",
|
||||||
|
)
|
||||||
|
values.update(overrides)
|
||||||
|
return MessageEnvelope(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_MESSAGE_TARGET_COLLISION_DENY():
|
||||||
|
a = stable_event_id(source="a", target="user-a", message_type="event", payload={"x": 1},
|
||||||
|
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
||||||
|
b = stable_event_id(source="a", target="user-b", message_type="event", payload={"x": 1},
|
||||||
|
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_MESSAGE_SCOPE_MISMATCH_DENY():
|
||||||
|
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
||||||
|
bind_envelope_scope(envelope(tenant_id="tenant-b"), actor())
|
||||||
|
|
||||||
|
|
||||||
|
def test_HANDOFF_CROSS_TENANT_DENY(tmp_path):
|
||||||
|
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
env = envelope()
|
||||||
|
store.put(env)
|
||||||
|
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
||||||
|
store.get(env.envelope_id, actor=actor(tenant_id="tenant-b"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE():
|
||||||
|
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
||||||
|
authorize_delivery(actor(project_id="project-b"), envelope(confidentiality_scope="project"))
|
||||||
|
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
||||||
|
authorize_delivery(actor(), envelope(capability_scope=("message.read", "secret.read")))
|
||||||
|
|
||||||
|
|
||||||
|
def test_MESSAGE_IDEMPOTENCY_REPLAY(tmp_path):
|
||||||
|
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
env = envelope()
|
||||||
|
assert store.put(env)[0] == "CREATED"
|
||||||
|
status, replay = store.put(env)
|
||||||
|
assert status == "REPLAY"
|
||||||
|
assert replay.envelope_id == env.envelope_id
|
||||||
|
with pytest.raises(IdempotencyConflict, match="MESSAGE_IDEMPOTENCY_CONFLICT"):
|
||||||
|
store.put(envelope(payload={"value": 2}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_MESSAGE_SEQUENCE_GAP_DETECT(tmp_path):
|
||||||
|
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
store.put(envelope(sequence=1, idempotency_key="one"))
|
||||||
|
with pytest.raises(SequenceGap, match="MESSAGE_SEQUENCE_GAP_DETECT"):
|
||||||
|
store.put(envelope(sequence=3, idempotency_key="three"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequence_advances_and_scope_filters_pending(tmp_path):
|
||||||
|
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
store.put(envelope(sequence=1, idempotency_key="one"))
|
||||||
|
store.put(envelope(sequence=2, idempotency_key="two", payload={"value": 2}))
|
||||||
|
assert [item.sequence for item in store.pending(target="agent-b", actor=actor())] == [1, 2]
|
||||||
|
assert store.pending(target="agent-b", actor=actor(tenant_id="tenant-b")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_message_rejected(tmp_path):
|
||||||
|
expired = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1)).isoformat()
|
||||||
|
with pytest.raises(MessageExpired, match="MESSAGE_EXPIRED"):
|
||||||
|
DurableHandoffStore(tmp_path / "handoff.sqlite3").put(envelope(expires_at=expired))
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_actor_binding_fills_scope_and_recomputes_identity():
|
||||||
|
public = MessageEnvelope(
|
||||||
|
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
||||||
|
sequence=1, idempotency_key="bind-1", confidentiality_scope="public",
|
||||||
|
)
|
||||||
|
bound = bind_envelope_scope(public, actor())
|
||||||
|
assert (bound.tenant_id, bound.user_id, bound.project_id, bound.run_id) == (
|
||||||
|
"tenant-a", "user-a", "project-a", "run-a"
|
||||||
|
)
|
||||||
|
assert bound.envelope_id != public.envelope_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_handoff_fabric_publish_is_durable(tmp_path):
|
||||||
|
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
status, env = HandoffFabric(store).publish(envelope(), actor=actor())
|
||||||
|
assert status == "CREATED"
|
||||||
|
assert store.get(env.envelope_id, actor=actor()).payload == {"value": 1}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
APP=ROOT/"active-runtime/app"
|
||||||
|
sys.path.insert(0,str(APP))
|
||||||
|
|
||||||
|
from aios_online_agent_checkpoint import (
|
||||||
|
CheckpointBinding,
|
||||||
|
CheckpointError,
|
||||||
|
CheckpointStore,
|
||||||
|
)
|
||||||
|
import aios_online_agent_worker as worker
|
||||||
|
|
||||||
|
|
||||||
|
def binding(attempt: int = 1, *, tenant_head: str = "b"*40):
|
||||||
|
return CheckpointBinding(
|
||||||
|
task_id="task-p1-a",
|
||||||
|
attempt=attempt,
|
||||||
|
execution_digest="d"*64,
|
||||||
|
job_id="job-p1-a",
|
||||||
|
repository="aios/aios",
|
||||||
|
base_oid="a"*40,
|
||||||
|
expected_branch_oid=tenant_head,
|
||||||
|
target_branch="agent/task-p1-a",
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_CHECKPOINT_DURABLE_WRITE(tmp_path):
|
||||||
|
store=CheckpointStore(tmp_path/"checkpoints.sqlite3")
|
||||||
|
first=store.append(binding(), "claimed", {"phase":"claimed","attempt":1})
|
||||||
|
second=store.append(binding(), "workspace_prepared", {"phase":"workspace_prepared","attempt":1})
|
||||||
|
assert first["sequence"] == 1
|
||||||
|
assert second["sequence"] == 2
|
||||||
|
latest=store.latest("task-p1-a")
|
||||||
|
assert latest["phase"]=="workspace_prepared"
|
||||||
|
assert latest["payload"]["attempt"]==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_CHECKPOINT_SCOPE_ISOLATION(tmp_path):
|
||||||
|
store=CheckpointStore(tmp_path/"checkpoints.sqlite3")
|
||||||
|
store.append(binding(), "claimed", {"phase":"claimed"})
|
||||||
|
changed=CheckpointBinding(
|
||||||
|
task_id="task-p1-a",attempt=1,execution_digest="d"*64,job_id="job-p1-a",
|
||||||
|
repository="aios/other",base_oid="a"*40,expected_branch_oid="b"*40,
|
||||||
|
target_branch="agent/task-p1-a",generation=1,
|
||||||
|
)
|
||||||
|
with pytest.raises(CheckpointError,match="CHECKPOINT_SCOPE_MISMATCH_DENY"):
|
||||||
|
store.append(changed,"workspace_prepared",{"phase":"workspace_prepared"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_RESUME_FROM_CHECKPOINT_across_attempt_same_binding(tmp_path):
|
||||||
|
store=CheckpointStore(tmp_path/"checkpoints.sqlite3")
|
||||||
|
store.append(binding(1), "source_capture_done", {
|
||||||
|
"phase":"source_capture_done",
|
||||||
|
"attempt":1,
|
||||||
|
"result":{"status":"SUCCEEDED","classification":"NEEDS_REWORK_SOURCE_CAPTURE"},
|
||||||
|
})
|
||||||
|
resumed=store.verify_latest_binding(binding(2))
|
||||||
|
assert resumed is not None
|
||||||
|
assert resumed["attempt"]==1
|
||||||
|
assert resumed["phase"]=="source_capture_done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_new_attempt_can_start_from_claimed(tmp_path):
|
||||||
|
store=CheckpointStore(tmp_path/"checkpoints.sqlite3")
|
||||||
|
store.append(binding(1),"result_submitted",{"phase":"result_submitted"})
|
||||||
|
cp=store.append(binding(2),"claimed",{"phase":"claimed"})
|
||||||
|
assert cp["attempt"]==2
|
||||||
|
assert cp["sequence"]==1
|
||||||
|
|
||||||
|
|
||||||
|
def envelope():
|
||||||
|
return worker.ExecutionEnvelope(
|
||||||
|
task_id="task-p1-a",
|
||||||
|
job_id="job-p1-a",
|
||||||
|
repository="aios/aios",
|
||||||
|
base_oid="a"*40,
|
||||||
|
expected_branch_oid="b"*40,
|
||||||
|
target_branch="agent/task-p1-a",
|
||||||
|
branch_mode="CONTINUE_EXISTING",
|
||||||
|
generation=1,
|
||||||
|
idempotency_key="governed-p1-a",
|
||||||
|
action="source_capture",
|
||||||
|
adapter="source_capture",
|
||||||
|
capability_scope=("git.read","git.test"),
|
||||||
|
source_signals=(),
|
||||||
|
change_set=(),
|
||||||
|
verification=(),
|
||||||
|
governance={"task_id":"task-p1-a","job_id":"job-p1-a"},
|
||||||
|
workspace={},
|
||||||
|
digest="d"*64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_RESULT_SUBMISSION_REPLAY_SAFE_attempt_aware_key():
|
||||||
|
env=envelope()
|
||||||
|
result={"status":"SUCCEEDED","execution_digest":env.digest}
|
||||||
|
one=worker.result_idempotency_key(env.task_id,1,env,result)
|
||||||
|
two=worker.result_idempotency_key(env.task_id,2,env,result)
|
||||||
|
assert one != two
|
||||||
|
assert "-a1-result-" in one
|
||||||
|
assert "-a2-result-" in two
|
||||||
|
assert one == worker.result_idempotency_key(env.task_id,1,env,result)
|
||||||
|
|
||||||
|
|
||||||
|
class RetryClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls=[]
|
||||||
|
def result(self,task_id,lease_token,key,result):
|
||||||
|
self.calls.append((task_id,key,dict(result)))
|
||||||
|
return (599,{"error":"SERVICE_UNAVAILABLE"}) if len(self.calls)==1 else (201,{"status":"VERIFYING"})
|
||||||
|
def get(self,task_id):
|
||||||
|
return 500,{}
|
||||||
|
|
||||||
|
|
||||||
|
def test_RETRY_IDEMPOTENT_NO_DUPLICATE_SIDE_EFFECT_reuses_same_key(monkeypatch):
|
||||||
|
client=RetryClient()
|
||||||
|
monkeypatch.setattr(worker.time,"sleep",lambda _:None)
|
||||||
|
status,response=worker.submit_result_replay_safe(
|
||||||
|
client,"task-p1-a","lease-token-"+"x"*32,"stable-key",{"status":"SUCCEEDED"}
|
||||||
|
)
|
||||||
|
assert status==201
|
||||||
|
assert [item[1] for item in client.calls]==["stable-key","stable-key"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictClient:
|
||||||
|
def __init__(self,result):
|
||||||
|
self.result_value=result
|
||||||
|
def result(self,*args):
|
||||||
|
return 409,{"error":"IDEMPOTENCY_CONFLICT"}
|
||||||
|
def get(self,task_id):
|
||||||
|
return 200,{"status":"VERIFYING","result":dict(self.result_value)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_RESULT_SUBMISSION_REPLAY_SAFE_reconciles_exact_result():
|
||||||
|
result={"status":"SUCCEEDED","value":1}
|
||||||
|
status,response=worker.submit_result_replay_safe(
|
||||||
|
ConflictClient(result),"task-p1-a","lease-token-"+"x"*32,"stable-key",result
|
||||||
|
)
|
||||||
|
assert status==200
|
||||||
|
assert response["idempotent"] is True
|
||||||
|
assert response["reconciled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_RESULT_SUBMISSION_REPLAY_SAFE_rejects_conflicting_result():
|
||||||
|
status,response=worker.submit_result_replay_safe(
|
||||||
|
ConflictClient({"status":"SUCCEEDED","value":2}),
|
||||||
|
"task-p1-a","lease-token-"+"x"*32,"stable-key",
|
||||||
|
{"status":"SUCCEEDED","value":1},
|
||||||
|
)
|
||||||
|
assert status==409
|
||||||
|
assert response["error"]=="IDEMPOTENCY_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_WORKSPACE_CLEANUP_RECOVERY(tmp_path,monkeypatch):
|
||||||
|
root=tmp_path/"workspaces"; root.mkdir()
|
||||||
|
stale=root/"task-old"; stale.mkdir(); (stale/"x").write_text("x")
|
||||||
|
monkeypatch.setattr(worker,"WORKSPACE_ROOT",root)
|
||||||
|
monkeypatch.setattr(worker,"STATE_ROOT",tmp_path/"state")
|
||||||
|
removed=worker.recover_stale_workspaces()
|
||||||
|
assert removed==1
|
||||||
|
assert not stale.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class LeaseBridge:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls=0
|
||||||
|
def call(self,route,body):
|
||||||
|
assert route=="/v2/writer-lease"
|
||||||
|
self.calls+=1
|
||||||
|
if self.calls==1:
|
||||||
|
return 599,{"error":"SERVICE_UNAVAILABLE"}
|
||||||
|
return 200,{
|
||||||
|
"lease_id":"lease-p1-a",
|
||||||
|
"job_id":"job-p1-a",
|
||||||
|
"task_id":"task-p1-a",
|
||||||
|
"repository":"aios/aios",
|
||||||
|
"base_oid":"a"*40,
|
||||||
|
"expected_branch_oid":"b"*40,
|
||||||
|
"branch_mode":"CONTINUE_EXISTING",
|
||||||
|
"generation":1,
|
||||||
|
"target_branch":"agent/task-p1-a",
|
||||||
|
"state":"ACTIVE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LeaseHeartbeatStub:
|
||||||
|
def ensure(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_LEASE_RENEWAL_RECOVERY_retries_same_binding(monkeypatch):
|
||||||
|
env=envelope()
|
||||||
|
env=worker.ExecutionEnvelope(
|
||||||
|
task_id=env.task_id,job_id=env.job_id,repository=env.repository,
|
||||||
|
base_oid=env.base_oid,expected_branch_oid=env.expected_branch_oid,
|
||||||
|
target_branch=env.target_branch,branch_mode=env.branch_mode,generation=env.generation,
|
||||||
|
idempotency_key=env.idempotency_key,action=env.action,adapter=env.adapter,
|
||||||
|
capability_scope=env.capability_scope,source_signals=env.source_signals,
|
||||||
|
change_set=env.change_set,verification=env.verification,
|
||||||
|
governance={"task_id":env.task_id,"job_id":env.job_id,"lease_seconds":900},
|
||||||
|
workspace=env.workspace,digest=env.digest,
|
||||||
|
)
|
||||||
|
bridge=LeaseBridge()
|
||||||
|
monkeypatch.setattr(worker.time,"sleep",lambda _:None)
|
||||||
|
monkeypatch.setattr(worker,"STATE_ROOT",Path("/tmp")/"aios-p1-test-state")
|
||||||
|
response=worker.acquire_writer_lease_replay_safe(env,bridge,LeaseHeartbeatStub())
|
||||||
|
assert response["lease_id"]=="lease-p1-a"
|
||||||
|
assert bridge.calls==2
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||||
|
|
||||||
|
from aios_online_agent_execution import GovernedExecutionBinding
|
||||||
|
from aios_online_agent_plan_store import ExecutionPlanStore, ExecutionPlanError
|
||||||
|
|
||||||
|
BASE="a642b045775b9154a00ae78543037fbef4fb447b"
|
||||||
|
HEAD="71523aef6e48b1e2fa516c456ca189b7a039104b"
|
||||||
|
|
||||||
|
def binding(head=HEAD):
|
||||||
|
return GovernedExecutionBinding.from_mapping({
|
||||||
|
"task_id":"task-plan-a","job_id":"job-plan-a","repository":"aios/aios","base_oid":BASE,
|
||||||
|
"expected_branch_oid":head,"target_branch":"agent/task-plan-a","branch_mode":"CONTINUE_EXISTING",
|
||||||
|
"generation":1,"idempotency_key":"governed-plan-a",
|
||||||
|
})
|
||||||
|
|
||||||
|
def request():
|
||||||
|
return {
|
||||||
|
"schema":"aios.online-agent.plan.v1","task_id":"task-plan-a","job_id":"job-plan-a",
|
||||||
|
"source_head_oid":HEAD,"generation":1,"idempotency_key":"plan-task-a-v1",
|
||||||
|
"execution":{
|
||||||
|
"action":"deterministic",
|
||||||
|
"change_set":[{"path":"x.py","operation":"write_file","content":"x=1\n"}],
|
||||||
|
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","x.py"],"cwd":".","timeout_seconds":30}],
|
||||||
|
"capability_scope":["git.read","git.test"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_plan_register_and_resolve(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path)
|
||||||
|
created=store.register(binding(),request())
|
||||||
|
assert created["idempotent"] is False
|
||||||
|
resolved=store.resolve(binding())
|
||||||
|
assert resolved["plan_digest"]==created["plan_digest"]
|
||||||
|
assert resolved["execution"]["action"]=="deterministic"
|
||||||
|
|
||||||
|
def test_plan_is_idempotent(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); req=request()
|
||||||
|
first=store.register(binding(),req); second=store.register(binding(),req)
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
assert first["plan_digest"]==second["plan_digest"]
|
||||||
|
|
||||||
|
def test_plan_source_head_drift_denied(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); store.register(binding(),request())
|
||||||
|
with pytest.raises(ExecutionPlanError,match="SOURCE_HEAD_DRIFT"):
|
||||||
|
store.resolve(binding("b"*40))
|
||||||
|
|
||||||
|
def test_plan_replacement_requires_cas(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); first=store.register(binding(),request())
|
||||||
|
changed=request(); changed["idempotency_key"]="plan-task-a-v2"
|
||||||
|
changed["execution"]["change_set"][0]["content"]="x=2\n"
|
||||||
|
with pytest.raises(ExecutionPlanError,match="REPLACEMENT_CAS_MISMATCH"):
|
||||||
|
store.register(binding(),changed)
|
||||||
|
changed["expected_previous_plan_digest"]=first["plan_digest"]
|
||||||
|
second=store.register(binding(),changed)
|
||||||
|
assert second["plan_digest"]!=first["plan_digest"]
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from active_runtime.app.aios_credential_broker import CredentialBroker
|
||||||
|
from active_runtime.app.agent_observability_hooks import AgentObservabilityHooks
|
||||||
|
from active_runtime.app.message_handoff import DurableHandoffStore, MessageEnvelope
|
||||||
|
from active_runtime.app.search_gateway import (
|
||||||
|
SearchEvidence,
|
||||||
|
SearchGateway,
|
||||||
|
SearchProviderAttempt,
|
||||||
|
SearchRequest,
|
||||||
|
)
|
||||||
|
from active_runtime.app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||||
|
from active_runtime.app.trace_tool_credential_governance import (
|
||||||
|
CredentialGrant,
|
||||||
|
GovernanceActor,
|
||||||
|
GovernanceError,
|
||||||
|
GovernanceScope,
|
||||||
|
GovernanceStore,
|
||||||
|
ToolDescriptor,
|
||||||
|
TraceContext,
|
||||||
|
bind_trace_lineage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_SCOPE = GovernanceScope(
|
||||||
|
"tenant",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
user_id="user-a",
|
||||||
|
project_id="project-a",
|
||||||
|
conversation_id="conversation-a",
|
||||||
|
run_id="run-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def actor(*capabilities: str, tenant_id: str = "tenant-a") -> GovernanceActor:
|
||||||
|
return GovernanceActor(
|
||||||
|
"actor-a",
|
||||||
|
GovernanceScope(
|
||||||
|
"tenant",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id="user-a",
|
||||||
|
project_id="project-a",
|
||||||
|
conversation_id="conversation-a",
|
||||||
|
run_id="run-a",
|
||||||
|
),
|
||||||
|
tuple(capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def owner() -> GovernanceActor:
|
||||||
|
return GovernanceActor("owner-control", GovernanceScope("system"), ("system.admin",))
|
||||||
|
|
||||||
|
|
||||||
|
def message(**changes):
|
||||||
|
values = dict(
|
||||||
|
source="task-plane",
|
||||||
|
target="worker-a",
|
||||||
|
message_type="tool.invoke",
|
||||||
|
payload={"query": "safe"},
|
||||||
|
sequence=1,
|
||||||
|
idempotency_key="message-a-1",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
user_id="user-a",
|
||||||
|
project_id="project-a",
|
||||||
|
conversation_id="conversation-a",
|
||||||
|
task_id="task-a",
|
||||||
|
run_id="run-a",
|
||||||
|
confidentiality_scope="run",
|
||||||
|
)
|
||||||
|
values.update(changes)
|
||||||
|
return MessageEnvelope(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def task(**changes):
|
||||||
|
value = {
|
||||||
|
"task_id": "task-a",
|
||||||
|
"claim_id": "claim-a",
|
||||||
|
"correlation_id": "correlation-a",
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"tenant_id": "tenant-a",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a",
|
||||||
|
"run_id": "run-a",
|
||||||
|
}
|
||||||
|
value.update(changes)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def root_trace():
|
||||||
|
return bind_trace_lineage(
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
task=task(),
|
||||||
|
message=message(),
|
||||||
|
trace_id="trace-a",
|
||||||
|
span_id="span-root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_lineage_continuity_and_durable_restart(tmp_path: Path) -> None:
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
root = root_trace()
|
||||||
|
child = root.child(span_id="span-tool", actor=actor("tool.search"))
|
||||||
|
store.record_trace(root, actor("tool.search"))
|
||||||
|
store.record_trace(child, actor("tool.search"))
|
||||||
|
|
||||||
|
reopened = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
assert reopened.verify_audit_chain(("TRACE_RECORDED",))
|
||||||
|
assert child.trace_id == root.trace_id
|
||||||
|
assert child.parent_span_id == root.span_id
|
||||||
|
assert child.task_id == "task-a"
|
||||||
|
assert child.claim_id == "claim-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_lineage_continuity_rejects_task_change() -> None:
|
||||||
|
with pytest.raises(GovernanceError, match="TRACE_LINEAGE_CONTINUITY"):
|
||||||
|
bind_trace_lineage(
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
task=task(task_id="task-b"),
|
||||||
|
message=message(),
|
||||||
|
trace_id="trace-a",
|
||||||
|
span_id="span-root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_scope_mismatch_deny() -> None:
|
||||||
|
with pytest.raises(GovernanceError, match="TRACE_SCOPE_MISMATCH_DENY"):
|
||||||
|
bind_trace_lineage(
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
task=task(),
|
||||||
|
message=message(tenant_id="tenant-b"),
|
||||||
|
trace_id="trace-a",
|
||||||
|
span_id="span-root",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_correlation_is_identity_bound() -> None:
|
||||||
|
first = message(correlation_id="correlation-a")
|
||||||
|
second = message(correlation_id="correlation-b")
|
||||||
|
assert first.envelope_id != second.envelope_id
|
||||||
|
assert first.as_record()["correlation_id"] == "correlation-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_handoff_legacy_schema_migrates_without_changing_id(tmp_path: Path) -> None:
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
path = tmp_path / "legacy.sqlite3"
|
||||||
|
legacy = message()
|
||||||
|
record = legacy.as_record()
|
||||||
|
record.pop("correlation_id")
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE message_handoff (
|
||||||
|
envelope_id TEXT PRIMARY KEY, stream_key TEXT NOT NULL, source TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL, message_type TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL, tenant_id TEXT, user_id TEXT, project_id TEXT,
|
||||||
|
conversation_id TEXT, task_id TEXT, run_id TEXT, causation_id TEXT,
|
||||||
|
sequence INTEGER NOT NULL, capability_scope_json TEXT NOT NULL,
|
||||||
|
confidentiality_scope TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||||
|
expires_at TEXT, created_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
delivered_at TEXT, acked_at TEXT,
|
||||||
|
UNIQUE(stream_key,idempotency_key), UNIQUE(stream_key,sequence)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
columns = tuple(record)
|
||||||
|
connection.execute(
|
||||||
|
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
|
||||||
|
tuple(record[name] for name in columns),
|
||||||
|
)
|
||||||
|
store = DurableHandoffStore(path)
|
||||||
|
loaded = store.get(
|
||||||
|
legacy.envelope_id,
|
||||||
|
actor={
|
||||||
|
"tenant_id": "tenant-a", "user_id": "user-a", "project_id": "project-a",
|
||||||
|
"conversation_id": "conversation-a", "run_id": "run-a",
|
||||||
|
"capabilities": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert loaded.envelope_id == legacy.envelope_id
|
||||||
|
assert loaded.correlation_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_handoff_rejects_raw_credential_material(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||||
|
message(payload={"value": "Bearer " + "x" * 24})
|
||||||
|
DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(tmp_path / "handoff.sqlite3") as connection:
|
||||||
|
assert connection.execute("SELECT count(*) FROM message_handoff").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_mcp_authz_deny_and_scope_propagation(tmp_path: Path) -> None:
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
store.register_tool(
|
||||||
|
ToolDescriptor("mcp.search", "mcp", TENANT_SCOPE, ("tool.search",)),
|
||||||
|
owner(),
|
||||||
|
)
|
||||||
|
trace = root_trace()
|
||||||
|
store.record_trace(trace, actor("tool.search"))
|
||||||
|
|
||||||
|
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||||
|
store.authorize_tool(
|
||||||
|
actor=actor(), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-deny-capability",
|
||||||
|
)
|
||||||
|
with pytest.raises(GovernanceError, match="TOOL_SCOPE_PROPAGATION"):
|
||||||
|
store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=GovernanceScope("tenant", tenant_id="tenant-b"),
|
||||||
|
input_digest="a" * 64, invocation_key="invoke-deny-scope",
|
||||||
|
)
|
||||||
|
|
||||||
|
invocation = store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-search-a",
|
||||||
|
)
|
||||||
|
assert invocation["trace_id"] == "trace-a"
|
||||||
|
assert invocation["evidence"]["action"] == "TOOL_AUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_requires_recorded_trace_and_receipt_is_idempotent(tmp_path: Path) -> None:
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
store.register_tool(
|
||||||
|
ToolDescriptor(
|
||||||
|
"mcp.search", "mcp", TENANT_SCOPE, ("tool.search",),
|
||||||
|
credential_ref="systemd-credential:search-provider-a",
|
||||||
|
),
|
||||||
|
owner(),
|
||||||
|
)
|
||||||
|
trace = root_trace()
|
||||||
|
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY:trace"):
|
||||||
|
store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-unrecorded",
|
||||||
|
)
|
||||||
|
store.record_trace(trace, actor("tool.search"))
|
||||||
|
authorized = store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-replay-a",
|
||||||
|
)
|
||||||
|
assert authorized["credential_required"] is True
|
||||||
|
assert "credential_ref" not in authorized
|
||||||
|
receipt = store.record_tool_receipt(
|
||||||
|
invocation_key="invoke-replay-a", result_digest="b" * 64,
|
||||||
|
actor=actor("tool.search"), trace=trace,
|
||||||
|
)
|
||||||
|
assert receipt["status"] == "VERIFIED"
|
||||||
|
replay = store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-replay-a",
|
||||||
|
)
|
||||||
|
assert replay["idempotent"] is True
|
||||||
|
assert replay["receipt_sha256"] == "b" * 64
|
||||||
|
assert store.verify_audit_chain(
|
||||||
|
("TRACE_RECORDED", "TOOL_AUTHORIZED", "TOOL_RESULT_VERIFIED")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_and_global_resources_do_not_use_fake_tenants(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(GovernanceError, match="GOVERNANCE_NON_TENANT_SCOPE_INVALID"):
|
||||||
|
GovernanceScope("system", tenant_id="fake-tenant")
|
||||||
|
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
descriptor = ToolDescriptor("system.health", "tool", GovernanceScope("global"), ("system.admin",))
|
||||||
|
registered = store.register_tool(descriptor, owner())
|
||||||
|
assert registered["descriptor"]["scope_type"] == "global"
|
||||||
|
assert registered["descriptor"]["tenant_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_scope_and_secret_exposure_deny(tmp_path: Path) -> None:
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
grant = CredentialGrant(
|
||||||
|
"grant-search-a",
|
||||||
|
"systemd-credential:search-provider-a",
|
||||||
|
TENANT_SCOPE,
|
||||||
|
("search.invoke",),
|
||||||
|
task_id="task-a",
|
||||||
|
tool_name="mcp.search",
|
||||||
|
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
|
||||||
|
)
|
||||||
|
stored = store.store_grant(grant, owner())
|
||||||
|
assert "secret" not in stored["grant"]
|
||||||
|
trace = root_trace()
|
||||||
|
store.record_trace(trace, actor("tool.search"))
|
||||||
|
|
||||||
|
with pytest.raises(GovernanceError, match="CREDENTIAL_SCOPE_DENY"):
|
||||||
|
store.authorize_credential(
|
||||||
|
grant_id=grant.grant_id,
|
||||||
|
actor=actor("tool.search", tenant_id="tenant-b"),
|
||||||
|
trace=trace,
|
||||||
|
operation="search.invoke",
|
||||||
|
task_id="task-a",
|
||||||
|
tool_name="mcp.search",
|
||||||
|
)
|
||||||
|
with pytest.raises(GovernanceError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||||
|
CredentialGrant("grant-raw", "Bearer raw-secret", TENANT_SCOPE, ("search.invoke",))
|
||||||
|
|
||||||
|
authorized = store.authorize_credential(
|
||||||
|
grant_id=grant.grant_id,
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
trace=trace,
|
||||||
|
operation="search.invoke",
|
||||||
|
task_id="task-a",
|
||||||
|
tool_name="mcp.search",
|
||||||
|
)
|
||||||
|
assert authorized["credential_ref"] == "systemd-credential:search-provider-a"
|
||||||
|
assert "credential_ref" not in authorized["evidence"]["details"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_evidence_completeness_and_tamper_detection(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "governance.sqlite3"
|
||||||
|
store = GovernanceStore(path)
|
||||||
|
trace = root_trace()
|
||||||
|
store.record_trace(trace, actor("tool.search"))
|
||||||
|
store.register_tool(
|
||||||
|
ToolDescriptor("mcp.search", "mcp", TENANT_SCOPE, ("tool.search",)), owner()
|
||||||
|
)
|
||||||
|
store.authorize_tool(
|
||||||
|
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||||
|
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||||
|
invocation_key="invoke-audit-a",
|
||||||
|
)
|
||||||
|
assert store.verify_audit_chain(("TRACE_RECORDED", "TOOL_REGISTERED", "TOOL_AUTHORIZED"))
|
||||||
|
assert not store.verify_audit_chain(("CREDENTIAL_AUTHORIZED",))
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE governance_audit_evidence SET outcome='DENY' WHERE sequence=1"
|
||||||
|
)
|
||||||
|
assert not GovernanceStore(path).verify_audit_chain()
|
||||||
|
|
||||||
|
|
||||||
|
def test_observability_record_carries_governed_trace(tmp_path: Path) -> None:
|
||||||
|
from active_runtime.app.agent_observability_store import AgentObservabilityStore
|
||||||
|
|
||||||
|
hooks = AgentObservabilityHooks(AgentObservabilityStore(tmp_path / "observability"))
|
||||||
|
trace = root_trace()
|
||||||
|
started = hooks.start(
|
||||||
|
"user-a", "project-a", "openclaw",
|
||||||
|
actor=actor("tool.search"), trace=trace,
|
||||||
|
)
|
||||||
|
record = hooks.finish(started["execution_id"])["record"]
|
||||||
|
assert record["trace_id"] == "trace-a"
|
||||||
|
assert record["tenant_id"] == "tenant-a"
|
||||||
|
assert record["task_id"] == "task-a"
|
||||||
|
assert record["message_id"] == message().envelope_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_runtime_credential_broker_scope_and_raw_secret_deny() -> None:
|
||||||
|
class Provider:
|
||||||
|
def environment(self, lease):
|
||||||
|
return {"ACCESS_TOKEN": "raw-secret"}
|
||||||
|
|
||||||
|
broker = CredentialBroker(Provider())
|
||||||
|
lease = broker.issue(
|
||||||
|
task_id="task-a", repository="aios/aios", target_branch="agent/task-a",
|
||||||
|
generation=1, scopes=("tool.invoke",), scope_type="tenant",
|
||||||
|
tenant_id="tenant-a", user_id="user-a", project_id="project-a", run_id="run-a",
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="CREDENTIAL_SCOPE_DENY"):
|
||||||
|
broker.authorize(
|
||||||
|
lease["lease_id"], task_id="task-a", operation="tool.invoke",
|
||||||
|
scope_type="tenant", tenant_id="tenant-b", user_id="user-a",
|
||||||
|
project_id="project-a", run_id="run-a",
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||||
|
broker.authorize(
|
||||||
|
lease["lease_id"], task_id="task-a", operation="tool.invoke",
|
||||||
|
scope_type="tenant", tenant_id="tenant-a", user_id="user-a",
|
||||||
|
project_id="project-a", run_id="run-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_gateway_uses_governed_tool_boundary(tmp_path: Path) -> None:
|
||||||
|
class Provider:
|
||||||
|
name = "official_site"
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def search(self, request, queries):
|
||||||
|
self.called = True
|
||||||
|
return (
|
||||||
|
[SearchEvidence("Official", "https://example.test", self.name, "safe")],
|
||||||
|
SearchProviderAttempt(self.name, "ok", 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||||
|
store.register_tool(
|
||||||
|
ToolDescriptor("search.official_site", "tool", TENANT_SCOPE, ("tool.search",)),
|
||||||
|
owner(),
|
||||||
|
)
|
||||||
|
provider = Provider()
|
||||||
|
gateway = SearchGateway([provider], governance_store=store)
|
||||||
|
trace = root_trace()
|
||||||
|
store.record_trace(trace, actor("tool.search"))
|
||||||
|
response = gateway.search(
|
||||||
|
SearchRequest(
|
||||||
|
query="AIOS", requester="actor-a", correlation_id="trace-a",
|
||||||
|
authorized_sources=["official_site"],
|
||||||
|
),
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
|
assert response.status == "found"
|
||||||
|
assert provider.called is True
|
||||||
|
assert store.verify_audit_chain(
|
||||||
|
("TOOL_REGISTERED", "TRACE_RECORDED", "TOOL_AUTHORIZED", "TOOL_RESULT_VERIFIED")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||||
|
gateway.search(
|
||||||
|
SearchRequest(query="AIOS", requester="forged", correlation_id="trace-a"),
|
||||||
|
actor=actor("tool.search"),
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_gateway_redacts_provider_exception() -> None:
|
||||||
|
class Provider:
|
||||||
|
name = "general_web"
|
||||||
|
|
||||||
|
def search(self, request, queries):
|
||||||
|
raise RuntimeError("Bearer should-not-leak")
|
||||||
|
|
||||||
|
response = SearchGateway([Provider()]).search(SearchRequest(query="AIOS", requester="legacy"))
|
||||||
|
assert response.attempts[0].reason == "PROVIDER_FAILURE"
|
||||||
|
assert "should-not-leak" not in str(response.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_runtime_default_path_requires_governance(tmp_path: Path) -> None:
|
||||||
|
orchestrator = SearchGatewayOrchestrator(
|
||||||
|
governance_store=GovernanceStore(tmp_path / "search-governance.sqlite3")
|
||||||
|
)
|
||||||
|
assert orchestrator.status()["governance"] == "required"
|
||||||
|
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||||
|
orchestrator.execute({"query": "AIOS", "requester": "actor-a"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_governance_newer_schema_fails_closed(tmp_path: Path) -> None:
|
||||||
|
import sqlite3
|
||||||
|
path = tmp_path / "newer.sqlite3"
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE governance_schema_metadata(singleton INTEGER PRIMARY KEY, schema_version INTEGER NOT NULL)"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,999)"
|
||||||
|
)
|
||||||
|
with pytest.raises(GovernanceError, match="GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED"):
|
||||||
|
GovernanceStore(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_production_files_have_no_embedded_secret_material() -> None:
|
||||||
|
import re
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
paths = (
|
||||||
|
"active-runtime/app/agent_event_bus.py",
|
||||||
|
"active-runtime/app/agent_event_observability_bridge.py",
|
||||||
|
"active-runtime/app/agent_observability.py",
|
||||||
|
"active-runtime/app/agent_observability_api.py",
|
||||||
|
"active-runtime/app/agent_observability_hooks.py",
|
||||||
|
"active-runtime/app/agent_observability_store.py",
|
||||||
|
"active-runtime/app/aios_credential_broker.py",
|
||||||
|
"active-runtime/app/message_handoff.py",
|
||||||
|
"active-runtime/app/search_agent_adapter.py",
|
||||||
|
"active-runtime/app/search_gateway.py",
|
||||||
|
"active-runtime/app/search_gateway_api.py",
|
||||||
|
"active-runtime/app/search_gateway_orchestrator.py",
|
||||||
|
"active-runtime/app/trace_tool_credential_governance.py",
|
||||||
|
)
|
||||||
|
patterns = (
|
||||||
|
re.compile(r"BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY"),
|
||||||
|
re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{24,}"),
|
||||||
|
)
|
||||||
|
hits = [path for path in paths for pattern in patterns if pattern.search((root / path).read_text(encoding="utf-8"))]
|
||||||
|
assert hits == []
|
||||||
Reference in New Issue
Block a user