Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a320ea391e | ||
|
|
6ffd7fe19c |
+3
-10
@@ -4,14 +4,7 @@ title = "AIOS repository secret scan policy"
|
|||||||
useDefault = true
|
useDefault = true
|
||||||
|
|
||||||
[allowlist]
|
[allowlist]
|
||||||
description = "Exact non-secret fixtures: Task Admission idempotency test fixture and release-freeze git commit OIDs (generic-api-key false positive on 40-hex git hashes)"
|
description = "Exact non-secret Task Admission test idempotency fixture in the historical PR commit"
|
||||||
condition = "AND"
|
condition = "AND"
|
||||||
paths = [
|
paths = ['''tests/test_task_admission_bootstrap_http\.py$''']
|
||||||
'''tests/test_task_admission_bootstrap_http\.py$''',
|
regexes = ['''idempotency-1''']
|
||||||
'''docs/V2R2C2-P0-convergence-20260909\.md$''',
|
|
||||||
'''artifacts/round3-provider-auth/ledger\.json$''',
|
|
||||||
]
|
|
||||||
regexes = [
|
|
||||||
'''idempotency-1''',
|
|
||||||
'''f7f53d0568767d6deec2c47008a89f4d8413f93d''',
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -16,28 +16,6 @@ from .agent_observability_store import AgentObservabilityStore
|
|||||||
store = AgentObservabilityStore()
|
store = AgentObservabilityStore()
|
||||||
dashboard = AgentObservabilityDashboard(store.load())
|
dashboard = AgentObservabilityDashboard(store.load())
|
||||||
|
|
||||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
|
||||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
|
||||||
|
|
||||||
|
|
||||||
def set_authenticator(fn):
|
|
||||||
"""Install a server-side authenticator used to derive the querying tenant.
|
|
||||||
|
|
||||||
The callable returns the authenticated principal (dict with "user_id" and
|
|
||||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
|
||||||
user/group ids are NOT trusted to escalate scope.
|
|
||||||
"""
|
|
||||||
global _authenticator
|
|
||||||
_authenticator = fn
|
|
||||||
|
|
||||||
|
|
||||||
def _principal():
|
|
||||||
if _authenticator is not None:
|
|
||||||
p = _authenticator()
|
|
||||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def record_agent_execution(payload: dict[str, Any]):
|
def record_agent_execution(payload: dict[str, Any]):
|
||||||
record = AgentExecutionRecord(**payload)
|
record = AgentExecutionRecord(**payload)
|
||||||
@@ -45,30 +23,11 @@ def record_agent_execution(payload: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
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"):
|
||||||
# The authenticated principal (tenant) is authoritative; the request params
|
records = store.query(user_id, group_id, role)
|
||||||
# role/user_id/group_id can never choose owner or another tenant.
|
|
||||||
principal = _principal()
|
|
||||||
pr_user = principal.get("user_id") if principal else None
|
|
||||||
pr_role = principal.get("role", "user") if principal else "user"
|
|
||||||
|
|
||||||
if pr_role == "owner":
|
|
||||||
# Server-verified owner may browse a requested scope.
|
|
||||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
|
||||||
summary_scope = None
|
|
||||||
elif pr_user:
|
|
||||||
# Authenticated non-owner: strictly scoped to own tenant.
|
|
||||||
q_user, q_group, q_role = pr_user, None, "user"
|
|
||||||
summary_scope = pr_user
|
|
||||||
else:
|
|
||||||
# Anonymous: nothing scoped to them.
|
|
||||||
q_user, q_group, q_role = None, None, "user"
|
|
||||||
summary_scope = None
|
|
||||||
|
|
||||||
records = store.query(q_user, q_group, q_role)
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"records": records,
|
"records": records,
|
||||||
"summary": store.load().summary(summary_scope),
|
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ metadata are stored for Web UI statistics and audit.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||||
|
|
||||||
# Constrain execution IDs (used as filename) to a single safe path segment.
|
|
||||||
_SAFE_EXECUTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentObservabilityStore:
|
class AgentObservabilityStore:
|
||||||
def __init__(self, root: str = "runtime/agent-observability"):
|
def __init__(self, root: str = "runtime/agent-observability"):
|
||||||
@@ -25,17 +21,9 @@ class AgentObservabilityStore:
|
|||||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||||
execution_id = str(record.execution_id or "")
|
path = self.records_dir / f"{record.execution_id}.json"
|
||||||
if not _SAFE_EXECUTION_ID.fullmatch(execution_id) or ".." in execution_id:
|
|
||||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
|
||||||
base = self.records_dir.resolve()
|
|
||||||
target = (self.records_dir / f"{execution_id}.json").resolve()
|
|
||||||
# The whitelist forbids separators/traversal, so the resolved target
|
|
||||||
# must sit directly inside the records dir; verify defensively.
|
|
||||||
if target.parent != base:
|
|
||||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
|
||||||
data = record.to_dict()
|
data = record.to_dict()
|
||||||
target.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
|
||||||
|
|
||||||
def load(self) -> AgentObservability:
|
def load(self) -> AgentObservability:
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,29 +13,6 @@ class RecoveryEvidenceError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Relative evidence path: a safe relative path, never absolute, never with
|
|
||||||
# traversal segments, never with backslash separators.
|
|
||||||
_SAFE_EVIDENCE_REL = re.compile(r"^[A-Za-z0-9_./@-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def _contained_evidence(path: Path, base: Path) -> Path:
|
|
||||||
"""Resolve an evidence path and guarantee it stays under `base`."""
|
|
||||||
raw = str(path)
|
|
||||||
if (
|
|
||||||
os.path.isabs(raw)
|
|
||||||
or "\\" in raw
|
|
||||||
or raw.startswith("/")
|
|
||||||
or ".." in Path(raw).parts
|
|
||||||
or not _SAFE_EVIDENCE_REL.fullmatch(raw)
|
|
||||||
):
|
|
||||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_INVALID")
|
|
||||||
base_resolved = base.resolve()
|
|
||||||
target = (base_resolved / raw).resolve()
|
|
||||||
if base_resolved != target and not str(target).startswith(str(base_resolved) + os.sep):
|
|
||||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_ESCAPE")
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(path: Path) -> str:
|
def _digest(path: Path) -> str:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
@@ -106,11 +81,10 @@ def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
|||||||
evidence = receipt["evidence_files"]
|
evidence = receipt["evidence_files"]
|
||||||
if not isinstance(evidence, list) or not evidence:
|
if not isinstance(evidence, list) or not evidence:
|
||||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||||
base = receipt_path.resolve().parent
|
|
||||||
for item in evidence:
|
for item in evidence:
|
||||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||||
target = _contained_evidence(Path(item["path"]), base)
|
target = Path(item["path"])
|
||||||
if _digest(target) != item["sha256"]:
|
if _digest(target) != item["sha256"]:
|
||||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -110,11 +110,6 @@ SECRET_VAL_RE = re.compile(
|
|||||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
|
||||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
|
||||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
|
||||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
|
||||||
|
|
||||||
READ_ONLY_GET_PATHS = frozenset({
|
READ_ONLY_GET_PATHS = frozenset({
|
||||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||||
@@ -1047,25 +1042,12 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
|||||||
commands.append(f.name)
|
commands.append(f.name)
|
||||||
command_detail=None
|
command_detail=None
|
||||||
if cmd_filter:
|
if cmd_filter:
|
||||||
actor = self.actor()
|
|
||||||
if not actor['ok']:
|
|
||||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
|
||||||
cmd = str(cmd_filter or '')
|
|
||||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
|
||||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
|
||||||
for d in cmd_dirs:
|
for d in cmd_dirs:
|
||||||
if not d.exists():
|
if d.exists():
|
||||||
continue
|
fp=d/cmd_filter
|
||||||
base = d.resolve()
|
if fp.exists():
|
||||||
fp = (base / cmd).resolve()
|
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||||
# Strict containment: must resolve directly inside one of the
|
break
|
||||||
# allowed command directories.
|
|
||||||
if fp.parent != base or not fp.is_file():
|
|
||||||
continue
|
|
||||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
|
||||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
|
||||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
|
||||||
break
|
|
||||||
if not command_detail:
|
if not command_detail:
|
||||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||||
return self.sendj(command_detail)
|
return self.sendj(command_detail)
|
||||||
|
|||||||
@@ -24,25 +24,6 @@ def _safe_oid(value: str, field: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _safe_branch(value: str) -> str:
|
|
||||||
branch = str(value or "")
|
|
||||||
if (
|
|
||||||
not branch
|
|
||||||
or len(branch) > 240
|
|
||||||
or branch != branch.strip()
|
|
||||||
or branch.startswith(("/", "-", "."))
|
|
||||||
or branch.endswith(("/", ".", ".lock"))
|
|
||||||
or ".." in branch
|
|
||||||
or "//" in branch
|
|
||||||
or "@{" in branch
|
|
||||||
or "\\" in branch
|
|
||||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
|
||||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
|
||||||
):
|
|
||||||
raise GiteaTransportError("BRANCH_INVALID")
|
|
||||||
return branch
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GiteaMachineTransport:
|
class GiteaMachineTransport:
|
||||||
api_base: str
|
api_base: str
|
||||||
@@ -110,7 +91,6 @@ class GiteaMachineTransport:
|
|||||||
return self.repository_info()
|
return self.repository_info()
|
||||||
|
|
||||||
def read_branch(self, branch: str) -> str | None:
|
def read_branch(self, branch: str) -> str | None:
|
||||||
branch = _safe_branch(branch)
|
|
||||||
owner, name = self.repository_id.split("/", 1)
|
owner, name = self.repository_id.split("/", 1)
|
||||||
try:
|
try:
|
||||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||||
@@ -156,99 +136,6 @@ class GiteaMachineTransport:
|
|||||||
finally:
|
finally:
|
||||||
askpass.unlink(missing_ok=True)
|
askpass.unlink(missing_ok=True)
|
||||||
|
|
||||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
|
||||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
|
||||||
|
|
||||||
This never updates ``main`` or any caller-visible branch. The remote
|
|
||||||
ref is read before and after the fetch so a concurrent branch move
|
|
||||||
cannot be silently admitted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
branch = _safe_branch(branch)
|
|
||||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
|
||||||
remote_before = self.read_branch(branch)
|
|
||||||
if remote_before != expected_oid:
|
|
||||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
|
||||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
|
||||||
|
|
||||||
current = subprocess.run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
f"--git-dir={self.authority_git_dir}",
|
|
||||||
"rev-parse",
|
|
||||||
"--verify",
|
|
||||||
f"{target_ref}^{{commit}}",
|
|
||||||
],
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
|
||||||
remote_after = self.read_branch(branch)
|
|
||||||
if remote_after != expected_oid:
|
|
||||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
|
||||||
return expected_oid
|
|
||||||
|
|
||||||
askpass, additions = self._askpass()
|
|
||||||
environment = os.environ.copy()
|
|
||||||
environment.update(additions)
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
f"--git-dir={self.authority_git_dir}",
|
|
||||||
"fetch",
|
|
||||||
"--no-tags",
|
|
||||||
"--no-write-fetch-head",
|
|
||||||
self.remote_url,
|
|
||||||
f"refs/heads/{branch}:{target_ref}",
|
|
||||||
],
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
env=environment,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
|
||||||
finally:
|
|
||||||
askpass.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
local = subprocess.run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
f"--git-dir={self.authority_git_dir}",
|
|
||||||
"rev-parse",
|
|
||||||
"--verify",
|
|
||||||
f"{target_ref}^{{commit}}",
|
|
||||||
],
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
local_oid = local.stdout.strip().lower()
|
|
||||||
if local.returncode != 0 or local_oid != expected_oid:
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
f"--git-dir={self.authority_git_dir}",
|
|
||||||
"update-ref",
|
|
||||||
"-d",
|
|
||||||
target_ref,
|
|
||||||
],
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
|
||||||
remote_after = self.read_branch(branch)
|
|
||||||
if remote_after != expected_oid:
|
|
||||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
|
||||||
return expected_oid
|
|
||||||
|
|
||||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||||
_safe_oid(expected_oid, "expected_oid")
|
_safe_oid(expected_oid, "expected_oid")
|
||||||
_safe_oid(new_oid, "new_oid")
|
_safe_oid(new_oid, "new_oid")
|
||||||
|
|||||||
@@ -664,23 +664,6 @@ class GitOpsJobController:
|
|||||||
raise JobError("LEASE_DURATION_EXCEEDED")
|
raise JobError("LEASE_DURATION_EXCEEDED")
|
||||||
return _iso_utc(deadline)
|
return _iso_utc(deadline)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def internal_job_id_for_idempotency_key(client_idempotency_key: str) -> str:
|
|
||||||
"""Return the durable Job ID for a client idempotency key."""
|
|
||||||
if (
|
|
||||||
not isinstance(client_idempotency_key, str)
|
|
||||||
or not client_idempotency_key
|
|
||||||
or len(client_idempotency_key) > 128
|
|
||||||
or not all(
|
|
||||||
character.isalnum() or character in "._:-"
|
|
||||||
for character in client_idempotency_key
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
|
||||||
return "job-" + sha256_json({
|
|
||||||
"client_idempotency_key": client_idempotency_key,
|
|
||||||
})[:32]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _binding(request: Mapping[str, Any]) -> dict[str, str]:
|
def _binding(request: Mapping[str, Any]) -> dict[str, str]:
|
||||||
if request.get("schema") == "aios.operation-job.v1":
|
if request.get("schema") == "aios.operation-job.v1":
|
||||||
@@ -784,12 +767,7 @@ class GitOpsJobController:
|
|||||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||||
else:
|
else:
|
||||||
key = sha256_json(binding)
|
key = sha256_json(binding)
|
||||||
job_id = (
|
job_id = "job-" + key[:32]
|
||||||
self.internal_job_id_for_idempotency_key(client_idempotency_key)
|
|
||||||
if binding.get("schema") != "aios.operation-job.v1"
|
|
||||||
and client_idempotency_key is not None
|
|
||||||
else "job-" + key[:32]
|
|
||||||
)
|
|
||||||
durable_request = dict(binding)
|
durable_request = dict(binding)
|
||||||
if payload_ref is not None:
|
if payload_ref is not None:
|
||||||
durable_request.update({"payload_ref": str(payload_ref), "payload_size": int(payload_size)})
|
durable_request.update({"payload_ref": str(payload_ref), "payload_size": int(payload_size)})
|
||||||
|
|||||||
@@ -57,36 +57,10 @@ def seed_base_models():
|
|||||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||||
|
|
||||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
|
||||||
def token_identity(token: str):
|
|
||||||
"""Resolve the server-authoritative identity bound to a token.
|
|
||||||
|
|
||||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
|
||||||
<token> -> authenticated principal, role 'user'
|
|
||||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
|
||||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
|
||||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
|
||||||
"""
|
|
||||||
if DEV_ALLOW:
|
|
||||||
return {'username': 'dev-owner', 'role': 'owner'}
|
|
||||||
if not token or not TOKEN_FILE.exists():
|
|
||||||
return None
|
|
||||||
for line in TOKEN_FILE.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = [x.strip() for x in line.split(':')]
|
|
||||||
if not parts or parts[0] != token:
|
|
||||||
continue
|
|
||||||
if len(parts) >= 3:
|
|
||||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
|
||||||
return {'username': safe_name(parts[1]), 'role': role}
|
|
||||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def valid_token(token: str) -> bool:
|
def valid_token(token: str) -> bool:
|
||||||
if DEV_ALLOW: return True
|
if DEV_ALLOW: return True
|
||||||
return token_identity(token) is not None
|
if not token or not TOKEN_FILE.exists(): return False
|
||||||
|
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||||
def git_cmd(args, check=False):
|
def git_cmd(args, check=False):
|
||||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||||
@@ -116,10 +90,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||||
def actor(self):
|
def actor(self):
|
||||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||||
ident = token_identity(token)
|
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||||
if ident is None:
|
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
|
||||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
|
||||||
def require_write(self, roles=None):
|
def require_write(self, roles=None):
|
||||||
a = self.actor()
|
a = self.actor()
|
||||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||||
|
|||||||
@@ -1,890 +0,0 @@
|
|||||||
"""Read-only four-authority evidence provider for one canonical C04 attachment.
|
|
||||||
|
|
||||||
This module never creates, claims, resumes, reconciles, or mutates a Task or
|
|
||||||
Job. The expired Owner epoch is verified as historical provenance only.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import stat
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping
|
|
||||||
|
|
||||||
from app.aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
|
||||||
from app.aios_single_host_authority import (
|
|
||||||
DEFAULT_OWNER_SIGNER_ID,
|
|
||||||
OWNER_NAMESPACE,
|
|
||||||
RegistryOwnerApprovalVerifier,
|
|
||||||
SignerRegistry,
|
|
||||||
)
|
|
||||||
from app.task_plane.legacy_attachment import (
|
|
||||||
ATTACH_EVIDENCE_SCHEMA,
|
|
||||||
AuthorityReadbackRef,
|
|
||||||
SOURCE_KIND,
|
|
||||||
VerifiedLegacyAttachmentEvidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyAttachmentRuntimeError(RuntimeError):
|
|
||||||
"""Stable fail-closed readback error."""
|
|
||||||
|
|
||||||
|
|
||||||
def _fail(code: str) -> None:
|
|
||||||
raise LegacyAttachmentRuntimeError(code)
|
|
||||||
|
|
||||||
|
|
||||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
||||||
value: dict[str, Any] = {}
|
|
||||||
for key, item in pairs:
|
|
||||||
if key in value:
|
|
||||||
_fail("DUPLICATE_JSON_FIELD")
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _json_object(raw: bytes | str, code: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
if isinstance(raw, bytes):
|
|
||||||
raw = raw.decode("utf-8")
|
|
||||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(code) from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
_fail(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> bytes:
|
|
||||||
try:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
allow_nan=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AUTHORITY_JSON_INVALID") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _hex(value: object, code: str) -> str:
|
|
||||||
if (
|
|
||||||
not isinstance(value, str)
|
|
||||||
or len(value) != 64
|
|
||||||
or any(ch not in "0123456789abcdef" for ch in value)
|
|
||||||
):
|
|
||||||
_fail(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: object, code: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value or value != value.strip():
|
|
||||||
_fail(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, code: str) -> int:
|
|
||||||
if type(value) is not int or value < 0:
|
|
||||||
_fail(code)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: object, code: str) -> str:
|
|
||||||
text = _text(value, code)
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(code) from exc
|
|
||||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
||||||
_fail(code)
|
|
||||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _observed(clock: Callable[[], float]) -> str:
|
|
||||||
return datetime.fromtimestamp(float(clock()), timezone.utc).isoformat().replace(
|
|
||||||
"+00:00", "Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mapping(value: object, code: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
_fail(code)
|
|
||||||
return dict(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _secure_file(
|
|
||||||
path: Path, *, require_root_owner: bool, writable_authority: bool = False
|
|
||||||
) -> os.stat_result:
|
|
||||||
try:
|
|
||||||
info = path.lstat()
|
|
||||||
except OSError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AUTHORITY_FILE_UNAVAILABLE") from exc
|
|
||||||
if path.is_symlink() or not stat.S_ISREG(info.st_mode):
|
|
||||||
_fail("AUTHORITY_FILE_INVALID")
|
|
||||||
if os.name == "posix":
|
|
||||||
if not writable_authority and stat.S_IMODE(info.st_mode) & 0o022:
|
|
||||||
_fail("AUTHORITY_FILE_WRITABLE")
|
|
||||||
if require_root_owner and info.st_uid != 0:
|
|
||||||
_fail("AUTHORITY_FILE_OWNER_INVALID")
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def _secure_directory(path: Path) -> os.stat_result:
|
|
||||||
try:
|
|
||||||
info = path.lstat()
|
|
||||||
except OSError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AUTHORITY_DIRECTORY_UNAVAILABLE") from exc
|
|
||||||
if path.is_symlink() or not stat.S_ISDIR(info.st_mode):
|
|
||||||
_fail("AUTHORITY_DIRECTORY_INVALID")
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def _stable_read(path: Path, *, require_root_owner: bool, maximum: int = 2_000_000) -> bytes:
|
|
||||||
before = _secure_file(path, require_root_owner=require_root_owner)
|
|
||||||
if before.st_size > maximum:
|
|
||||||
_fail("AUTHORITY_FILE_TOO_LARGE")
|
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
except OSError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AUTHORITY_FILE_UNAVAILABLE") from exc
|
|
||||||
after = _secure_file(path, require_root_owner=require_root_owner)
|
|
||||||
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
|
|
||||||
after.st_dev,
|
|
||||||
after.st_ino,
|
|
||||||
after.st_size,
|
|
||||||
after.st_mtime_ns,
|
|
||||||
):
|
|
||||||
_fail("AUTHORITY_FILE_CHANGED")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _ro_connection(path: Path, *, require_root_owner: bool) -> tuple[sqlite3.Connection, os.stat_result]:
|
|
||||||
before = _secure_file(
|
|
||||||
path, require_root_owner=require_root_owner, writable_authority=True
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resolved = path.resolve(strict=True)
|
|
||||||
connection = sqlite3.connect(
|
|
||||||
resolved.as_uri() + "?mode=ro", uri=True, timeout=5.0
|
|
||||||
)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA query_only=ON")
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("BEGIN")
|
|
||||||
return connection, before
|
|
||||||
except (OSError, sqlite3.Error) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AUTHORITY_DB_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _finish_ro(connection: sqlite3.Connection, path: Path, before: os.stat_result) -> None:
|
|
||||||
connection.close()
|
|
||||||
after = _secure_file(
|
|
||||||
path, require_root_owner=False, writable_authority=True
|
|
||||||
)
|
|
||||||
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
|
||||||
_fail("AUTHORITY_DB_CHANGED")
|
|
||||||
|
|
||||||
|
|
||||||
def _columns(connection: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
|
||||||
return tuple(str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})"))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LegacyC04Binding:
|
|
||||||
mission_id: str
|
|
||||||
root_task_id: str
|
|
||||||
anchor_task_id: str
|
|
||||||
owner_principal_id: str
|
|
||||||
original_acceptance_digest: str
|
|
||||||
source_task_id: str
|
|
||||||
external_job_id: str
|
|
||||||
|
|
||||||
def validate(self) -> None:
|
|
||||||
for value in (
|
|
||||||
self.mission_id,
|
|
||||||
self.root_task_id,
|
|
||||||
self.anchor_task_id,
|
|
||||||
self.owner_principal_id,
|
|
||||||
self.source_task_id,
|
|
||||||
self.external_job_id,
|
|
||||||
):
|
|
||||||
_text(value, "C04_BINDING_INVALID")
|
|
||||||
_hex(self.original_acceptance_digest, "C04_ACCEPTANCE_DIGEST_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CredentialFileBearerSupplier:
|
|
||||||
"""Read a systemd credential for one call without caching or displaying it."""
|
|
||||||
|
|
||||||
path: Path
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not self.path.is_absolute():
|
|
||||||
_fail("AGENT_TASK_CREDENTIAL_PATH_INVALID")
|
|
||||||
if os.name == "posix":
|
|
||||||
try:
|
|
||||||
self.path.resolve(strict=False).relative_to(Path("/run/credentials"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(
|
|
||||||
"AGENT_TASK_CREDENTIAL_PATH_INVALID"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def __call__(self) -> str:
|
|
||||||
raw = _stable_read(self.path, require_root_owner=False, maximum=8192)
|
|
||||||
try:
|
|
||||||
value = raw.decode("utf-8").strip()
|
|
||||||
except UnicodeDecodeError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(
|
|
||||||
"AGENT_TASK_CREDENTIAL_INVALID"
|
|
||||||
) from exc
|
|
||||||
if len(value) < 32 or len(value) > 4096:
|
|
||||||
_fail("AGENT_TASK_CREDENTIAL_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_AGENT_TASK_COLUMNS = (
|
|
||||||
"task_id",
|
|
||||||
"goal",
|
|
||||||
"instructions",
|
|
||||||
"success_criteria_json",
|
|
||||||
"required_capabilities_json",
|
|
||||||
"constraints_json",
|
|
||||||
"metadata_json",
|
|
||||||
"deadline",
|
|
||||||
"priority",
|
|
||||||
"max_attempts",
|
|
||||||
"attempt",
|
|
||||||
"status",
|
|
||||||
"claimed_by",
|
|
||||||
"lease_token_hash",
|
|
||||||
"lease_expires_at",
|
|
||||||
"result_json",
|
|
||||||
"result_digest",
|
|
||||||
"result_idempotency_key",
|
|
||||||
"verification_json",
|
|
||||||
"revision",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AuthenticatedAgentTaskReader:
|
|
||||||
"""Authenticated non-mutating proof + read-only authority-row snapshot.
|
|
||||||
|
|
||||||
The authenticated HTTP request is intentionally sent to the task events
|
|
||||||
endpoint because the historical GET /v1/tasks/{id} path calls the global
|
|
||||||
lease-expiry mutator. The task row itself is read from the authoritative
|
|
||||||
SQLite database using query_only mode and an exact schema allowlist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
base_url: str
|
|
||||||
bearer_supplier: Callable[[], str]
|
|
||||||
task_db_path: Path
|
|
||||||
timeout_seconds: float = 10.0
|
|
||||||
clock: Callable[[], float] = time.time
|
|
||||||
require_db_root_owner: bool = False
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
parsed = urllib.parse.urlsplit(self.base_url)
|
|
||||||
if (
|
|
||||||
parsed.scheme != "http"
|
|
||||||
or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
|
|
||||||
or parsed.username is not None
|
|
||||||
or parsed.password is not None
|
|
||||||
or parsed.query
|
|
||||||
or parsed.fragment
|
|
||||||
):
|
|
||||||
_fail("AGENT_TASK_URL_INVALID")
|
|
||||||
if not self.task_db_path.is_absolute():
|
|
||||||
_fail("AGENT_TASK_DB_PATH_INVALID")
|
|
||||||
self.base_url = self.base_url.rstrip("/")
|
|
||||||
|
|
||||||
def _authenticated_events(self, task_id: str) -> tuple[list[dict[str, Any]], str]:
|
|
||||||
token = self.bearer_supplier()
|
|
||||||
if not isinstance(token, str) or len(token.strip()) < 32:
|
|
||||||
_fail("AGENT_TASK_CREDENTIAL_INVALID")
|
|
||||||
url = (
|
|
||||||
self.base_url
|
|
||||||
+ "/v1/tasks/"
|
|
||||||
+ urllib.parse.quote(task_id, safe="._:-")
|
|
||||||
+ "/events"
|
|
||||||
)
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
method="GET",
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer " + token.strip(),
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.build_opener(_NoRedirect).open(
|
|
||||||
request, timeout=self.timeout_seconds
|
|
||||||
) as response:
|
|
||||||
if response.status != 200 or response.geturl() != url:
|
|
||||||
_fail("AGENT_TASK_EVENTS_REJECTED")
|
|
||||||
raw = response.read(2_000_001)
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(
|
|
||||||
"AGENT_TASK_EVENTS_UNAVAILABLE"
|
|
||||||
) from exc
|
|
||||||
finally:
|
|
||||||
token = ""
|
|
||||||
if len(raw) > 2_000_000:
|
|
||||||
_fail("AGENT_TASK_EVENTS_TOO_LARGE")
|
|
||||||
try:
|
|
||||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_strict_object)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("AGENT_TASK_EVENTS_INVALID") from exc
|
|
||||||
if not isinstance(value, list):
|
|
||||||
_fail("AGENT_TASK_EVENTS_INVALID")
|
|
||||||
events: list[dict[str, Any]] = []
|
|
||||||
for event in value:
|
|
||||||
if not isinstance(event, Mapping) or event.get("task_id") != task_id:
|
|
||||||
_fail("AGENT_TASK_EVENTS_INVALID")
|
|
||||||
events.append(dict(event))
|
|
||||||
return events, url
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _task_projection(row: sqlite3.Row) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
body = {
|
|
||||||
"task_id": row["task_id"],
|
|
||||||
"goal": row["goal"],
|
|
||||||
"instructions": row["instructions"],
|
|
||||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
|
||||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
|
||||||
"constraints": json.loads(row["constraints_json"]),
|
|
||||||
"metadata": json.loads(row["metadata_json"]),
|
|
||||||
"deadline": row["deadline"],
|
|
||||||
"priority": row["priority"],
|
|
||||||
"max_attempts": row["max_attempts"],
|
|
||||||
"attempt": row["attempt"],
|
|
||||||
"status": row["status"],
|
|
||||||
"claimed_by": row["claimed_by"],
|
|
||||||
"lease_expires_at": row["lease_expires_at"],
|
|
||||||
"revision": row["revision"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"updated_at": row["updated_at"],
|
|
||||||
"result": json.loads(row["result_json"]) if row["result_json"] else None,
|
|
||||||
"verification": (
|
|
||||||
json.loads(row["verification_json"])
|
|
||||||
if row["verification_json"]
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError(
|
|
||||||
"AGENT_TASK_ROW_INVALID"
|
|
||||||
) from exc
|
|
||||||
_mapping(body["constraints"], "AGENT_TASK_CONSTRAINTS_INVALID")
|
|
||||||
_mapping(body["metadata"], "AGENT_TASK_METADATA_INVALID")
|
|
||||||
_integer(body["revision"], "AGENT_TASK_REVISION_INVALID")
|
|
||||||
if body["status"] not in {"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"}:
|
|
||||||
_fail("AGENT_TASK_NOT_TERMINAL")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def read(self, task_id: str) -> dict[str, Any]:
|
|
||||||
events, events_url = self._authenticated_events(task_id)
|
|
||||||
connection, before = _ro_connection(
|
|
||||||
self.task_db_path, require_root_owner=self.require_db_root_owner
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if _columns(connection, "tasks") != _AGENT_TASK_COLUMNS:
|
|
||||||
_fail("AGENT_TASK_DB_SCHEMA_INVALID")
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM tasks WHERE task_id=? LIMIT 1", (task_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
_fail("AGENT_TASK_NOT_FOUND")
|
|
||||||
body = self._task_projection(row)
|
|
||||||
finally:
|
|
||||||
_finish_ro(connection, self.task_db_path, before)
|
|
||||||
event_digest = _sha(events)
|
|
||||||
return {
|
|
||||||
"body": body,
|
|
||||||
"observed_at": _observed(self.clock),
|
|
||||||
"readback_ref": (
|
|
||||||
events_url
|
|
||||||
+ "#authenticated-events-sha256="
|
|
||||||
+ event_digest
|
|
||||||
+ ";task-db-revision="
|
|
||||||
+ str(body["revision"])
|
|
||||||
),
|
|
||||||
"canonical_sha256": _sha(body),
|
|
||||||
"authenticated_events_sha256": event_digest,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_ADMISSION_COLUMNS = {
|
|
||||||
"task_admission_envelopes": (
|
|
||||||
"idempotency_key", "envelope_id", "request_digest", "envelope_json", "status", "created_at"
|
|
||||||
),
|
|
||||||
"task_admission_grants": (
|
|
||||||
"envelope_id", "grant_id", "grant_digest", "grant_json", "authorization_digest", "created_at"
|
|
||||||
),
|
|
||||||
"task_admission_receipts": ("envelope_id", "task_id", "receipt_json", "created_at"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AdmissionEpochRegistryReader:
|
|
||||||
db_path: Path
|
|
||||||
epoch_root: Path
|
|
||||||
revocation_file: Path
|
|
||||||
registry_path: Path
|
|
||||||
require_db_root_owner: bool = False
|
|
||||||
require_static_root_owner: bool = True
|
|
||||||
clock: Callable[[], float] = time.time
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for path in (self.db_path, self.epoch_root, self.revocation_file, self.registry_path):
|
|
||||||
if not path.is_absolute():
|
|
||||||
_fail("AUTHORITY_PATH_NOT_ABSOLUTE")
|
|
||||||
|
|
||||||
def read(self, task_id: str) -> dict[str, Any]:
|
|
||||||
connection, before = _ro_connection(
|
|
||||||
self.db_path, require_root_owner=self.require_db_root_owner
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if any(_columns(connection, table) != columns for table, columns in _ADMISSION_COLUMNS.items()):
|
|
||||||
_fail("ADMISSION_SCHEMA_INVALID")
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT e.*,g.grant_id,g.grant_digest,g.grant_json,g.authorization_digest,"
|
|
||||||
"g.created_at AS grant_created_at,r.task_id,r.receipt_json,"
|
|
||||||
"r.created_at AS receipt_created_at FROM task_admission_receipts r "
|
|
||||||
"JOIN task_admission_envelopes e ON e.envelope_id=r.envelope_id "
|
|
||||||
"JOIN task_admission_grants g ON g.envelope_id=r.envelope_id WHERE r.task_id=?",
|
|
||||||
(task_id,),
|
|
||||||
).fetchall()
|
|
||||||
if len(rows) != 1:
|
|
||||||
_fail("ADMISSION_READBACK_AMBIGUOUS")
|
|
||||||
row = dict(rows[0])
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("ADMISSION_READBACK_FAILED") from exc
|
|
||||||
finally:
|
|
||||||
_finish_ro(connection, self.db_path, before)
|
|
||||||
|
|
||||||
envelope = _json_object(row["envelope_json"], "ADMISSION_ENVELOPE_INVALID")
|
|
||||||
grant = _json_object(row["grant_json"], "ADMISSION_GRANT_INVALID")
|
|
||||||
receipt = _json_object(row["receipt_json"], "ADMISSION_RECEIPT_INVALID")
|
|
||||||
envelope_id = _text(row["envelope_id"], "ADMISSION_ENVELOPE_INVALID")
|
|
||||||
grant_id = _text(row["grant_id"], "ADMISSION_GRANT_INVALID")
|
|
||||||
epoch_id = _text(grant.get("parent_epoch_id"), "ADMISSION_EPOCH_ID_INVALID")
|
|
||||||
if (
|
|
||||||
row.get("status") != "ADMITTED"
|
|
||||||
or envelope.get("schema") != "aios.task-admission-envelope.v1"
|
|
||||||
or grant.get("schema") != "aios.task-admission-grant.v1"
|
|
||||||
or receipt.get("schema") != "aios.task-admission-receipt.v1"
|
|
||||||
or envelope.get("envelope_id") != envelope_id
|
|
||||||
or grant.get("envelope_id") != envelope_id
|
|
||||||
or receipt.get("envelope_id") != envelope_id
|
|
||||||
or envelope.get("task_id") != task_id
|
|
||||||
or grant.get("task_id") != task_id
|
|
||||||
or receipt.get("task_id") != task_id
|
|
||||||
or grant.get("grant_id") != grant_id
|
|
||||||
or receipt.get("grant_id") != grant_id
|
|
||||||
or grant.get("authorization_kind") != "delegation_epoch"
|
|
||||||
or receipt.get("authorization_kind") != "delegation_epoch"
|
|
||||||
or receipt.get("parent_epoch_id") != epoch_id
|
|
||||||
):
|
|
||||||
_fail("ADMISSION_BINDING_INVALID")
|
|
||||||
request_value = {
|
|
||||||
key: envelope.get(key)
|
|
||||||
for key in (
|
|
||||||
"task_id", "repository", "authority_base_oid", "target_branch",
|
|
||||||
"generation", "execution_digest", "requested_capabilities",
|
|
||||||
"file_allowlist", "risk", "trust_domain", "policy_digest",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if envelope.get("request_digest") != _sha(request_value) or row.get("request_digest") != _sha(request_value):
|
|
||||||
_fail("ADMISSION_REQUEST_DIGEST_INVALID")
|
|
||||||
grant_digest = _sha(grant)
|
|
||||||
if row.get("grant_digest") != grant_digest or receipt.get("grant_digest_sha256") != grant_digest:
|
|
||||||
_fail("ADMISSION_GRANT_DIGEST_INVALID")
|
|
||||||
auth_value = {
|
|
||||||
"envelope_id": envelope_id,
|
|
||||||
"authorization_kind": "delegation_epoch",
|
|
||||||
"delegation_epoch_id": epoch_id,
|
|
||||||
}
|
|
||||||
if row.get("authorization_digest") != _sha(auth_value):
|
|
||||||
_fail("ADMISSION_AUTHORIZATION_DIGEST_INVALID")
|
|
||||||
linked = (
|
|
||||||
"repository", "authority_base_oid", "target_branch", "generation",
|
|
||||||
"execution_digest", "policy_digest", "trust_domain", "risk",
|
|
||||||
)
|
|
||||||
if any(grant.get(key) != envelope.get(key) for key in linked):
|
|
||||||
_fail("ADMISSION_GRANT_SCOPE_INVALID")
|
|
||||||
if grant.get("capabilities") != envelope.get("requested_capabilities") or grant.get("file_allowlist") != envelope.get("file_allowlist"):
|
|
||||||
_fail("ADMISSION_GRANT_SCOPE_INVALID")
|
|
||||||
if receipt.get("execution_digest") != grant.get("execution_digest"):
|
|
||||||
_fail("ADMISSION_RECEIPT_SCOPE_INVALID")
|
|
||||||
|
|
||||||
_secure_directory(self.epoch_root)
|
|
||||||
canonical_path = self.epoch_root / "owner-delegation-epoch.canonical.json"
|
|
||||||
signature_path = self.epoch_root / "owner-delegation-epoch.sig"
|
|
||||||
key_path = self.epoch_root / "owner-delegation-epoch.key-id"
|
|
||||||
canonical = _stable_read(canonical_path, require_root_owner=self.require_static_root_owner)
|
|
||||||
epoch = _json_object(canonical, "OWNER_EPOCH_INVALID")
|
|
||||||
if _canonical(epoch) != canonical:
|
|
||||||
_fail("OWNER_EPOCH_NOT_CANONICAL")
|
|
||||||
signature = _stable_read(signature_path, require_root_owner=self.require_static_root_owner).decode("utf-8")
|
|
||||||
key_id = _stable_read(key_path, require_root_owner=self.require_static_root_owner).decode("utf-8").strip()
|
|
||||||
registry_raw = _stable_read(self.registry_path, require_root_owner=self.require_static_root_owner)
|
|
||||||
registry_json = _json_object(registry_raw, "OWNER_REGISTRY_INVALID")
|
|
||||||
revocations = _json_object(
|
|
||||||
_stable_read(self.revocation_file, require_root_owner=self.require_static_root_owner),
|
|
||||||
"OWNER_REVOCATIONS_INVALID",
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
set(revocations) != {"schema", "revoked_epoch_ids"}
|
|
||||||
or revocations.get("schema") != "aios.owner-delegation-revocations.v1"
|
|
||||||
or not isinstance(revocations.get("revoked_epoch_ids"), list)
|
|
||||||
or epoch_id in revocations["revoked_epoch_ids"]
|
|
||||||
):
|
|
||||||
_fail("OWNER_EPOCH_REVOKED")
|
|
||||||
try:
|
|
||||||
registry = SignerRegistry(
|
|
||||||
self.registry_path, require_root_owner=self.require_static_root_owner
|
|
||||||
)
|
|
||||||
record = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
|
||||||
verified = RegistryOwnerApprovalVerifier(
|
|
||||||
self.registry_path,
|
|
||||||
require_root_owner=self.require_static_root_owner,
|
|
||||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
|
||||||
).verify(
|
|
||||||
namespace=OWNER_NAMESPACE,
|
|
||||||
payload=canonical,
|
|
||||||
signature=signature,
|
|
||||||
key_id=key_id,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("OWNER_REGISTRY_VERIFICATION_FAILED") from exc
|
|
||||||
now = int(self.clock())
|
|
||||||
provenance_checks = {
|
|
||||||
"signature": verified is True,
|
|
||||||
"fingerprint": record.fingerprint == key_id,
|
|
||||||
"schema": epoch.get("schema") == "aios.owner-delegation-epoch.v1",
|
|
||||||
"epoch_id": epoch.get("epoch_id") == epoch_id,
|
|
||||||
"owner": epoch.get("owner_identity") == grant.get("owner_identity"),
|
|
||||||
"repository": epoch.get("repository") == grant.get("repository"),
|
|
||||||
"trust_domain": epoch.get("trust_domain") == grant.get("trust_domain"),
|
|
||||||
"policy": epoch.get("policy_digest") == grant.get("policy_digest"),
|
|
||||||
"revocation_epoch": epoch.get("revocation_epoch") == 0,
|
|
||||||
"expires_type": type(epoch.get("expires_at")) is int,
|
|
||||||
"not_before_type": type(epoch.get("not_before")) is int,
|
|
||||||
}
|
|
||||||
failed = [name for name, passed in provenance_checks.items() if not passed]
|
|
||||||
if failed:
|
|
||||||
_fail("OWNER_EPOCH_PROVENANCE_INVALID:" + ",".join(sorted(failed)))
|
|
||||||
if (
|
|
||||||
now < epoch["expires_at"]
|
|
||||||
or type(epoch.get("issued_at")) is not int
|
|
||||||
or epoch["issued_at"] > grant.get("issued_at", 0)
|
|
||||||
or grant.get("issued_at", 0) < epoch["not_before"]
|
|
||||||
or grant.get("expires_at", 0) > epoch["expires_at"]
|
|
||||||
):
|
|
||||||
_fail("OWNER_EPOCH_PROVENANCE_INVALID:temporal")
|
|
||||||
if not any(str(grant["target_branch"]).startswith(str(prefix)) for prefix in epoch.get("allowed_branch_prefixes", [])):
|
|
||||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
|
||||||
if not set(grant.get("capabilities", [])).issubset(set(epoch.get("allowed_capabilities", []))):
|
|
||||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
|
||||||
risk_rank = {"low": 0, "medium": 1, "high": 2}
|
|
||||||
if (
|
|
||||||
grant.get("risk") not in risk_rank
|
|
||||||
or epoch.get("risk_ceiling") not in risk_rank
|
|
||||||
or risk_rank[grant["risk"]] > risk_rank[epoch["risk_ceiling"]]
|
|
||||||
):
|
|
||||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
|
||||||
if any(
|
|
||||||
not any(path == prefix.rstrip("/") or path.startswith(prefix.rstrip("/") + "/") for prefix in epoch.get("allowed_file_prefixes", []))
|
|
||||||
for path in grant.get("file_allowlist", [])
|
|
||||||
):
|
|
||||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
|
||||||
|
|
||||||
admission_projection = {
|
|
||||||
"envelope": envelope,
|
|
||||||
"grant": grant,
|
|
||||||
"receipt": receipt,
|
|
||||||
"request_digest": row["request_digest"],
|
|
||||||
"grant_digest": row["grant_digest"],
|
|
||||||
"authorization_digest": row["authorization_digest"],
|
|
||||||
"envelope_status": row["status"],
|
|
||||||
}
|
|
||||||
owner_projection = {
|
|
||||||
"epoch": epoch,
|
|
||||||
"epoch_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
||||||
"epoch_key_id": key_id,
|
|
||||||
"signature_verified": True,
|
|
||||||
"epoch_currently_valid": False,
|
|
||||||
"revocations": revocations,
|
|
||||||
"registry_schema": registry_json.get("schema"),
|
|
||||||
"registry_generation": registry.authority_generation,
|
|
||||||
"signer": record.to_dict(),
|
|
||||||
}
|
|
||||||
observed = _observed(self.clock)
|
|
||||||
return {
|
|
||||||
"envelope": envelope,
|
|
||||||
"grant": grant,
|
|
||||||
"receipt": receipt,
|
|
||||||
"epoch": epoch,
|
|
||||||
"epoch_digest": hashlib.sha256(canonical).hexdigest(),
|
|
||||||
"signer_id": record.signer_id,
|
|
||||||
"signer_fingerprint": record.fingerprint,
|
|
||||||
"registry_generation": registry.authority_generation,
|
|
||||||
"observed_at": observed,
|
|
||||||
"admission_ref": f"sqlite:{self.db_path}#task={task_id};envelope={envelope_id}",
|
|
||||||
"admission_sha256": _sha(admission_projection),
|
|
||||||
"registry_ref": f"file:{self.registry_path}#generation={registry.authority_generation};epoch={epoch_id}",
|
|
||||||
"registry_sha256": _sha(owner_projection),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_JOB_COLUMNS = {
|
|
||||||
"job_aliases": (
|
|
||||||
"external_job_id", "internal_job_id", "task_id", "repository", "target_branch",
|
|
||||||
"branch_mode", "generation", "commit_idempotency_key", "expected_branch_oid",
|
|
||||||
"request_digest", "adopted_legacy", "created_at",
|
|
||||||
),
|
|
||||||
"jobs": (
|
|
||||||
"job_id", "idempotency_key", "campaign_id", "repository", "operation", "branch",
|
|
||||||
"expected_branch_oid", "payload_digest", "payload_ref", "payload_size", "state",
|
|
||||||
"side_effect_state", "revision", "request_json", "receipt_json", "created_at", "updated_at",
|
|
||||||
),
|
|
||||||
"idempotency_keys": ("key", "job_id", "binding_json", "created_at"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class BridgeJobSQLiteReader:
|
|
||||||
jobs_root: Path
|
|
||||||
require_root_owner: bool = False
|
|
||||||
clock: Callable[[], float] = time.time
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not self.jobs_root.is_absolute():
|
|
||||||
_fail("BRIDGE_JOB_ROOT_NOT_ABSOLUTE")
|
|
||||||
|
|
||||||
def read(self, task_id: str, external_job_id: str) -> dict[str, Any]:
|
|
||||||
exact_root = self.jobs_root / task_storage_key(task_id)
|
|
||||||
legacy_root = self.jobs_root / legacy_task_storage_key(task_id)
|
|
||||||
db_path = exact_root / "state.sqlite3"
|
|
||||||
if legacy_root != exact_root and legacy_root.exists():
|
|
||||||
_fail("BRIDGE_JOB_STORE_AMBIGUOUS")
|
|
||||||
_secure_directory(self.jobs_root)
|
|
||||||
_secure_directory(exact_root)
|
|
||||||
try:
|
|
||||||
if db_path.resolve(strict=True).parent != exact_root.resolve(strict=True):
|
|
||||||
_fail("BRIDGE_JOB_STORE_INVALID")
|
|
||||||
except OSError as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_STORE_INVALID") from exc
|
|
||||||
connection, before = _ro_connection(
|
|
||||||
db_path, require_root_owner=self.require_root_owner
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if any(_columns(connection, table) != columns for table, columns in _JOB_COLUMNS.items()):
|
|
||||||
_fail("BRIDGE_JOB_SCHEMA_INVALID")
|
|
||||||
aliases = connection.execute(
|
|
||||||
"SELECT * FROM job_aliases WHERE external_job_id=? AND task_id=?",
|
|
||||||
(external_job_id, task_id),
|
|
||||||
).fetchall()
|
|
||||||
if len(aliases) != 1:
|
|
||||||
_fail("BRIDGE_JOB_ALIAS_AMBIGUOUS")
|
|
||||||
alias = dict(aliases[0])
|
|
||||||
job_rows = connection.execute(
|
|
||||||
"SELECT * FROM jobs WHERE job_id=?", (alias["internal_job_id"],)
|
|
||||||
).fetchall()
|
|
||||||
key_rows = connection.execute(
|
|
||||||
"SELECT * FROM idempotency_keys WHERE job_id=?", (alias["internal_job_id"],)
|
|
||||||
).fetchall()
|
|
||||||
if len(job_rows) != 1 or len(key_rows) != 1:
|
|
||||||
_fail("BRIDGE_JOB_BINDING_MISSING")
|
|
||||||
job, key = dict(job_rows[0]), dict(key_rows[0])
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_READBACK_FAILED") from exc
|
|
||||||
finally:
|
|
||||||
_finish_ro(connection, db_path, before)
|
|
||||||
request = _json_object(job.get("request_json") or "{}", "BRIDGE_JOB_REQUEST_INVALID")
|
|
||||||
binding = _mapping(request.get("binding", request), "BRIDGE_JOB_REQUEST_INVALID")
|
|
||||||
key_binding = _json_object(key.get("binding_json") or "{}", "BRIDGE_JOB_KEY_BINDING_INVALID")
|
|
||||||
external_commit_key = _text(alias.get("commit_idempotency_key"), "BRIDGE_EXTERNAL_IDEMPOTENCY_INVALID")
|
|
||||||
internal_key = _text(job.get("idempotency_key"), "BRIDGE_INTERNAL_IDEMPOTENCY_INVALID")
|
|
||||||
if (
|
|
||||||
alias.get("external_job_id") != external_job_id
|
|
||||||
or alias.get("task_id") != task_id
|
|
||||||
or job.get("job_id") != alias.get("internal_job_id")
|
|
||||||
or job.get("campaign_id") != task_id
|
|
||||||
or key.get("job_id") != job.get("job_id")
|
|
||||||
or key.get("key") != internal_key
|
|
||||||
or binding.get("task_id", binding.get("campaign_id")) != task_id
|
|
||||||
or binding.get("external_job_id") != external_job_id
|
|
||||||
or binding.get("idempotency_key") != external_commit_key
|
|
||||||
or key_binding.get("task_id", key_binding.get("campaign_id")) != task_id
|
|
||||||
or key_binding.get("external_job_id") != external_job_id
|
|
||||||
or key_binding.get("idempotency_key") != external_commit_key
|
|
||||||
or alias.get("repository") != job.get("repository")
|
|
||||||
or alias.get("repository") != binding.get("repository")
|
|
||||||
or alias.get("target_branch") != job.get("branch")
|
|
||||||
or alias.get("target_branch") != binding.get("branch", binding.get("target_branch"))
|
|
||||||
):
|
|
||||||
_fail("BRIDGE_JOB_BINDING_INVALID")
|
|
||||||
try:
|
|
||||||
if int(alias.get("generation")) != int(binding.get("generation")):
|
|
||||||
_fail("BRIDGE_JOB_BINDING_INVALID")
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_BINDING_INVALID") from exc
|
|
||||||
projection = {
|
|
||||||
"alias": alias,
|
|
||||||
"job": job,
|
|
||||||
"idempotency": key,
|
|
||||||
"external_commit_idempotency_key": external_commit_key,
|
|
||||||
"internal_idempotency_key": internal_key,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
**projection,
|
|
||||||
"observed_at": _observed(self.clock),
|
|
||||||
"readback_ref": f"sqlite:{db_path}#external={external_job_id};internal={job['job_id']}",
|
|
||||||
"canonical_sha256": _sha(projection),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class LegacyAttachmentRuntimeProvider:
|
|
||||||
binding: LegacyC04Binding
|
|
||||||
agent_task: AuthenticatedAgentTaskReader
|
|
||||||
admission: AdmissionEpochRegistryReader
|
|
||||||
bridge_job: BridgeJobSQLiteReader
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
self.binding.validate()
|
|
||||||
|
|
||||||
def read_verified(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mission_id: str,
|
|
||||||
root_task_id: str,
|
|
||||||
anchor_task_id: str,
|
|
||||||
source_kind: str,
|
|
||||||
source_task_id: str,
|
|
||||||
external_job_id: str,
|
|
||||||
original_acceptance_digest: str,
|
|
||||||
) -> VerifiedLegacyAttachmentEvidence:
|
|
||||||
expected = self.binding
|
|
||||||
if (
|
|
||||||
mission_id != expected.mission_id
|
|
||||||
or root_task_id != expected.root_task_id
|
|
||||||
or anchor_task_id != expected.anchor_task_id
|
|
||||||
or source_kind != SOURCE_KIND
|
|
||||||
or source_task_id != expected.source_task_id
|
|
||||||
or external_job_id != expected.external_job_id
|
|
||||||
or original_acceptance_digest != expected.original_acceptance_digest
|
|
||||||
):
|
|
||||||
_fail("C04_AUTHORITY_BINDING_MISMATCH")
|
|
||||||
|
|
||||||
task_read = self.agent_task.read(source_task_id)
|
|
||||||
admission_read = self.admission.read(source_task_id)
|
|
||||||
job_read = self.bridge_job.read(source_task_id, external_job_id)
|
|
||||||
task = task_read["body"]
|
|
||||||
metadata = _mapping(task["metadata"], "AGENT_TASK_METADATA_INVALID")
|
|
||||||
constraints = _mapping(task["constraints"], "AGENT_TASK_CONSTRAINTS_INVALID")
|
|
||||||
execution = _mapping(metadata.get("execution"), "AGENT_TASK_EXECUTION_INVALID")
|
|
||||||
grant = admission_read["grant"]
|
|
||||||
envelope = admission_read["envelope"]
|
|
||||||
alias, job = job_read["alias"], job_read["job"]
|
|
||||||
try:
|
|
||||||
immutable_metadata = _json_object(
|
|
||||||
constraints.get("immutable_metadata", ""), "AGENT_TASK_IMMUTABLE_METADATA_INVALID"
|
|
||||||
)
|
|
||||||
except LegacyAttachmentRuntimeError:
|
|
||||||
raise
|
|
||||||
if (
|
|
||||||
immutable_metadata != metadata
|
|
||||||
or metadata.get("grant_id") != grant.get("grant_id")
|
|
||||||
or metadata.get("grant_digest_sha256") != admission_read["receipt"].get("grant_digest_sha256")
|
|
||||||
or metadata.get("execution_digest") != grant.get("execution_digest")
|
|
||||||
or execution.get("execution_digest") != grant.get("execution_digest")
|
|
||||||
or execution.get("external_job_id") != external_job_id
|
|
||||||
or execution.get("task_id") != source_task_id
|
|
||||||
or metadata.get("repository") != grant.get("repository")
|
|
||||||
or metadata.get("target_branch") != grant.get("target_branch")
|
|
||||||
or metadata.get("generation") != grant.get("generation")
|
|
||||||
or constraints.get("repository") != grant.get("repository")
|
|
||||||
or constraints.get("target_branch") != grant.get("target_branch")
|
|
||||||
or constraints.get("generation") != grant.get("generation")
|
|
||||||
or envelope.get("task_id") != source_task_id
|
|
||||||
or alias.get("repository") != grant.get("repository")
|
|
||||||
or alias.get("target_branch") != grant.get("target_branch")
|
|
||||||
or alias.get("generation") != grant.get("generation")
|
|
||||||
or admission_read["signer_id"] != expected.owner_principal_id
|
|
||||||
):
|
|
||||||
_fail("FOUR_AUTHORITY_BINDING_INVALID")
|
|
||||||
|
|
||||||
return VerifiedLegacyAttachmentEvidence(
|
|
||||||
schema=ATTACH_EVIDENCE_SCHEMA,
|
|
||||||
source_kind=SOURCE_KIND,
|
|
||||||
source_task_id=source_task_id,
|
|
||||||
source_task_created_at=_iso(task.get("created_at"), "AGENT_TASK_CREATED_AT_INVALID"),
|
|
||||||
source_task_status=_text(task.get("status"), "AGENT_TASK_STATUS_INVALID"),
|
|
||||||
source_task_revision=_integer(task.get("revision"), "AGENT_TASK_REVISION_INVALID"),
|
|
||||||
source_task_attempt=_integer(task.get("attempt"), "AGENT_TASK_ATTEMPT_INVALID"),
|
|
||||||
source_task_max_attempts=_integer(task.get("max_attempts"), "AGENT_TASK_MAX_ATTEMPTS_INVALID"),
|
|
||||||
external_job_id=external_job_id,
|
|
||||||
internal_job_id=_text(job.get("job_id"), "BRIDGE_INTERNAL_JOB_INVALID"),
|
|
||||||
internal_idempotency_key=_text(job.get("idempotency_key"), "BRIDGE_INTERNAL_IDEMPOTENCY_INVALID"),
|
|
||||||
job_branch=_text(job.get("branch"), "BRIDGE_JOB_BRANCH_INVALID"),
|
|
||||||
job_state=_text(job.get("state"), "BRIDGE_JOB_STATE_INVALID"),
|
|
||||||
job_revision=_integer(job.get("revision"), "BRIDGE_JOB_REVISION_INVALID"),
|
|
||||||
side_effect_state=_text(job.get("side_effect_state"), "BRIDGE_SIDE_EFFECT_INVALID"),
|
|
||||||
grant_id=_text(grant.get("grant_id"), "ADMISSION_GRANT_INVALID"),
|
|
||||||
grant_digest=_hex(admission_read["receipt"].get("grant_digest_sha256"), "ADMISSION_GRANT_DIGEST_INVALID"),
|
|
||||||
execution_digest=_hex(grant.get("execution_digest"), "ADMISSION_EXECUTION_DIGEST_INVALID"),
|
|
||||||
epoch_id=_text(admission_read["epoch"].get("epoch_id"), "OWNER_EPOCH_ID_INVALID"),
|
|
||||||
epoch_digest=_hex(admission_read["epoch_digest"], "OWNER_EPOCH_DIGEST_INVALID"),
|
|
||||||
historical_signer_id=admission_read["signer_id"],
|
|
||||||
historical_signer_fingerprint=admission_read["signer_fingerprint"],
|
|
||||||
registry_generation=_integer(admission_read["registry_generation"], "OWNER_REGISTRY_GENERATION_INVALID"),
|
|
||||||
signature_verified=True,
|
|
||||||
epoch_currently_valid=False,
|
|
||||||
readbacks=(
|
|
||||||
AuthorityReadbackRef(
|
|
||||||
role="agent_task", authority="aios-agent-task-api",
|
|
||||||
readback_ref=task_read["readback_ref"], canonical_sha256=task_read["canonical_sha256"],
|
|
||||||
observed_at=task_read["observed_at"], revision=task["revision"],
|
|
||||||
),
|
|
||||||
AuthorityReadbackRef(
|
|
||||||
role="admission", authority="aios-task-admission-store",
|
|
||||||
readback_ref=admission_read["admission_ref"], canonical_sha256=admission_read["admission_sha256"],
|
|
||||||
observed_at=admission_read["observed_at"], revision=None,
|
|
||||||
),
|
|
||||||
AuthorityReadbackRef(
|
|
||||||
role="owner_registry", authority="aios-single-host-owner-authority",
|
|
||||||
readback_ref=admission_read["registry_ref"], canonical_sha256=admission_read["registry_sha256"],
|
|
||||||
observed_at=admission_read["observed_at"], revision=admission_read["registry_generation"],
|
|
||||||
),
|
|
||||||
AuthorityReadbackRef(
|
|
||||||
role="bridge_job", authority="aios-gitops-job-controller",
|
|
||||||
readback_ref=job_read["readback_ref"], canonical_sha256=job_read["canonical_sha256"],
|
|
||||||
observed_at=job_read["observed_at"], revision=job["revision"],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||||
from silent_release_controller import ReleaseCandidate
|
from silent_release_controller import ReleaseCandidate
|
||||||
@@ -115,9 +114,6 @@ TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
|||||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
||||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
||||||
))
|
))
|
||||||
TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT = str(os.getenv(
|
|
||||||
'AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT', ''
|
|
||||||
)).strip()
|
|
||||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
||||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY',
|
'AIOS_SILENT_RELEASE_TRUSTED_POLICY',
|
||||||
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
||||||
@@ -135,11 +131,6 @@ SECRET_VAL_RE = re.compile(
|
|||||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
|
||||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
|
||||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
|
||||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
|
||||||
|
|
||||||
READ_ONLY_GET_PATHS = frozenset({
|
READ_ONLY_GET_PATHS = frozenset({
|
||||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||||
@@ -160,7 +151,6 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
|||||||
'/submitSignedApproval',
|
'/submitSignedApproval',
|
||||||
'/submitOwnerApproval',
|
'/submitOwnerApproval',
|
||||||
'/startDeployment',
|
'/startDeployment',
|
||||||
'/reopenOwnerApproval',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||||
@@ -263,22 +253,6 @@ class _HostTrustedCandidateBuilderAdapter:
|
|||||||
nonce=secrets.token_hex(32),
|
nonce=secrets.token_hex(32),
|
||||||
)
|
)
|
||||||
except BuilderError as exc:
|
except BuilderError as exc:
|
||||||
code = str(exc)
|
|
||||||
if code in {'SOURCE_ADMISSION_REQUIRED', 'RECIPE_UNKNOWN'} or (
|
|
||||||
code == 'REQUEST_RELEASE_BINDING_INVALID'
|
|
||||||
and not intent.get('version')
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'release source admission required'
|
|
||||||
) from exc
|
|
||||||
if code == 'MIGRATION_PLAN_REQUIRED':
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'migration plan required'
|
|
||||||
) from exc
|
|
||||||
if code == 'MIGRATION_PLAN_INVALID':
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'migration plan invalid'
|
|
||||||
) from exc
|
|
||||||
raise TrustedCandidateBuilderUnavailable(
|
raise TrustedCandidateBuilderUnavailable(
|
||||||
'trusted builder request failed'
|
'trusted builder request failed'
|
||||||
) from exc
|
) from exc
|
||||||
@@ -316,7 +290,6 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
|||||||
builder_attestation_verifier = None
|
builder_attestation_verifier = None
|
||||||
trusted_policy = None
|
trusted_policy = None
|
||||||
builder_readiness_probe = None
|
builder_readiness_probe = None
|
||||||
source_admission_resolver = None
|
|
||||||
if TRUSTED_BUILDER_ENABLED:
|
if TRUSTED_BUILDER_ENABLED:
|
||||||
# Every path is opt-in. In particular, this API receives its own
|
# Every path is opt-in. In particular, this API receives its own
|
||||||
# client HMAC credential, never the builder's signing private key.
|
# client HMAC credential, never the builder's signing private key.
|
||||||
@@ -344,33 +317,6 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
|||||||
client_hmac = (
|
client_hmac = (
|
||||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
||||||
).read_bytes().strip()
|
).read_bytes().strip()
|
||||||
if TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT:
|
|
||||||
from release_source_admission import (
|
|
||||||
open_source_admission_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
source_admission_hmac = (
|
|
||||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY
|
|
||||||
/ 'source_admission_hmac'
|
|
||||||
).read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
'release source admission credential is unavailable'
|
|
||||||
) from exc
|
|
||||||
source_store = open_source_admission_store(
|
|
||||||
Path(TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT),
|
|
||||||
repository=builder_config.repository,
|
|
||||||
policy=TRUSTED_BUILDER_POLICY,
|
|
||||||
hmac_key=source_admission_hmac,
|
|
||||||
expected_owner_uid=0,
|
|
||||||
writable=False,
|
|
||||||
)
|
|
||||||
builder_config = replace(
|
|
||||||
builder_config,
|
|
||||||
source_admission_resolver=source_store.resolve_recipe,
|
|
||||||
)
|
|
||||||
source_admission_resolver = source_store.lookup_source
|
|
||||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
||||||
client = UnixTrustedCandidateBuilderClient(
|
client = UnixTrustedCandidateBuilderClient(
|
||||||
builder_config,
|
builder_config,
|
||||||
@@ -419,7 +365,6 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
|||||||
builder_attestation_verifier=builder_attestation_verifier,
|
builder_attestation_verifier=builder_attestation_verifier,
|
||||||
trusted_policy=trusted_policy,
|
trusted_policy=trusted_policy,
|
||||||
builder_readiness_probe=builder_readiness_probe,
|
builder_readiness_probe=builder_readiness_probe,
|
||||||
source_admission_resolver=source_admission_resolver,
|
|
||||||
allow_legacy_candidate_input=False,
|
allow_legacy_candidate_input=False,
|
||||||
expected_repository=REPOSITORY_IDENTITY,
|
expected_repository=REPOSITORY_IDENTITY,
|
||||||
policy_sha256=policy_sha,
|
policy_sha256=policy_sha,
|
||||||
@@ -695,6 +640,20 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(body, exc.code)
|
return self.sendj(body, exc.code)
|
||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||||
|
def do_POST(self):
|
||||||
|
path = urllib.parse.urlparse(self.path).path
|
||||||
|
if path in {
|
||||||
|
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||||
|
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||||
|
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||||
|
}:
|
||||||
|
actor = self.need('tag:create')
|
||||||
|
if not actor:
|
||||||
|
return
|
||||||
|
return self.bridge_release(path, self.body())
|
||||||
|
if reject_non_read_http(self, 'POST', path):
|
||||||
|
return
|
||||||
|
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||||
@@ -940,17 +899,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||||
if reject_non_read_http(self, 'POST', path): return
|
if reject_non_read_http(self, 'POST', path): return
|
||||||
data=self.body()
|
data=self.body()
|
||||||
# Governed V2 bridge release routes (previously shadowed by the
|
|
||||||
# redefinition of do_POST in this class).
|
|
||||||
if path in {
|
|
||||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
|
||||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
|
||||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
|
||||||
}:
|
|
||||||
actor = self.need('tag:create')
|
|
||||||
if not actor:
|
|
||||||
return
|
|
||||||
return self.bridge_release(path, self.body())
|
|
||||||
try:
|
try:
|
||||||
if path == '/createReleaseCandidate':
|
if path == '/createReleaseCandidate':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
@@ -977,17 +925,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(
|
return self.sendj(
|
||||||
silent_release_service().submit_owner_approval(data)
|
silent_release_service().submit_owner_approval(data)
|
||||||
)
|
)
|
||||||
if path == "/reopenOwnerApproval":
|
|
||||||
a = self.need("release:approve")
|
|
||||||
if not a:
|
|
||||||
return
|
|
||||||
from release_approval_reopen import ApprovalReopenError, reopen_owner_approval
|
|
||||||
try:
|
|
||||||
result = reopen_owner_approval(silent_release_service(), data)
|
|
||||||
except ApprovalReopenError as exc:
|
|
||||||
return self.sendj({"error": exc.code, "message": str(exc)}, 409)
|
|
||||||
return self.sendj(result)
|
|
||||||
|
|
||||||
if path == '/startDeployment':
|
if path == '/startDeployment':
|
||||||
a=self.need('release:deploy')
|
a=self.need('release:deploy')
|
||||||
if not a: return
|
if not a: return
|
||||||
@@ -1250,25 +1187,12 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
|||||||
commands.append(f.name)
|
commands.append(f.name)
|
||||||
command_detail=None
|
command_detail=None
|
||||||
if cmd_filter:
|
if cmd_filter:
|
||||||
actor = self.actor()
|
|
||||||
if not actor['ok']:
|
|
||||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
|
||||||
cmd = str(cmd_filter or '')
|
|
||||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
|
||||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
|
||||||
for d in cmd_dirs:
|
for d in cmd_dirs:
|
||||||
if not d.exists():
|
if d.exists():
|
||||||
continue
|
fp=d/cmd_filter
|
||||||
base = d.resolve()
|
if fp.exists():
|
||||||
fp = (base / cmd).resolve()
|
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||||
# Strict containment: must resolve directly inside one of the
|
break
|
||||||
# allowed command directories.
|
|
||||||
if fp.parent != base or not fp.is_file():
|
|
||||||
continue
|
|
||||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
|
||||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
|
||||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
|
||||||
break
|
|
||||||
if not command_detail:
|
if not command_detail:
|
||||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||||
return self.sendj(command_detail)
|
return self.sendj(command_detail)
|
||||||
|
|||||||
@@ -929,23 +929,10 @@ class TaskAdmissionBootstrapService:
|
|||||||
trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest,
|
trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest,
|
||||||
authorization_kind=kind, owner_identity=owner_identity,
|
authorization_kind=kind, owner_identity=owner_identity,
|
||||||
parent_epoch_id=parent_epoch_id, issued_at=now,
|
parent_epoch_id=parent_epoch_id, issued_at=now,
|
||||||
# V2R2C2 TTL contract: the admission envelope is a *signing
|
expires_at=min(
|
||||||
# window* only. Once authorization succeeds (epoch verified or
|
envelope.expires_at,
|
||||||
# Owner signature verified) the envelope is consumed, and the
|
now + self.policy.max_task_ttl_seconds,
|
||||||
# derived grant's lifecycle is bounded by the task/grant policy
|
parent_epoch.expires_at if parent_epoch is not None else envelope.expires_at,
|
||||||
# ceiling (max_task_ttl_seconds) and, for a delegation epoch,
|
|
||||||
# by the parent epoch's own expiry. It must NOT be truncated by
|
|
||||||
# the already-consumed envelope's remaining window, otherwise a
|
|
||||||
# nominal 3600s policy collapses to the ~900s envelope window
|
|
||||||
# (dual-semantic TTL defect). The envelope's temporal validity
|
|
||||||
# still gates authorization (now < envelope.expires_at above).
|
|
||||||
expires_at=(
|
|
||||||
now + self.policy.max_task_ttl_seconds
|
|
||||||
if parent_epoch is None
|
|
||||||
else min(
|
|
||||||
now + self.policy.max_task_ttl_seconds,
|
|
||||||
parent_epoch.expires_at,
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
persisted_grant, _ = self.store.claim_grant(
|
persisted_grant, _ = self.store.claim_grant(
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ import base64
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
@@ -31,15 +29,6 @@ from aios_task_admission_bootstrap import (
|
|||||||
canonical_json_bytes,
|
canonical_json_bytes,
|
||||||
sha256_json,
|
sha256_json,
|
||||||
)
|
)
|
||||||
from aios_gitops_job_controller import (
|
|
||||||
GitOpsJobController as _FLGitOpsJobController,
|
|
||||||
JobState as _FLJobState,
|
|
||||||
sha256_json as _fl_job_sha256_json,
|
|
||||||
)
|
|
||||||
from aios_gitops_job_identity import (
|
|
||||||
legacy_task_storage_key as _fl_legacy_task_storage_key,
|
|
||||||
task_storage_key as _fl_task_storage_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||||
@@ -1080,8 +1069,6 @@ PLAN_FIELDS_V3=frozenset({
|
|||||||
_SHA256=re.compile(r"^[0-9a-f]{64}$")
|
_SHA256=re.compile(r"^[0-9a-f]{64}$")
|
||||||
_OID=re.compile(r"^[0-9a-f]{40}$")
|
_OID=re.compile(r"^[0-9a-f]{40}$")
|
||||||
_JOB_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$")
|
_JOB_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$")
|
||||||
_TASK_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
||||||
_TASK_BRANCH=re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
|
||||||
|
|
||||||
|
|
||||||
class ProducerReceiptError(RuntimeError):
|
class ProducerReceiptError(RuntimeError):
|
||||||
@@ -1143,38 +1130,6 @@ def require_target_grant(grant:object)->None:
|
|||||||
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
||||||
|
|
||||||
|
|
||||||
def require_reservable_grant(grant:object)->None:
|
|
||||||
"""Validate the authenticated, persisted grant accepted by the producer.
|
|
||||||
|
|
||||||
The historical target keeps its exact tuple. Every other target is driven
|
|
||||||
by the grant persisted by TaskAdmissionBootstrap; no Mission metadata or
|
|
||||||
caller-supplied execution identity is consulted here.
|
|
||||||
"""
|
|
||||||
task_id=_gv(grant,"task_id")
|
|
||||||
if task_id==TARGET_TASK_ID:
|
|
||||||
require_target_grant(grant)
|
|
||||||
return
|
|
||||||
if not isinstance(task_id,str) or not _TASK_ID.fullmatch(task_id):
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_TASK_INVALID")
|
|
||||||
if _gv(grant,"repository")!=TARGET_REPOSITORY:
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_REPOSITORY_INVALID")
|
|
||||||
base=_gv(grant,"authority_base_oid")
|
|
||||||
if not isinstance(base,str) or not _OID.fullmatch(base):
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_BASE_INVALID")
|
|
||||||
branch=_gv(grant,"target_branch")
|
|
||||||
if not isinstance(branch,str) or not _TASK_BRANCH.fullmatch(branch):
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_BRANCH_INVALID")
|
|
||||||
generation=_gv(grant,"generation")
|
|
||||||
if isinstance(generation,bool) or not isinstance(generation,int) or generation<1:
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_GENERATION_INVALID")
|
|
||||||
digest=_gv(grant,"execution_digest")
|
|
||||||
if not isinstance(digest,str) or not _SHA256.fullmatch(digest):
|
|
||||||
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
|
||||||
grant_id=_gv(grant,"grant_id")
|
|
||||||
if not isinstance(grant_id,str) or not grant_id:
|
|
||||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_GRANT_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_path(value:object)->str:
|
def _safe_path(value:object)->str:
|
||||||
if not isinstance(value,str) or not value or "\x00" in value:
|
if not isinstance(value,str) or not value or "\x00" in value:
|
||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
||||||
@@ -1197,7 +1152,7 @@ def _unique_strings(value:object,code:str)->list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||||
require_reservable_grant(grant)
|
require_target_grant(grant)
|
||||||
plan=strict_json_object(raw)
|
plan=strict_json_object(raw)
|
||||||
if set(plan)!=PLAN_FIELDS_V3:
|
if set(plan)!=PLAN_FIELDS_V3:
|
||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FIELDS_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_FIELDS_INVALID")
|
||||||
@@ -1240,17 +1195,14 @@ def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
|||||||
|
|
||||||
|
|
||||||
def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||||
require_reservable_grant(grant)
|
require_target_grant(grant)
|
||||||
try:
|
try:
|
||||||
rst=root.lstat()
|
rst=root.lstat()
|
||||||
except OSError:
|
except OSError:
|
||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||||
if (
|
if (
|
||||||
stat.S_ISLNK(rst.st_mode) or not stat.S_ISDIR(rst.st_mode)
|
stat.S_ISLNK(rst.st_mode) or not stat.S_ISDIR(rst.st_mode)
|
||||||
or (
|
or rst.st_uid!=0 or rst.st_mode&0o022
|
||||||
_host_posix_metadata_enforced()
|
|
||||||
and (rst.st_uid!=0 or rst.st_mode&0o022)
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DIRECTORY_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_DIRECTORY_INVALID")
|
||||||
path=root/(str(_gv(grant,"execution_digest"))+".json")
|
path=root/(str(_gv(grant,"execution_digest"))+".json")
|
||||||
@@ -1260,10 +1212,7 @@ def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
|||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||||
if (
|
if (
|
||||||
stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode)
|
stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode)
|
||||||
or (
|
or st.st_uid!=0 or st.st_mode&0o022
|
||||||
_host_posix_metadata_enforced()
|
|
||||||
and (st.st_uid!=0 or st.st_mode&0o022)
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_INVALID")
|
||||||
try:
|
try:
|
||||||
@@ -1274,30 +1223,25 @@ def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
|||||||
|
|
||||||
|
|
||||||
def reservation_idempotency_key(grant:object)->str:
|
def reservation_idempotency_key(grant:object)->str:
|
||||||
require_reservable_grant(grant)
|
require_target_grant(grant)
|
||||||
material=(
|
material=(
|
||||||
str(_gv(grant,"grant_id"))+"\0"+str(_gv(grant,"execution_digest"))
|
str(_gv(grant,"grant_id"))+"\0"+str(_gv(grant,"execution_digest"))
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
return "task-admission-execution-reservation-"+hashlib.sha256(material).hexdigest()[:32]
|
return "task-admission-execution-reservation-"+hashlib.sha256(material).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
def build_reservation_request(
|
def build_reservation_request(grant:object)->dict[str,Any]:
|
||||||
grant:object,*,expected_branch_oid:str|None=None,
|
require_target_grant(grant)
|
||||||
)->dict[str,Any]:
|
return {
|
||||||
require_reservable_grant(grant)
|
"task_id":TARGET_TASK_ID,
|
||||||
request={
|
"repository":TARGET_REPOSITORY,
|
||||||
"task_id":str(_gv(grant,"task_id")),
|
"base_oid":TARGET_BASE_OID,
|
||||||
"repository":str(_gv(grant,"repository")),
|
"target_branch":TARGET_BRANCH,
|
||||||
"base_oid":str(_gv(grant,"authority_base_oid")),
|
"generation":TARGET_GENERATION,
|
||||||
"target_branch":str(_gv(grant,"target_branch")),
|
|
||||||
"generation":int(_gv(grant,"generation")),
|
|
||||||
"risk":str(_gv(grant,"risk")),
|
"risk":str(_gv(grant,"risk")),
|
||||||
"description":"Reserve one inert durable GitOps Job identity after exact Owner TaskAdmission authorization; no Agent Task claim or execution.",
|
"description":"Reserve one inert durable GitOps Job identity after exact Owner TaskAdmission authorization; no Agent Task claim or execution.",
|
||||||
"idempotency_key":reservation_idempotency_key(grant),
|
"idempotency_key":reservation_idempotency_key(grant),
|
||||||
}
|
}
|
||||||
if expected_branch_oid is not None:
|
|
||||||
request["expected_branch_oid"]=expected_branch_oid
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
def _mapping(value:object,code:str)->Mapping[str,Any]:
|
def _mapping(value:object,code:str)->Mapping[str,Any]:
|
||||||
@@ -1312,22 +1256,21 @@ def validate_bootstrap_response(
|
|||||||
request:Mapping[str,Any],
|
request:Mapping[str,Any],
|
||||||
response:object,
|
response:object,
|
||||||
expected_side_effect_state:str,
|
expected_side_effect_state:str,
|
||||||
expected_branch_mode:str="CONTINUE_EXISTING",
|
|
||||||
)->str:
|
)->str:
|
||||||
require_reservable_grant(grant)
|
require_target_grant(grant)
|
||||||
result=_mapping(response,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
result=_mapping(response,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
||||||
job_id=result.get("job_id")
|
job_id=result.get("job_id")
|
||||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
||||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID")
|
fail("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID")
|
||||||
expected={
|
expected={
|
||||||
"task_id":_gv(grant,"task_id"),
|
"task_id":TARGET_TASK_ID,
|
||||||
"job_id":job_id,
|
"job_id":job_id,
|
||||||
"repository":_gv(grant,"repository"),
|
"repository":TARGET_REPOSITORY,
|
||||||
"base_oid":_gv(grant,"authority_base_oid"),
|
"base_oid":TARGET_BASE_OID,
|
||||||
"expected_branch_oid":request.get("expected_branch_oid",_gv(grant,"authority_base_oid")),
|
"expected_branch_oid":TARGET_BASE_OID,
|
||||||
"target_branch":_gv(grant,"target_branch"),
|
"target_branch":TARGET_BRANCH,
|
||||||
"branch_mode":expected_branch_mode,
|
"branch_mode":"CONTINUE_EXISTING",
|
||||||
"generation":_gv(grant,"generation"),
|
"generation":TARGET_GENERATION,
|
||||||
"state":"BOUND",
|
"state":"BOUND",
|
||||||
}
|
}
|
||||||
for key,value in expected.items():
|
for key,value in expected.items():
|
||||||
@@ -1390,20 +1333,13 @@ def validate_bootstrap_replay(
|
|||||||
first:object,
|
first:object,
|
||||||
replay:object,
|
replay:object,
|
||||||
replay_side_effect_state:str,
|
replay_side_effect_state:str,
|
||||||
first_side_effect_state:str="QUEUED",
|
|
||||||
expected_branch_mode:str="CONTINUE_EXISTING",
|
|
||||||
)->str:
|
)->str:
|
||||||
if first_side_effect_state not in {"QUEUED","SAME_JOB"}:
|
|
||||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_PHASE_INVALID")
|
|
||||||
first_id=validate_bootstrap_response(
|
first_id=validate_bootstrap_response(
|
||||||
grant=grant,request=request,response=first,
|
grant=grant,request=request,response=first,expected_side_effect_state="QUEUED"
|
||||||
expected_side_effect_state=first_side_effect_state,
|
|
||||||
expected_branch_mode=expected_branch_mode,
|
|
||||||
)
|
)
|
||||||
replay_id=validate_bootstrap_response(
|
replay_id=validate_bootstrap_response(
|
||||||
grant=grant,request=request,response=replay,
|
grant=grant,request=request,response=replay,
|
||||||
expected_side_effect_state=replay_side_effect_state,
|
expected_side_effect_state=replay_side_effect_state
|
||||||
expected_branch_mode=expected_branch_mode,
|
|
||||||
)
|
)
|
||||||
if first_id!=replay_id:
|
if first_id!=replay_id:
|
||||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_JOB_CONFLICT")
|
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_JOB_CONFLICT")
|
||||||
@@ -1419,12 +1355,6 @@ import inspect as _fl_inspect
|
|||||||
_FL_HARDENING_PROVIDER = ExecutionPlanDirectoryProvider
|
_FL_HARDENING_PROVIDER = ExecutionPlanDirectoryProvider
|
||||||
_FL_BUILD_FROM_ACTIVATION = build_task_admission_handlers_from_activation_file
|
_FL_BUILD_FROM_ACTIVATION = build_task_admission_handlers_from_activation_file
|
||||||
_FL_BUILD_FROM_ENVIRONMENT = globals().get("build_task_admission_handlers_from_environment")
|
_FL_BUILD_FROM_ENVIRONMENT = globals().get("build_task_admission_handlers_from_environment")
|
||||||
_FL_BUILD_RUNTIME_FROM_ACTIVATION = globals().get(
|
|
||||||
"build_task_admission_runtime_from_activation_file"
|
|
||||||
)
|
|
||||||
_FL_BUILD_RUNTIME_FROM_ENVIRONMENT = globals().get(
|
|
||||||
"build_task_admission_runtime_from_environment"
|
|
||||||
)
|
|
||||||
_FL_APPROVED_BOOTSTRAP_SHA256="758895a8407568890fd2f8eb2f232a3dbf2cfa369e5f65ae8c6975bf8d01e3b5"
|
_FL_APPROVED_BOOTSTRAP_SHA256="758895a8407568890fd2f8eb2f232a3dbf2cfa369e5f65ae8c6975bf8d01e3b5"
|
||||||
REPLAY_SIDE_EFFECT_STATE="SAME_JOB"
|
REPLAY_SIDE_EFFECT_STATE="SAME_JOB"
|
||||||
|
|
||||||
@@ -1465,428 +1395,38 @@ def _fl_assert_bootstrap_source(bootstrap:object)->None:
|
|||||||
if _fl_sha(path)!=_FL_APPROVED_BOOTSTRAP_SHA256:
|
if _fl_sha(path)!=_FL_APPROVED_BOOTSTRAP_SHA256:
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_DRIFT",503)
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_DRIFT",503)
|
||||||
|
|
||||||
|
|
||||||
def _fl_reservation_refs(runtime:object,grant:object)->tuple[str,str|None,str,str]:
|
|
||||||
require_reservable_grant(grant)
|
|
||||||
authority=getattr(runtime,"authority",None)
|
|
||||||
if authority is None or not callable(getattr(authority,"read_ref",None)):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
|
||||||
base=str(_gv(grant,"authority_base_oid"))
|
|
||||||
branch=str(_gv(grant,"target_branch"))
|
|
||||||
try:
|
|
||||||
main=authority.read_ref("refs/heads/main")
|
|
||||||
target=authority.read_ref("refs/heads/"+branch)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
|
||||||
if main!=base:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
|
||||||
if _gv(grant,"task_id")==TARGET_TASK_ID:
|
|
||||||
if target!=base:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
|
||||||
return main,target,"CONTINUE_EXISTING",base
|
|
||||||
if target is None:
|
|
||||||
if _gv(grant,"generation")!=1:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_GENERATION_CONFLICT",409)
|
|
||||||
return main,target,"CREATE_NEW",base
|
|
||||||
if target==base:
|
|
||||||
return main,target,"CONTINUE_EXISTING",base
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
|
||||||
|
|
||||||
|
|
||||||
def _fl_validate_durable_binding(
|
|
||||||
*,grant:object,request:Mapping[str,Any],durable:object,job_id:str,
|
|
||||||
expected_branch_mode:str,
|
|
||||||
)->None:
|
|
||||||
expected={
|
|
||||||
"task_id":_gv(grant,"task_id"),
|
|
||||||
"job_id":job_id,
|
|
||||||
"repository":_gv(grant,"repository"),
|
|
||||||
"base_oid":_gv(grant,"authority_base_oid"),
|
|
||||||
"expected_branch_oid":request.get("expected_branch_oid"),
|
|
||||||
"target_branch":_gv(grant,"target_branch"),
|
|
||||||
"branch_mode":expected_branch_mode,
|
|
||||||
"generation":_gv(grant,"generation"),
|
|
||||||
"risk":_gv(grant,"risk"),
|
|
||||||
"idempotency_key":request.get("idempotency_key"),
|
|
||||||
"description":request.get("description"),
|
|
||||||
}
|
|
||||||
if any(getattr(durable,field,None)!=value for field,value in expected.items()):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
|
||||||
|
|
||||||
|
|
||||||
def _fl_existing_reservation_readback(
|
|
||||||
*,bootstrap:object,grant:object,request:Mapping[str,Any],durable:object,
|
|
||||||
expected_branch_mode:str,
|
|
||||||
)->dict[str,Any]:
|
|
||||||
"""Verify an existing producer reservation without opening a mutating store.
|
|
||||||
|
|
||||||
``GovernedTaskBootstrap._store`` initializes missing directories and SQLite
|
|
||||||
state, while ``create`` may submit a new Job. Neither is valid after a
|
|
||||||
durable task binding already exists: missing producer state is corruption,
|
|
||||||
not crash recovery. Read the exact Job and idempotency rows through a
|
|
||||||
read-only SQLite URI and return an explicitly typed readback proof. This
|
|
||||||
is deliberately not a producer submit receipt: Job lifecycle state may
|
|
||||||
have advanced since the reservation was first created.
|
|
||||||
"""
|
|
||||||
task_id=str(_gv(grant,"task_id"))
|
|
||||||
job_id=getattr(durable,"job_id",None)
|
|
||||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
|
||||||
key=request.get("idempotency_key")
|
|
||||||
if not isinstance(key,str) or not key:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_IDEMPOTENCY_INVALID",409)
|
|
||||||
try:
|
|
||||||
expected_job_id=_FLGitOpsJobController.internal_job_id_for_idempotency_key(key)
|
|
||||||
producer_key=_fl_job_sha256_json({"client_idempotency_key":key})
|
|
||||||
except Exception as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_IDEMPOTENCY_INVALID",409) from exc
|
|
||||||
if job_id!=expected_job_id or job_id!="job-"+producer_key[:32]:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
|
||||||
|
|
||||||
job_root=getattr(bootstrap,"job_root",None)
|
|
||||||
if not isinstance(job_root,(str,os.PathLike)):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
|
||||||
job_root=Path(job_root)
|
|
||||||
store_root=job_root/_fl_task_storage_key(task_id)
|
|
||||||
legacy_root=job_root/_fl_legacy_task_storage_key(task_id)
|
|
||||||
try:
|
|
||||||
legacy_stat=None
|
|
||||||
try:
|
|
||||||
legacy_stat=legacy_root.lstat()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
if legacy_root!=store_root and legacy_stat is not None:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_CONFLICT",409)
|
|
||||||
store_stat=store_root.lstat()
|
|
||||||
db_path=store_root/"state.sqlite3"
|
|
||||||
db_stat=db_path.lstat()
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_MISSING",409) from exc
|
|
||||||
except OSError as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_READ_FAILED",503) from exc
|
|
||||||
if (
|
|
||||||
stat.S_ISLNK(store_stat.st_mode) or not stat.S_ISDIR(store_stat.st_mode)
|
|
||||||
or stat.S_ISLNK(db_stat.st_mode) or not stat.S_ISREG(db_stat.st_mode)
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_INVALID",409)
|
|
||||||
|
|
||||||
values={
|
|
||||||
field:str(request[field]) for field in (
|
|
||||||
"task_id","repository","base_oid","target_branch","risk",
|
|
||||||
"description","idempotency_key",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
payload=json.dumps(values,sort_keys=True,separators=(",",":")).encode()
|
|
||||||
payload_digest=hashlib.sha256(payload).hexdigest()
|
|
||||||
expected_branch_oid=str(
|
|
||||||
request.get("expected_branch_oid") or values["base_oid"]
|
|
||||||
)
|
|
||||||
producer_binding={
|
|
||||||
"campaign_id":task_id,
|
|
||||||
"repository":values["repository"],
|
|
||||||
"operation":"GOVERNED_TASK_CREATE",
|
|
||||||
"branch":values["target_branch"],
|
|
||||||
"expected_branch_oid":expected_branch_oid,
|
|
||||||
"payload_digest":payload_digest,
|
|
||||||
"idempotency_key":key,
|
|
||||||
"task_id":task_id,
|
|
||||||
"lineage_anchor_oid":values["base_oid"],
|
|
||||||
}
|
|
||||||
expected_request={
|
|
||||||
**producer_binding,
|
|
||||||
"binding":producer_binding,
|
|
||||||
"payload_ref":"sha256/"+payload_digest,
|
|
||||||
"payload_size":len(payload),
|
|
||||||
}
|
|
||||||
expected_job={
|
|
||||||
"job_id":job_id,
|
|
||||||
"idempotency_key":producer_key,
|
|
||||||
"campaign_id":task_id,
|
|
||||||
"repository":values["repository"],
|
|
||||||
"operation":"GOVERNED_TASK_CREATE",
|
|
||||||
"branch":values["target_branch"],
|
|
||||||
"expected_branch_oid":expected_branch_oid,
|
|
||||||
"payload_digest":payload_digest,
|
|
||||||
"payload_ref":"sha256/"+payload_digest,
|
|
||||||
"payload_size":len(payload),
|
|
||||||
}
|
|
||||||
# mode=ro prevents schema/data creation while still including committed WAL
|
|
||||||
# rows. query_only supplies a second SQLite-level write prohibition.
|
|
||||||
try:
|
|
||||||
resolved_store=store_root.resolve(strict=True)
|
|
||||||
resolved_db=db_path.resolve(strict=True)
|
|
||||||
if resolved_db.parent!=resolved_store:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_INVALID",409)
|
|
||||||
connection=sqlite3.connect(
|
|
||||||
resolved_db.as_uri()+"?mode=ro",
|
|
||||||
uri=True,timeout=5.0,
|
|
||||||
)
|
|
||||||
connection.row_factory=sqlite3.Row
|
|
||||||
try:
|
|
||||||
connection.execute("PRAGMA query_only=ON")
|
|
||||||
connection.execute("BEGIN")
|
|
||||||
job=connection.execute(
|
|
||||||
"SELECT job_id,idempotency_key,campaign_id,repository,operation,"
|
|
||||||
"branch,expected_branch_oid,payload_digest,payload_ref,payload_size,"
|
|
||||||
"state,side_effect_state,revision,request_json FROM jobs WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
).fetchone()
|
|
||||||
idempotency=connection.execute(
|
|
||||||
"SELECT key,job_id,binding_json FROM idempotency_keys WHERE key=?",
|
|
||||||
(producer_key,),
|
|
||||||
).fetchone()
|
|
||||||
store_after=store_root.lstat()
|
|
||||||
db_after=db_path.lstat()
|
|
||||||
if (
|
|
||||||
(store_stat.st_dev,store_stat.st_ino)!=(store_after.st_dev,store_after.st_ino)
|
|
||||||
or (db_stat.st_dev,db_stat.st_ino)!=(db_after.st_dev,db_after.st_ino)
|
|
||||||
or stat.S_ISLNK(store_after.st_mode)
|
|
||||||
or not stat.S_ISDIR(store_after.st_mode)
|
|
||||||
or stat.S_ISLNK(db_after.st_mode)
|
|
||||||
or not stat.S_ISREG(db_after.st_mode)
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_CHANGED",409)
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
del connection
|
|
||||||
except (OSError,sqlite3.Error) as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_READ_FAILED",503) from exc
|
|
||||||
if job is None:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_MISSING",409)
|
|
||||||
if idempotency is None:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_IDEMPOTENCY_MISSING",409)
|
|
||||||
if any(job[field]!=value for field,value in expected_job.items()):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409)
|
|
||||||
valid_states={item.value for item in _FLJobState}
|
|
||||||
if (
|
|
||||||
job["state"] not in valid_states
|
|
||||||
or not isinstance(job["side_effect_state"],str)
|
|
||||||
or not job["side_effect_state"]
|
|
||||||
or not isinstance(job["revision"],int)
|
|
||||||
or isinstance(job["revision"],bool)
|
|
||||||
or job["revision"]<0
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STATE_INVALID",409)
|
|
||||||
try:
|
|
||||||
recorded_request=json.loads(job["request_json"])
|
|
||||||
recorded_binding=json.loads(idempotency["binding_json"])
|
|
||||||
except (TypeError,ValueError) as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409) from exc
|
|
||||||
if (
|
|
||||||
recorded_request!=expected_request
|
|
||||||
or idempotency["key"]!=producer_key
|
|
||||||
or idempotency["job_id"]!=job_id
|
|
||||||
or recorded_binding!=producer_binding
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409)
|
|
||||||
payload_path=store_root/"payloads"/payload_digest
|
|
||||||
descriptor=None
|
|
||||||
try:
|
|
||||||
descriptor=os.open(
|
|
||||||
payload_path,
|
|
||||||
os.O_RDONLY|getattr(os,"O_NOFOLLOW",0),
|
|
||||||
)
|
|
||||||
payload_stat=os.fstat(descriptor)
|
|
||||||
with os.fdopen(descriptor,"rb",closefd=False) as stream:
|
|
||||||
payload_bytes=stream.read(len(payload)+1)
|
|
||||||
payload_after=payload_path.lstat()
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_MISSING",409) from exc
|
|
||||||
except OSError as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_READ_FAILED",503) from exc
|
|
||||||
finally:
|
|
||||||
if descriptor is not None:
|
|
||||||
os.close(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(payload_stat.st_mode)
|
|
||||||
or stat.S_ISLNK(payload_after.st_mode)
|
|
||||||
or not stat.S_ISREG(payload_after.st_mode)
|
|
||||||
or (payload_stat.st_dev,payload_stat.st_ino)!=(payload_after.st_dev,payload_after.st_ino)
|
|
||||||
or len(payload_bytes)!=len(payload)
|
|
||||||
or not hmac.compare_digest(hashlib.sha256(payload_bytes).hexdigest(),payload_digest)
|
|
||||||
or payload_bytes!=payload
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_CONFLICT",409)
|
|
||||||
|
|
||||||
response=dict(durable.response(idempotent=True))
|
|
||||||
response["reservation_readback"]={
|
|
||||||
"schema":"aios.governed-task-reservation-readback.v1",
|
|
||||||
"job_id":job_id,
|
|
||||||
"state":job["state"],
|
|
||||||
"side_effect_state":job["side_effect_state"],
|
|
||||||
"revision":job["revision"],
|
|
||||||
"producer_idempotency_key":producer_key,
|
|
||||||
"request_idempotency_key":key,
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _fl_validate_reservation_readback(
|
|
||||||
*,grant:object,request:Mapping[str,Any],durable:object,
|
|
||||||
response:object,expected_branch_mode:str,
|
|
||||||
)->str:
|
|
||||||
if not isinstance(response,Mapping):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409)
|
|
||||||
result=response
|
|
||||||
expected_response=dict(durable.response(idempotent=True))
|
|
||||||
if (
|
|
||||||
set(result)!=set(expected_response)|{"reservation_readback"}
|
|
||||||
or any(result.get(field)!=value for field,value in expected_response.items())
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_CONFLICT",409)
|
|
||||||
proof=result.get("reservation_readback")
|
|
||||||
if not isinstance(proof,Mapping):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409)
|
|
||||||
expected_keys={
|
|
||||||
"schema","job_id","state","side_effect_state","revision",
|
|
||||||
"producer_idempotency_key","request_idempotency_key",
|
|
||||||
}
|
|
||||||
key=request.get("idempotency_key")
|
|
||||||
try:
|
|
||||||
producer_key=_fl_job_sha256_json({"client_idempotency_key":key})
|
|
||||||
except Exception as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409) from exc
|
|
||||||
job_id=getattr(durable,"job_id",None)
|
|
||||||
if (
|
|
||||||
set(proof)!=expected_keys
|
|
||||||
or proof.get("schema")!="aios.governed-task-reservation-readback.v1"
|
|
||||||
or proof.get("job_id")!=job_id
|
|
||||||
or proof.get("producer_idempotency_key")!=producer_key
|
|
||||||
or proof.get("request_idempotency_key")!=key
|
|
||||||
or proof.get("state") not in {item.value for item in _FLJobState}
|
|
||||||
or not isinstance(proof.get("side_effect_state"),str)
|
|
||||||
or not proof.get("side_effect_state")
|
|
||||||
or not isinstance(proof.get("revision"),int)
|
|
||||||
or isinstance(proof.get("revision"),bool)
|
|
||||||
or proof.get("revision")<0
|
|
||||||
or getattr(durable,"branch_mode",None)!=expected_branch_mode
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_CONFLICT",409)
|
|
||||||
return str(job_id)
|
|
||||||
|
|
||||||
class _FLProducerReceiptReserver:
|
class _FLProducerReceiptReserver:
|
||||||
def __init__(self,runtime:object):
|
def __init__(self,runtime:object):
|
||||||
self.runtime=runtime
|
self.runtime=runtime
|
||||||
self.readbacks:dict[str,dict[str,Any]]={}
|
|
||||||
|
|
||||||
def validate_v2_replay(self,grant:object,plan:Mapping[str,Any])->None:
|
|
||||||
"""Permit the old v2 contract only as an exact durable replay."""
|
|
||||||
require_reservable_grant(grant)
|
|
||||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
|
||||||
if bootstrap is None or not callable(getattr(bootstrap,"get_binding",None)):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
|
||||||
_fl_assert_bootstrap_source(bootstrap)
|
|
||||||
task_id=str(_gv(grant,"task_id"))
|
|
||||||
try:
|
|
||||||
durable=bootstrap.get_binding(task_id)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_REQUIRED",409) from exc
|
|
||||||
expected={
|
|
||||||
"task_id":task_id,
|
|
||||||
"job_id":plan.get("external_job_id"),
|
|
||||||
"repository":_gv(grant,"repository"),
|
|
||||||
"base_oid":_gv(grant,"authority_base_oid"),
|
|
||||||
"expected_branch_oid":_gv(grant,"authority_base_oid"),
|
|
||||||
"target_branch":_gv(grant,"target_branch"),
|
|
||||||
"generation":_gv(grant,"generation"),
|
|
||||||
"risk":_gv(grant,"risk"),
|
|
||||||
"idempotency_key":"task-admission:"+task_id,
|
|
||||||
"description":"P0 governed execution projection for officially admitted Agent Task.",
|
|
||||||
}
|
|
||||||
if any(getattr(durable,field,None)!=value for field,value in expected.items()):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_CONFLICT",409)
|
|
||||||
if getattr(durable,"branch_mode",None) not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_CONFLICT",409)
|
|
||||||
if not isinstance(plan.get("external_job_id"),str) or not _JOB_ID.fullmatch(plan["external_job_id"]):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
|
||||||
|
|
||||||
def existing_v3_job(self,grant:object,plan:Mapping[str,Any])->str|None:
|
|
||||||
"""Reuse an exact durable reservation without reinterpreting live refs.
|
|
||||||
|
|
||||||
Once reserved, CREATE_NEW versus CONTINUE_EXISTING is immutable input
|
|
||||||
lineage. A later TaskPrincipal read must not turn legitimate branch
|
|
||||||
creation or forward progress into a fresh admission decision.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
validate_v3_plan(grant=grant,raw=canonical_json_bytes(dict(plan)))
|
|
||||||
except ProducerReceiptError as exc:
|
|
||||||
raise _fl_error(exc.code,403) from exc
|
|
||||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
|
||||||
bindings=getattr(self.runtime,"bindings",None)
|
|
||||||
if (
|
|
||||||
bootstrap is None
|
|
||||||
or not callable(getattr(bootstrap,"get_binding",None))
|
|
||||||
or not isinstance(bindings,dict)
|
|
||||||
):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
|
||||||
_fl_assert_bootstrap_source(bootstrap)
|
|
||||||
task_id=str(_gv(grant,"task_id"))
|
|
||||||
try:
|
|
||||||
durable=bootstrap.get_binding(task_id)
|
|
||||||
except Exception as exc:
|
|
||||||
if str(exc)=="TASK_EXECUTION_PROJECTION_MISSING":
|
|
||||||
return None
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_READ_FAILED",503) from exc
|
|
||||||
request=build_reservation_request(
|
|
||||||
grant,expected_branch_oid=str(_gv(grant,"authority_base_oid")),
|
|
||||||
)
|
|
||||||
branch_mode=getattr(durable,"branch_mode",None)
|
|
||||||
if branch_mode not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
|
||||||
job_id=getattr(durable,"job_id",None)
|
|
||||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
|
||||||
_fl_validate_durable_binding(
|
|
||||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
readback=_fl_existing_reservation_readback(
|
|
||||||
bootstrap=bootstrap,grant=grant,request=request,durable=durable,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
_fl_validate_reservation_readback(
|
|
||||||
grant=grant,request=request,durable=durable,response=readback,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
# Keep readback evidence separate from producer submit receipts. In
|
|
||||||
# particular, a principal re-read must not overwrite the real receipt
|
|
||||||
# retained by a fresh admission in this runtime.
|
|
||||||
self.readbacks[task_id]=dict(readback)
|
|
||||||
return job_id
|
|
||||||
|
|
||||||
def __call__(self,grant:object,plan:Mapping[str,Any])->str:
|
def __call__(self,grant:object,plan:Mapping[str,Any])->str:
|
||||||
try:
|
|
||||||
validate_v3_plan(grant=grant,raw=canonical_json_bytes(dict(plan)))
|
|
||||||
except ProducerReceiptError as exc:
|
|
||||||
raise _fl_error(exc.code,403) from exc
|
|
||||||
authority=getattr(self.runtime,"authority",None)
|
authority=getattr(self.runtime,"authority",None)
|
||||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
bootstrap=getattr(self.runtime,"bootstrap",None)
|
||||||
bindings=getattr(self.runtime,"bindings",None)
|
bindings=getattr(self.runtime,"bindings",None)
|
||||||
if (
|
if (
|
||||||
authority is None or bootstrap is None
|
authority is None or bootstrap is None
|
||||||
or not callable(getattr(bootstrap,"create",None))
|
or not callable(getattr(bootstrap,"create",None))
|
||||||
or not callable(getattr(bootstrap,"get_binding",None))
|
|
||||||
or not isinstance(bindings,dict)
|
or not isinstance(bindings,dict)
|
||||||
):
|
):
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||||
|
|
||||||
_fl_assert_bootstrap_source(bootstrap)
|
_fl_assert_bootstrap_source(bootstrap)
|
||||||
|
|
||||||
main_before,branch_before,branch_mode,expected_branch_oid=_fl_reservation_refs(self.runtime,grant)
|
try:
|
||||||
|
main_before=authority.read_ref("refs/heads/main")
|
||||||
|
branch_before=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||||
|
if main_before!=TARGET_BASE_OID or branch_before!=TARGET_BASE_OID:
|
||||||
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||||
|
|
||||||
request=build_reservation_request(grant,expected_branch_oid=expected_branch_oid)
|
request=build_reservation_request(grant)
|
||||||
request_before=dict(request)
|
request_before=dict(request)
|
||||||
try:
|
try:
|
||||||
first=bootstrap.create(request)
|
first=bootstrap.create(request)
|
||||||
if request!=request_before:
|
if request!=request_before:
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
||||||
first_phase=_mapping(first,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID").get("reservation_receipt",{}).get("side_effect_state")
|
validate_bootstrap_response(grant=grant,request=request,response=first,expected_side_effect_state="QUEUED")
|
||||||
if first_phase not in {"QUEUED","SAME_JOB"}:
|
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_PHASE_INVALID",409)
|
|
||||||
validate_bootstrap_response(
|
|
||||||
grant=grant,request=request,response=first,
|
|
||||||
expected_side_effect_state=first_phase,expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
replay=bootstrap.create(request)
|
replay=bootstrap.create(request)
|
||||||
if request!=request_before:
|
if request!=request_before:
|
||||||
@@ -1894,12 +1434,6 @@ class _FLProducerReceiptReserver:
|
|||||||
job_id=validate_bootstrap_replay(
|
job_id=validate_bootstrap_replay(
|
||||||
grant=grant,request=request,first=first,replay=replay,
|
grant=grant,request=request,first=first,replay=replay,
|
||||||
replay_side_effect_state=REPLAY_SIDE_EFFECT_STATE,
|
replay_side_effect_state=REPLAY_SIDE_EFFECT_STATE,
|
||||||
first_side_effect_state=first_phase,expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
durable=bootstrap.get_binding(str(_gv(grant,"task_id")))
|
|
||||||
_fl_validate_durable_binding(
|
|
||||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
)
|
||||||
except ProducerReceiptError as exc:
|
except ProducerReceiptError as exc:
|
||||||
raise _fl_error(exc.code,409) from exc
|
raise _fl_error(exc.code,409) from exc
|
||||||
@@ -1914,12 +1448,11 @@ class _FLProducerReceiptReserver:
|
|||||||
# Existing runtime's public createGovernedTask projection performs this
|
# Existing runtime's public createGovernedTask projection performs this
|
||||||
# in-memory binding update after bootstrap.create(). Mirror it only
|
# in-memory binding update after bootstrap.create(). Mirror it only
|
||||||
# after the exact producer receipt + replay proof has passed.
|
# after the exact producer receipt + replay proof has passed.
|
||||||
bindings[str(_gv(grant,"task_id"))]=dict(replay)
|
bindings[TARGET_TASK_ID]=dict(replay)
|
||||||
self.readbacks.pop(str(_gv(grant,"task_id")),None)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
main_after=authority.read_ref("refs/heads/main")
|
main_after=authority.read_ref("refs/heads/main")
|
||||||
branch_after=authority.read_ref("refs/heads/"+str(_gv(grant,"target_branch")))
|
branch_after=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||||
if main_after!=main_before or branch_after!=branch_before:
|
if main_after!=main_before or branch_after!=branch_before:
|
||||||
@@ -1934,33 +1467,29 @@ class V3ProducerReceiptExecutionPlanDirectoryProvider(_FL_HARDENING_PROVIDER):
|
|||||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||||
try:
|
try:
|
||||||
plan=load_v3_plan(_fl_provider_root(self),grant)
|
plan=load_v3_plan(_fl_provider_root(self),grant)
|
||||||
job_id=self._reserver.existing_v3_job(grant,plan)
|
job_id=self._reserver(grant,plan)
|
||||||
if job_id is None:
|
|
||||||
job_id=self._reserver(grant,plan)
|
|
||||||
except ProducerReceiptError as exc:
|
except ProducerReceiptError as exc:
|
||||||
raise _fl_error(exc.code,403) from exc
|
raise _fl_error(exc.code,403) from exc
|
||||||
return {
|
return {**plan,"external_job_id":job_id}
|
||||||
**plan,
|
|
||||||
"external_job_id":job_id,
|
|
||||||
"execution_digest":_gv(grant,"execution_digest"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __call__(self,grant:object)->Mapping[str,Any]:
|
def __call__(self,grant:object)->Mapping[str,Any]:
|
||||||
try:
|
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||||
return self._target(grant)
|
|
||||||
except Exception as v3_error:
|
|
||||||
parent=getattr(super(),"__call__",None)
|
parent=getattr(super(),"__call__",None)
|
||||||
if not callable(parent) or self._reserver is None:
|
if callable(parent):
|
||||||
raise v3_error
|
return parent(grant)
|
||||||
try:
|
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||||
legacy=parent(grant)
|
return self._target(grant)
|
||||||
except Exception:
|
|
||||||
raise v3_error
|
|
||||||
self._reserver.validate_v2_replay(grant,legacy)
|
|
||||||
return legacy
|
|
||||||
|
|
||||||
def load(self,grant:object)->Mapping[str,Any]:
|
def load(self,grant:object)->Mapping[str,Any]:
|
||||||
return self(grant)
|
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||||
|
parent=getattr(super(),"load",None)
|
||||||
|
if callable(parent):
|
||||||
|
return parent(grant)
|
||||||
|
parent_call=getattr(super(),"__call__",None)
|
||||||
|
if callable(parent_call):
|
||||||
|
return parent_call(grant)
|
||||||
|
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||||
|
return self._target(grant)
|
||||||
|
|
||||||
def _fl_runtime_from_post_admission(post_admission:object)->object:
|
def _fl_runtime_from_post_admission(post_admission:object)->object:
|
||||||
runtime=getattr(post_admission,"__self__",None)
|
runtime=getattr(post_admission,"__self__",None)
|
||||||
@@ -1980,93 +1509,31 @@ def _fl_call(builder,*args,post_admission=None,**kwargs):
|
|||||||
previous=ExecutionPlanDirectoryProvider
|
previous=ExecutionPlanDirectoryProvider
|
||||||
ExecutionPlanDirectoryProvider=BoundProvider
|
ExecutionPlanDirectoryProvider=BoundProvider
|
||||||
try:
|
try:
|
||||||
def exact_post_admission(grant,result):
|
return builder(*args,post_admission=post_admission,**kwargs)
|
||||||
task_id=str(_gv(grant,"task_id"))
|
|
||||||
durable=reserver.runtime.bootstrap.get_binding(task_id)
|
|
||||||
request=build_reservation_request(
|
|
||||||
grant,expected_branch_oid=str(_gv(grant,"authority_base_oid")),
|
|
||||||
)
|
|
||||||
if getattr(durable,"idempotency_key",None)!=request["idempotency_key"]:
|
|
||||||
return post_admission(grant,result)
|
|
||||||
branch_mode=getattr(durable,"branch_mode",None)
|
|
||||||
if branch_mode not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
|
||||||
binding=reserver.runtime.bindings.get(task_id)
|
|
||||||
if isinstance(binding,Mapping) and "reservation_receipt" in binding:
|
|
||||||
# Fresh admission must retain the actual producer submit replay
|
|
||||||
# receipt produced by GovernedTaskBootstrap.create().
|
|
||||||
job_id=validate_bootstrap_response(
|
|
||||||
grant=grant,request=request,response=binding,
|
|
||||||
expected_side_effect_state="SAME_JOB",expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# A process restart may leave only durable producer state. Its
|
|
||||||
# separately typed readback proves immutable identity without
|
|
||||||
# claiming that a producer submit happened in this request.
|
|
||||||
binding=reserver.readbacks.get(task_id)
|
|
||||||
job_id=_fl_validate_reservation_readback(
|
|
||||||
grant=grant,request=request,durable=durable,response=binding,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
if result.get("task_id")!=task_id or result.get("external_job_id")!=job_id or result.get("state",result.get("status")) not in {"QUEUED","CLAIMED","RUNNING"}:
|
|
||||||
raise _fl_error("TASK_ADMISSION_PRODUCER_TASK_BINDING_CONFLICT",409)
|
|
||||||
_fl_validate_durable_binding(
|
|
||||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
|
||||||
expected_branch_mode=branch_mode,
|
|
||||||
)
|
|
||||||
return dict(binding)
|
|
||||||
return builder(*args,post_admission=exact_post_admission,**kwargs)
|
|
||||||
finally:
|
finally:
|
||||||
ExecutionPlanDirectoryProvider=previous
|
ExecutionPlanDirectoryProvider=previous
|
||||||
|
|
||||||
def build_task_admission_runtime_from_activation_file(
|
|
||||||
activation_file:Path,*,
|
|
||||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
|
||||||
post_admission=None,
|
|
||||||
):
|
|
||||||
if not callable(_FL_BUILD_RUNTIME_FROM_ACTIVATION):
|
|
||||||
raise RuntimeError("TASK_ADMISSION_RUNTIME_ACTIVATION_BUILDER_MISSING")
|
|
||||||
return _fl_call(
|
|
||||||
_FL_BUILD_RUNTIME_FROM_ACTIVATION,activation_file,
|
|
||||||
caller_service=caller_service,post_admission=post_admission,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_task_admission_handlers_from_activation_file(
|
def build_task_admission_handlers_from_activation_file(
|
||||||
activation_file:Path,*,
|
activation_file:Path,*,
|
||||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||||
post_admission=None,
|
post_admission=None,
|
||||||
):
|
):
|
||||||
return build_task_admission_runtime_from_activation_file(
|
|
||||||
activation_file,
|
|
||||||
caller_service=caller_service,post_admission=post_admission,
|
|
||||||
).handlers
|
|
||||||
|
|
||||||
|
|
||||||
def build_task_admission_runtime_from_environment(
|
|
||||||
*,
|
|
||||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
|
||||||
post_admission=None,
|
|
||||||
):
|
|
||||||
if not callable(_FL_BUILD_RUNTIME_FROM_ENVIRONMENT):
|
|
||||||
raise RuntimeError("TASK_ADMISSION_RUNTIME_ENVIRONMENT_BUILDER_MISSING")
|
|
||||||
return _fl_call(
|
return _fl_call(
|
||||||
_FL_BUILD_RUNTIME_FROM_ENVIRONMENT,
|
_FL_BUILD_FROM_ACTIVATION,activation_file,
|
||||||
caller_service=caller_service,post_admission=post_admission,
|
caller_service=caller_service,post_admission=post_admission,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_task_admission_handlers_from_environment(
|
def build_task_admission_handlers_from_environment(
|
||||||
*,
|
*,
|
||||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||||
post_admission=None,
|
post_admission=None,
|
||||||
):
|
):
|
||||||
return build_task_admission_runtime_from_environment(
|
if not callable(_FL_BUILD_FROM_ENVIRONMENT):
|
||||||
|
raise RuntimeError("TASK_ADMISSION_ENVIRONMENT_BUILDER_MISSING")
|
||||||
|
return _fl_call(
|
||||||
|
_FL_BUILD_FROM_ENVIRONMENT,
|
||||||
caller_service=caller_service,post_admission=post_admission,
|
caller_service=caller_service,post_admission=post_admission,
|
||||||
).handlers
|
)
|
||||||
|
|
||||||
# Keep the standalone legacy provider contract. The exported admission builders
|
ExecutionPlanDirectoryProvider=V3ProducerReceiptExecutionPlanDirectoryProvider
|
||||||
# install a runtime-bound v3 subclass only while composing their trusted port;
|
|
||||||
# an unbound provider cannot prove a durable producer receipt.
|
|
||||||
ExecutionPlanDirectoryProvider=_FL_HARDENING_PROVIDER
|
|
||||||
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — END =====
|
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — END =====
|
||||||
|
|||||||
@@ -1,779 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field, replace
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import Enum
|
|
||||||
from hashlib import sha256
|
|
||||||
import re
|
|
||||||
from typing import Callable, Iterable, Mapping, Protocol, Sequence
|
|
||||||
|
|
||||||
|
|
||||||
class LineageError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RelationType(str, Enum):
|
|
||||||
ROOT = "ROOT"
|
|
||||||
CHILD = "CHILD"
|
|
||||||
SUCCESSOR = "SUCCESSOR"
|
|
||||||
RECOVERY = "RECOVERY"
|
|
||||||
|
|
||||||
|
|
||||||
class NextLegalAction(str, Enum):
|
|
||||||
ROOT_COMPLETE = "ROOT_COMPLETE"
|
|
||||||
CREATE_OR_RESUME_NEXT_CHILD = "CREATE_OR_RESUME_NEXT_CHILD"
|
|
||||||
WAIT_DEPENDENCY = "WAIT_DEPENDENCY"
|
|
||||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
|
||||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
|
||||||
|
|
||||||
|
|
||||||
TERMINAL_STATUSES = frozenset(
|
|
||||||
{"COMPLETE", "COMPLETED", "SUCCEEDED", "FAILED", "FAILED_FINAL", "CANCELLED"}
|
|
||||||
)
|
|
||||||
RECOVERY_STATUSES = frozenset(
|
|
||||||
{"RECONCILIATION_REQUIRED", "RECOVERY_REQUIRED", "FAILED_RECOVERABLE"}
|
|
||||||
)
|
|
||||||
BLOCKED_STATUSES = frozenset({"BLOCKED", "WAITING_DEPENDENCY", "WAITING_CAPACITY"})
|
|
||||||
|
|
||||||
|
|
||||||
_ALIAS_RE = re.compile(r"^[A-Z0-9][A-Z0-9_-]{1,31}$")
|
|
||||||
_SLUG_RE = re.compile(r"^[A-Z0-9][A-Z0-9-]{1,63}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_utc(ts: datetime) -> datetime:
|
|
||||||
if ts.tzinfo is None or ts.utcoffset() is None:
|
|
||||||
raise LineageError("created_at/reviewed_at must be timezone-aware")
|
|
||||||
return ts.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def derive_safe_owner_alias(principal_id: str, preferred_alias: str | None = None) -> str:
|
|
||||||
"""Return a non-secret display alias.
|
|
||||||
|
|
||||||
A trusted identity adapter may supply a safe human alias. Otherwise we use a
|
|
||||||
deterministic pseudonym derived from the principal identifier. We never put
|
|
||||||
raw email addresses, tokens, or principal strings into task names.
|
|
||||||
"""
|
|
||||||
if preferred_alias and "@" not in preferred_alias and ":" not in preferred_alias:
|
|
||||||
candidate = re.sub(r"[^A-Za-z0-9_-]+", "-", preferred_alias.strip()).strip("-_").upper()
|
|
||||||
if 2 <= len(candidate) <= 32 and _ALIAS_RE.fullmatch(candidate):
|
|
||||||
return candidate
|
|
||||||
if not principal_id:
|
|
||||||
raise LineageError("principal_id is required")
|
|
||||||
return "U" + sha256(principal_id.encode("utf-8")).hexdigest()[:9].upper()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_goal_slug(value: str) -> str:
|
|
||||||
value = re.sub(r"[^A-Za-z0-9]+", "-", value.strip()).strip("-").upper()
|
|
||||||
value = re.sub(r"-+", "-", value)
|
|
||||||
if len(value) > 64:
|
|
||||||
value = value[:64].rstrip("-")
|
|
||||||
if not _SLUG_RE.fullmatch(value):
|
|
||||||
raise LineageError("goal_slug must normalize to 2-64 [A-Z0-9-] characters")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def short_ref(task_id: str) -> str:
|
|
||||||
if not task_id:
|
|
||||||
raise LineageError("task_id is required")
|
|
||||||
compact = re.sub(r"[^A-Za-z0-9]", "", task_id).upper()
|
|
||||||
if len(compact) >= 8:
|
|
||||||
return compact[-8:]
|
|
||||||
return sha256(task_id.encode("utf-8")).hexdigest()[:8].upper()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskLineage:
|
|
||||||
task_id: str
|
|
||||||
display_name: str
|
|
||||||
owner_principal_id: str
|
|
||||||
owner_alias: str
|
|
||||||
created_at: datetime
|
|
||||||
mission_id: str
|
|
||||||
root_task_id: str
|
|
||||||
parent_task_id: str | None
|
|
||||||
lineage_depth: int
|
|
||||||
relation_type: RelationType
|
|
||||||
sibling_sequence: int
|
|
||||||
goal_slug: str
|
|
||||||
root_acceptance_ref: str
|
|
||||||
parent_revision: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskRecord:
|
|
||||||
task_id: str
|
|
||||||
revision: int
|
|
||||||
status: str
|
|
||||||
lineage: TaskLineage
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
terminal_evidence_proven: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AcceptanceCondition:
|
|
||||||
key: str
|
|
||||||
satisfied: bool
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
owner_boundary: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RootAcceptanceSnapshot:
|
|
||||||
conditions: tuple[AcceptanceCondition, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def remaining(self) -> tuple[str, ...]:
|
|
||||||
return tuple(c.key for c in self.conditions if not c.satisfied)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def complete(self) -> bool:
|
|
||||||
return all(c.satisfied for c in self.conditions)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def owner_boundary_required(self) -> bool:
|
|
||||||
return any((not c.satisfied) and c.owner_boundary for c in self.conditions)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def evidence_refs(self) -> tuple[str, ...]:
|
|
||||||
values: list[str] = []
|
|
||||||
for condition in self.conditions:
|
|
||||||
if condition.satisfied:
|
|
||||||
values.extend(condition.evidence_refs)
|
|
||||||
return tuple(dict.fromkeys(values))
|
|
||||||
|
|
||||||
|
|
||||||
def canonicalize_acceptance(
|
|
||||||
acceptance: RootAcceptanceSnapshot,
|
|
||||||
) -> RootAcceptanceSnapshot:
|
|
||||||
if not isinstance(acceptance, RootAcceptanceSnapshot):
|
|
||||||
raise LineageError("acceptance evaluator returned an invalid snapshot")
|
|
||||||
normalized: list[AcceptanceCondition] = []
|
|
||||||
keys: set[str] = set()
|
|
||||||
for condition in acceptance.conditions:
|
|
||||||
if not isinstance(condition, AcceptanceCondition):
|
|
||||||
raise LineageError("acceptance snapshot contains an invalid condition")
|
|
||||||
if not isinstance(condition.key, str) or not condition.key:
|
|
||||||
raise LineageError("acceptance condition key must be a non-empty string")
|
|
||||||
if condition.key in keys:
|
|
||||||
raise LineageError("acceptance condition keys must be unique")
|
|
||||||
if not isinstance(condition.satisfied, bool) or not isinstance(
|
|
||||||
condition.owner_boundary, bool
|
|
||||||
):
|
|
||||||
raise LineageError("acceptance condition flags must be booleans")
|
|
||||||
if not all(isinstance(ref, str) and ref for ref in condition.evidence_refs):
|
|
||||||
raise LineageError("acceptance evidence refs must be non-empty strings")
|
|
||||||
keys.add(condition.key)
|
|
||||||
normalized.append(
|
|
||||||
replace(
|
|
||||||
condition,
|
|
||||||
evidence_refs=tuple(sorted(set(condition.evidence_refs))),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return RootAcceptanceSnapshot(tuple(sorted(normalized, key=lambda c: c.key)))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RootReviewRecord:
|
|
||||||
review_id: str
|
|
||||||
root_task_id: str
|
|
||||||
reviewed_at: datetime
|
|
||||||
trigger_child_task_id: str
|
|
||||||
root_revision_before: int
|
|
||||||
root_revision_after: int
|
|
||||||
completed_children: tuple[str, ...]
|
|
||||||
active_children: tuple[str, ...]
|
|
||||||
blocked_children: tuple[str, ...]
|
|
||||||
remaining_acceptance_conditions: tuple[str, ...]
|
|
||||||
next_legal_action: NextLegalAction
|
|
||||||
root_complete: bool
|
|
||||||
evidence_refs: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class RootReviewStore(Protocol):
|
|
||||||
def get_task(self, task_id: str) -> TaskRecord: ...
|
|
||||||
def list_descendants(self, root_task_id: str) -> Sequence[TaskRecord]: ...
|
|
||||||
def get_root_review(self, review_id: str) -> RootReviewRecord | None: ...
|
|
||||||
def compare_and_swap_root_review(
|
|
||||||
self,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_root_revision: int,
|
|
||||||
review: RootReviewRecord,
|
|
||||||
) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
AcceptanceEvaluator = Callable[[TaskRecord, Sequence[TaskRecord]], RootAcceptanceSnapshot]
|
|
||||||
|
|
||||||
|
|
||||||
def _timestamp(ts: datetime) -> str:
|
|
||||||
return _require_utc(ts).strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _relation_token(relation: RelationType, sequence: int) -> str:
|
|
||||||
if sequence < 1 or sequence > 99:
|
|
||||||
raise LineageError("non-root sibling_sequence must be 1..99")
|
|
||||||
prefix = {
|
|
||||||
RelationType.CHILD: "C",
|
|
||||||
RelationType.SUCCESSOR: "S",
|
|
||||||
RelationType.RECOVERY: "X",
|
|
||||||
}.get(relation)
|
|
||||||
if not prefix:
|
|
||||||
raise LineageError("ROOT does not use a child relation token")
|
|
||||||
return f"{prefix}{sequence:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_display_name(lineage: TaskLineage) -> str:
|
|
||||||
alias = lineage.owner_alias.upper()
|
|
||||||
if not _ALIAS_RE.fullmatch(alias):
|
|
||||||
raise LineageError("owner_alias must be a non-secret [A-Z0-9_-] alias")
|
|
||||||
goal = normalize_goal_slug(lineage.goal_slug)
|
|
||||||
ts = _timestamp(lineage.created_at)
|
|
||||||
if lineage.relation_type == RelationType.ROOT:
|
|
||||||
return f"TASK-{alias}-{ts}-ROOT-{goal}-{short_ref(lineage.task_id)[:4]}"
|
|
||||||
relation = _relation_token(lineage.relation_type, lineage.sibling_sequence)
|
|
||||||
return (
|
|
||||||
f"TASK-{alias}-{ts}"
|
|
||||||
f"-R{short_ref(lineage.root_task_id)}"
|
|
||||||
f"-P{short_ref(lineage.parent_task_id or '')}"
|
|
||||||
f"-D{lineage.lineage_depth:02d}"
|
|
||||||
f"-{relation}-{goal}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_root_lineage(
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
owner_principal_id: str,
|
|
||||||
owner_alias: str,
|
|
||||||
created_at: datetime,
|
|
||||||
mission_id: str,
|
|
||||||
goal_slug: str,
|
|
||||||
root_acceptance_ref: str,
|
|
||||||
) -> TaskLineage:
|
|
||||||
lineage = TaskLineage(
|
|
||||||
task_id=task_id,
|
|
||||||
display_name="",
|
|
||||||
owner_principal_id=owner_principal_id,
|
|
||||||
owner_alias=owner_alias.upper(),
|
|
||||||
created_at=_require_utc(created_at),
|
|
||||||
mission_id=mission_id,
|
|
||||||
root_task_id=task_id,
|
|
||||||
parent_task_id=None,
|
|
||||||
lineage_depth=0,
|
|
||||||
relation_type=RelationType.ROOT,
|
|
||||||
sibling_sequence=0,
|
|
||||||
goal_slug=normalize_goal_slug(goal_slug),
|
|
||||||
root_acceptance_ref=root_acceptance_ref,
|
|
||||||
parent_revision=None,
|
|
||||||
)
|
|
||||||
lineage = replace(lineage, display_name=build_display_name(lineage))
|
|
||||||
validate_lineage(lineage)
|
|
||||||
return lineage
|
|
||||||
|
|
||||||
|
|
||||||
def make_child_lineage(
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
created_at: datetime,
|
|
||||||
parent: TaskRecord,
|
|
||||||
root: TaskRecord,
|
|
||||||
relation_type: RelationType,
|
|
||||||
sibling_sequence: int,
|
|
||||||
goal_slug: str,
|
|
||||||
) -> TaskLineage:
|
|
||||||
if relation_type == RelationType.ROOT:
|
|
||||||
raise LineageError("child relation_type cannot be ROOT")
|
|
||||||
lineage = TaskLineage(
|
|
||||||
task_id=task_id,
|
|
||||||
display_name="",
|
|
||||||
owner_principal_id=root.lineage.owner_principal_id,
|
|
||||||
owner_alias=root.lineage.owner_alias,
|
|
||||||
created_at=_require_utc(created_at),
|
|
||||||
mission_id=root.lineage.mission_id,
|
|
||||||
root_task_id=root.task_id,
|
|
||||||
parent_task_id=parent.task_id,
|
|
||||||
lineage_depth=parent.lineage.lineage_depth + 1,
|
|
||||||
relation_type=relation_type,
|
|
||||||
sibling_sequence=sibling_sequence,
|
|
||||||
goal_slug=normalize_goal_slug(goal_slug),
|
|
||||||
root_acceptance_ref=root.lineage.root_acceptance_ref,
|
|
||||||
parent_revision=parent.revision,
|
|
||||||
)
|
|
||||||
lineage = replace(lineage, display_name=build_display_name(lineage))
|
|
||||||
validate_lineage(lineage, parent=parent, root=root)
|
|
||||||
return lineage
|
|
||||||
|
|
||||||
|
|
||||||
def validate_lineage(
|
|
||||||
lineage: TaskLineage,
|
|
||||||
*,
|
|
||||||
parent: TaskRecord | None = None,
|
|
||||||
root: TaskRecord | None = None,
|
|
||||||
) -> None:
|
|
||||||
_validate_lineage_shape(lineage)
|
|
||||||
|
|
||||||
if lineage.relation_type == RelationType.ROOT:
|
|
||||||
return
|
|
||||||
|
|
||||||
if parent is None or root is None:
|
|
||||||
raise LineageError("non-root validation requires parent and root authority records")
|
|
||||||
if root.lineage.relation_type != RelationType.ROOT:
|
|
||||||
raise LineageError("root authority record must have ROOT lineage")
|
|
||||||
if parent.lineage.root_task_id != root.task_id:
|
|
||||||
raise LineageError("parent is not attached to the selected root")
|
|
||||||
if parent.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise LineageError("parent owner does not match root owner")
|
|
||||||
if parent.lineage.mission_id != root.lineage.mission_id:
|
|
||||||
raise LineageError("parent mission does not match root mission")
|
|
||||||
if parent.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
|
||||||
raise LineageError("parent acceptance reference does not match root")
|
|
||||||
if lineage.root_task_id != root.task_id:
|
|
||||||
raise LineageError("root_task_id mismatch")
|
|
||||||
if lineage.parent_task_id != parent.task_id:
|
|
||||||
raise LineageError("parent_task_id mismatch")
|
|
||||||
if lineage.lineage_depth != parent.lineage.lineage_depth + 1:
|
|
||||||
raise LineageError("lineage_depth must equal parent depth + 1")
|
|
||||||
if lineage.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise LineageError("cross-owner lineage attachment rejected")
|
|
||||||
if lineage.owner_alias != root.lineage.owner_alias:
|
|
||||||
raise LineageError("owner_alias must match root")
|
|
||||||
if lineage.mission_id != root.lineage.mission_id:
|
|
||||||
raise LineageError("mission_id must match root")
|
|
||||||
if lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
|
||||||
raise LineageError("root_acceptance_ref must match root")
|
|
||||||
if lineage.parent_revision != parent.revision:
|
|
||||||
raise LineageError("stale or mismatched parent revision")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_lineage_shape(lineage: TaskLineage) -> None:
|
|
||||||
if not lineage.task_id or not lineage.owner_principal_id or not lineage.mission_id:
|
|
||||||
raise LineageError("task_id, owner_principal_id and mission_id are required")
|
|
||||||
if not lineage.root_acceptance_ref:
|
|
||||||
raise LineageError("root_acceptance_ref is required")
|
|
||||||
if not _ALIAS_RE.fullmatch(lineage.owner_alias):
|
|
||||||
raise LineageError("invalid owner_alias")
|
|
||||||
if normalize_goal_slug(lineage.goal_slug) != lineage.goal_slug:
|
|
||||||
raise LineageError("goal_slug must already be normalized")
|
|
||||||
if not lineage.display_name or len(lineage.display_name) > 200:
|
|
||||||
raise LineageError("display_name must be non-empty and <= 200 characters")
|
|
||||||
|
|
||||||
if lineage.relation_type == RelationType.ROOT:
|
|
||||||
if lineage.root_task_id != lineage.task_id:
|
|
||||||
raise LineageError("root_task_id must equal task_id for ROOT")
|
|
||||||
if lineage.parent_task_id is not None:
|
|
||||||
raise LineageError("ROOT parent_task_id must be null")
|
|
||||||
if lineage.lineage_depth != 0:
|
|
||||||
raise LineageError("ROOT lineage_depth must be 0")
|
|
||||||
if lineage.sibling_sequence != 0:
|
|
||||||
raise LineageError("ROOT sibling_sequence must be 0")
|
|
||||||
if lineage.parent_revision is not None:
|
|
||||||
raise LineageError("ROOT parent_revision must be null")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not lineage.root_task_id or not lineage.parent_task_id:
|
|
||||||
raise LineageError("non-root root_task_id and parent_task_id are required")
|
|
||||||
if lineage.lineage_depth < 1:
|
|
||||||
raise LineageError("non-root lineage_depth must be >= 1")
|
|
||||||
if lineage.parent_revision is None or lineage.parent_revision < 0:
|
|
||||||
raise LineageError("non-root parent_revision must be >= 0")
|
|
||||||
_relation_token(lineage.relation_type, lineage.sibling_sequence)
|
|
||||||
|
|
||||||
|
|
||||||
def attach_legacy_lineage(
|
|
||||||
*,
|
|
||||||
existing_task_id: str,
|
|
||||||
created_at: datetime,
|
|
||||||
parent: TaskRecord,
|
|
||||||
root: TaskRecord,
|
|
||||||
relation_type: RelationType,
|
|
||||||
sibling_sequence: int,
|
|
||||||
goal_slug: str,
|
|
||||||
) -> TaskLineage:
|
|
||||||
"""Return lineage metadata for an existing durable task without changing its ID."""
|
|
||||||
return make_child_lineage(
|
|
||||||
task_id=existing_task_id,
|
|
||||||
created_at=created_at,
|
|
||||||
parent=parent,
|
|
||||||
root=root,
|
|
||||||
relation_type=relation_type,
|
|
||||||
sibling_sequence=sibling_sequence,
|
|
||||||
goal_slug=goal_slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def rename_display_only(lineage: TaskLineage, new_display_name: str) -> TaskLineage:
|
|
||||||
if not new_display_name or len(new_display_name) > 200:
|
|
||||||
raise LineageError("display name must be non-empty and <= 200 characters")
|
|
||||||
return replace(lineage, display_name=new_display_name)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
LINEAGE_SCHEMA = "aios.task-lineage.v1"
|
|
||||||
ROOT_REVIEW_SCHEMA = "aios.root-review.v1"
|
|
||||||
|
|
||||||
_LINEAGE_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"schema",
|
|
||||||
"task_id",
|
|
||||||
"display_name",
|
|
||||||
"owner_principal_id",
|
|
||||||
"owner_alias",
|
|
||||||
"created_at",
|
|
||||||
"mission_id",
|
|
||||||
"root_task_id",
|
|
||||||
"parent_task_id",
|
|
||||||
"lineage_depth",
|
|
||||||
"relation_type",
|
|
||||||
"sibling_sequence",
|
|
||||||
"goal_slug",
|
|
||||||
"root_acceptance_ref",
|
|
||||||
"parent_revision",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_ROOT_REVIEW_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"schema",
|
|
||||||
"review_id",
|
|
||||||
"root_task_id",
|
|
||||||
"reviewed_at",
|
|
||||||
"trigger_child_task_id",
|
|
||||||
"root_revision_before",
|
|
||||||
"root_revision_after",
|
|
||||||
"completed_children",
|
|
||||||
"active_children",
|
|
||||||
"blocked_children",
|
|
||||||
"remaining_acceptance_conditions",
|
|
||||||
"next_legal_action",
|
|
||||||
"root_complete",
|
|
||||||
"evidence_refs",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_exact_fields(metadata: Mapping[str, object], expected: frozenset[str]) -> None:
|
|
||||||
if not all(isinstance(key, str) for key in metadata):
|
|
||||||
raise LineageError("metadata field names must be strings")
|
|
||||||
actual = frozenset(metadata)
|
|
||||||
if actual != expected:
|
|
||||||
missing = sorted(expected - actual)
|
|
||||||
extra = sorted(actual - expected)
|
|
||||||
raise LineageError(f"metadata fields mismatch: missing={missing} extra={extra}")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_string(metadata: Mapping[str, object], key: str) -> str:
|
|
||||||
value = metadata[key]
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise LineageError(f"{key} must be a non-empty string")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _require_int(metadata: Mapping[str, object], key: str) -> int:
|
|
||||||
value = metadata[key]
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
|
||||||
raise LineageError(f"{key} must be an integer")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _require_string_list(metadata: Mapping[str, object], key: str) -> tuple[str, ...]:
|
|
||||||
value = metadata[key]
|
|
||||||
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
||||||
raise LineageError(f"{key} must be a list of non-empty strings")
|
|
||||||
items = tuple(value)
|
|
||||||
if len(items) != len(set(items)):
|
|
||||||
raise LineageError(f"{key} must not contain duplicates")
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def lineage_to_metadata(lineage: TaskLineage) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema": LINEAGE_SCHEMA,
|
|
||||||
"task_id": lineage.task_id,
|
|
||||||
"display_name": lineage.display_name,
|
|
||||||
"owner_principal_id": lineage.owner_principal_id,
|
|
||||||
"owner_alias": lineage.owner_alias,
|
|
||||||
"created_at": _require_utc(lineage.created_at).isoformat().replace("+00:00", "Z"),
|
|
||||||
"mission_id": lineage.mission_id,
|
|
||||||
"root_task_id": lineage.root_task_id,
|
|
||||||
"parent_task_id": lineage.parent_task_id,
|
|
||||||
"lineage_depth": lineage.lineage_depth,
|
|
||||||
"relation_type": lineage.relation_type.value,
|
|
||||||
"sibling_sequence": lineage.sibling_sequence,
|
|
||||||
"goal_slug": lineage.goal_slug,
|
|
||||||
"root_acceptance_ref": lineage.root_acceptance_ref,
|
|
||||||
"parent_revision": lineage.parent_revision,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def lineage_from_metadata(metadata: Mapping[str, object]) -> TaskLineage:
|
|
||||||
_require_exact_fields(metadata, _LINEAGE_FIELDS)
|
|
||||||
if metadata.get("schema") != LINEAGE_SCHEMA:
|
|
||||||
raise LineageError("unsupported lineage metadata schema")
|
|
||||||
try:
|
|
||||||
created_raw = _require_string(metadata, "created_at")
|
|
||||||
if created_raw.endswith("Z"):
|
|
||||||
created_raw = created_raw[:-1] + "+00:00"
|
|
||||||
parent_task_id = metadata["parent_task_id"]
|
|
||||||
if parent_task_id is not None and (
|
|
||||||
not isinstance(parent_task_id, str) or not parent_task_id
|
|
||||||
):
|
|
||||||
raise LineageError("parent_task_id must be null or a non-empty string")
|
|
||||||
parent_revision = metadata["parent_revision"]
|
|
||||||
if parent_revision is not None and (
|
|
||||||
isinstance(parent_revision, bool) or not isinstance(parent_revision, int)
|
|
||||||
):
|
|
||||||
raise LineageError("parent_revision must be null or an integer")
|
|
||||||
lineage = TaskLineage(
|
|
||||||
task_id=_require_string(metadata, "task_id"),
|
|
||||||
display_name=_require_string(metadata, "display_name"),
|
|
||||||
owner_principal_id=_require_string(metadata, "owner_principal_id"),
|
|
||||||
owner_alias=_require_string(metadata, "owner_alias"),
|
|
||||||
created_at=datetime.fromisoformat(created_raw),
|
|
||||||
mission_id=_require_string(metadata, "mission_id"),
|
|
||||||
root_task_id=_require_string(metadata, "root_task_id"),
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
lineage_depth=_require_int(metadata, "lineage_depth"),
|
|
||||||
relation_type=RelationType(_require_string(metadata, "relation_type")),
|
|
||||||
sibling_sequence=_require_int(metadata, "sibling_sequence"),
|
|
||||||
goal_slug=_require_string(metadata, "goal_slug"),
|
|
||||||
root_acceptance_ref=_require_string(metadata, "root_acceptance_ref"),
|
|
||||||
parent_revision=parent_revision,
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError, LineageError) as exc:
|
|
||||||
raise LineageError(f"invalid lineage metadata: {exc}") from exc
|
|
||||||
_validate_lineage_shape(lineage)
|
|
||||||
return lineage
|
|
||||||
|
|
||||||
|
|
||||||
def root_review_to_record(review: RootReviewRecord) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema": ROOT_REVIEW_SCHEMA,
|
|
||||||
"review_id": review.review_id,
|
|
||||||
"root_task_id": review.root_task_id,
|
|
||||||
"reviewed_at": _require_utc(review.reviewed_at).isoformat().replace("+00:00", "Z"),
|
|
||||||
"trigger_child_task_id": review.trigger_child_task_id,
|
|
||||||
"root_revision_before": review.root_revision_before,
|
|
||||||
"root_revision_after": review.root_revision_after,
|
|
||||||
"completed_children": list(review.completed_children),
|
|
||||||
"active_children": list(review.active_children),
|
|
||||||
"blocked_children": list(review.blocked_children),
|
|
||||||
"remaining_acceptance_conditions": list(review.remaining_acceptance_conditions),
|
|
||||||
"next_legal_action": review.next_legal_action.value,
|
|
||||||
"root_complete": review.root_complete,
|
|
||||||
"evidence_refs": list(review.evidence_refs),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def root_review_from_record(
|
|
||||||
record: Mapping[str, object], *, expected_review_id: str | None = None
|
|
||||||
) -> RootReviewRecord:
|
|
||||||
_require_exact_fields(record, _ROOT_REVIEW_FIELDS)
|
|
||||||
if record.get("schema") != ROOT_REVIEW_SCHEMA:
|
|
||||||
raise LineageError("unsupported root-review metadata schema")
|
|
||||||
try:
|
|
||||||
reviewed_raw = _require_string(record, "reviewed_at")
|
|
||||||
if reviewed_raw.endswith("Z"):
|
|
||||||
reviewed_raw = reviewed_raw[:-1] + "+00:00"
|
|
||||||
root_complete = record["root_complete"]
|
|
||||||
if not isinstance(root_complete, bool):
|
|
||||||
raise LineageError("root_complete must be a boolean")
|
|
||||||
review = RootReviewRecord(
|
|
||||||
review_id=_require_string(record, "review_id"),
|
|
||||||
root_task_id=_require_string(record, "root_task_id"),
|
|
||||||
reviewed_at=_require_utc(datetime.fromisoformat(reviewed_raw)),
|
|
||||||
trigger_child_task_id=_require_string(record, "trigger_child_task_id"),
|
|
||||||
root_revision_before=_require_int(record, "root_revision_before"),
|
|
||||||
root_revision_after=_require_int(record, "root_revision_after"),
|
|
||||||
completed_children=_require_string_list(record, "completed_children"),
|
|
||||||
active_children=_require_string_list(record, "active_children"),
|
|
||||||
blocked_children=_require_string_list(record, "blocked_children"),
|
|
||||||
remaining_acceptance_conditions=_require_string_list(
|
|
||||||
record, "remaining_acceptance_conditions"
|
|
||||||
),
|
|
||||||
next_legal_action=NextLegalAction(
|
|
||||||
_require_string(record, "next_legal_action")
|
|
||||||
),
|
|
||||||
root_complete=root_complete,
|
|
||||||
evidence_refs=_require_string_list(record, "evidence_refs"),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError, LineageError) as exc:
|
|
||||||
raise LineageError(f"invalid root-review metadata: {exc}") from exc
|
|
||||||
if expected_review_id is not None and review.review_id != expected_review_id:
|
|
||||||
raise LineageError("root-review id does not match lookup key")
|
|
||||||
if review.root_revision_before < 0 or (
|
|
||||||
review.root_revision_after != review.root_revision_before + 1
|
|
||||||
):
|
|
||||||
raise LineageError("root-review revision transition is invalid")
|
|
||||||
if review.root_complete != (
|
|
||||||
review.next_legal_action == NextLegalAction.ROOT_COMPLETE
|
|
||||||
):
|
|
||||||
raise LineageError("root_complete does not match next_legal_action")
|
|
||||||
child_sets = (
|
|
||||||
set(review.completed_children),
|
|
||||||
set(review.active_children),
|
|
||||||
set(review.blocked_children),
|
|
||||||
)
|
|
||||||
if any(child_sets[i] & child_sets[j] for i in range(3) for j in range(i + 1, 3)):
|
|
||||||
raise LineageError("root-review child classifications overlap")
|
|
||||||
return review
|
|
||||||
|
|
||||||
def _review_id(
|
|
||||||
root_task_id: str,
|
|
||||||
trigger_child_task_id: str,
|
|
||||||
trigger_revision: int,
|
|
||||||
acceptance: RootAcceptanceSnapshot,
|
|
||||||
) -> str:
|
|
||||||
acceptance_digest = sha256(
|
|
||||||
"\n".join(
|
|
||||||
f"{c.key}:{int(c.satisfied)}:{int(c.owner_boundary)}:"
|
|
||||||
f"{','.join(sorted(set(c.evidence_refs)))}"
|
|
||||||
for c in acceptance.conditions
|
|
||||||
).encode("utf-8")
|
|
||||||
).hexdigest()
|
|
||||||
payload = f"{root_task_id}|{trigger_child_task_id}|{trigger_revision}|{acceptance_digest}"
|
|
||||||
return "RR-" + sha256(payload.encode("utf-8")).hexdigest()[:24]
|
|
||||||
|
|
||||||
|
|
||||||
def _next_action(
|
|
||||||
descendants: Sequence[TaskRecord],
|
|
||||||
acceptance: RootAcceptanceSnapshot,
|
|
||||||
) -> NextLegalAction:
|
|
||||||
if acceptance.complete:
|
|
||||||
return NextLegalAction.ROOT_COMPLETE
|
|
||||||
if acceptance.owner_boundary_required:
|
|
||||||
return NextLegalAction.OWNER_BOUNDARY
|
|
||||||
if any(d.status in RECOVERY_STATUSES for d in descendants):
|
|
||||||
return NextLegalAction.RECOVER_EXISTING_CHILD
|
|
||||||
if any(d.status in BLOCKED_STATUSES for d in descendants):
|
|
||||||
return NextLegalAction.WAIT_DEPENDENCY
|
|
||||||
return NextLegalAction.CREATE_OR_RESUME_NEXT_CHILD
|
|
||||||
|
|
||||||
|
|
||||||
def review_root_after_child_terminal(
|
|
||||||
*,
|
|
||||||
store: RootReviewStore,
|
|
||||||
trigger_child_task_id: str,
|
|
||||||
acceptance_evaluator: AcceptanceEvaluator,
|
|
||||||
reviewed_at: datetime,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
) -> RootReviewRecord:
|
|
||||||
"""Persist a root review after a proven child terminal transition.
|
|
||||||
|
|
||||||
This function is deliberately storage-agnostic. TaskPlane remains the
|
|
||||||
authority and supplies the CAS implementation. The function never creates a
|
|
||||||
new root or mutates a child identity.
|
|
||||||
"""
|
|
||||||
if max_cas_retries < 1:
|
|
||||||
raise LineageError("max_cas_retries must be >= 1")
|
|
||||||
reviewed_at = _require_utc(reviewed_at)
|
|
||||||
|
|
||||||
for _ in range(max_cas_retries):
|
|
||||||
child = store.get_task(trigger_child_task_id)
|
|
||||||
if child.lineage.relation_type == RelationType.ROOT:
|
|
||||||
raise LineageError("root review hook requires a child task")
|
|
||||||
if child.status not in TERMINAL_STATUSES:
|
|
||||||
raise LineageError("child must be terminal before root review")
|
|
||||||
if not child.terminal_evidence_proven:
|
|
||||||
raise LineageError("child terminal evidence is not proven")
|
|
||||||
|
|
||||||
root = store.get_task(child.lineage.root_task_id)
|
|
||||||
if root.lineage.relation_type != RelationType.ROOT:
|
|
||||||
raise LineageError("root_task_id does not resolve to a ROOT task")
|
|
||||||
if child.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise LineageError("trigger child owner does not match root owner")
|
|
||||||
if child.lineage.mission_id != root.lineage.mission_id:
|
|
||||||
raise LineageError("trigger child mission does not match root mission")
|
|
||||||
if child.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
|
||||||
raise LineageError("trigger child acceptance reference does not match root")
|
|
||||||
|
|
||||||
descendants = tuple(
|
|
||||||
sorted(store.list_descendants(root.task_id), key=lambda item: item.task_id)
|
|
||||||
)
|
|
||||||
descendant_ids = tuple(item.task_id for item in descendants)
|
|
||||||
if len(descendant_ids) != len(set(descendant_ids)):
|
|
||||||
raise LineageError("descendant authority returned duplicate task ids")
|
|
||||||
if child.task_id not in descendant_ids:
|
|
||||||
raise LineageError("trigger child missing from authoritative descendants")
|
|
||||||
for descendant in descendants:
|
|
||||||
if descendant.lineage.root_task_id != root.task_id:
|
|
||||||
raise LineageError("descendant is attached to a different root")
|
|
||||||
if descendant.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise LineageError("descendant owner does not match root owner")
|
|
||||||
if descendant.lineage.mission_id != root.lineage.mission_id:
|
|
||||||
raise LineageError("descendant mission does not match root mission")
|
|
||||||
if (
|
|
||||||
descendant.lineage.root_acceptance_ref
|
|
||||||
!= root.lineage.root_acceptance_ref
|
|
||||||
):
|
|
||||||
raise LineageError("descendant acceptance reference does not match root")
|
|
||||||
|
|
||||||
acceptance = canonicalize_acceptance(acceptance_evaluator(root, descendants))
|
|
||||||
review_id = _review_id(root.task_id, child.task_id, child.revision, acceptance)
|
|
||||||
|
|
||||||
completed = tuple(
|
|
||||||
d.task_id for d in descendants
|
|
||||||
if d.status in TERMINAL_STATUSES and d.terminal_evidence_proven
|
|
||||||
)
|
|
||||||
active = tuple(
|
|
||||||
d.task_id for d in descendants
|
|
||||||
if d.status not in TERMINAL_STATUSES and d.status not in BLOCKED_STATUSES
|
|
||||||
)
|
|
||||||
blocked = tuple(
|
|
||||||
d.task_id for d in descendants
|
|
||||||
if d.status in BLOCKED_STATUSES
|
|
||||||
)
|
|
||||||
evidence = tuple(sorted(set(child.evidence_refs + acceptance.evidence_refs)))
|
|
||||||
action = _next_action(descendants, acceptance)
|
|
||||||
|
|
||||||
existing = store.get_root_review(review_id)
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing.root_task_id != root.task_id
|
|
||||||
or existing.trigger_child_task_id != child.task_id
|
|
||||||
):
|
|
||||||
raise LineageError("persisted root review identity conflicts with trigger")
|
|
||||||
if existing.root_revision_after > root.revision:
|
|
||||||
raise LineageError("persisted root review revision is ahead of authority")
|
|
||||||
expected_semantics = (
|
|
||||||
completed,
|
|
||||||
active,
|
|
||||||
blocked,
|
|
||||||
acceptance.remaining,
|
|
||||||
action,
|
|
||||||
action == NextLegalAction.ROOT_COMPLETE,
|
|
||||||
evidence,
|
|
||||||
)
|
|
||||||
actual_semantics = (
|
|
||||||
existing.completed_children,
|
|
||||||
existing.active_children,
|
|
||||||
existing.blocked_children,
|
|
||||||
existing.remaining_acceptance_conditions,
|
|
||||||
existing.next_legal_action,
|
|
||||||
existing.root_complete,
|
|
||||||
existing.evidence_refs,
|
|
||||||
)
|
|
||||||
if actual_semantics != expected_semantics:
|
|
||||||
raise LineageError("persisted root review conflicts with current semantics")
|
|
||||||
return existing
|
|
||||||
|
|
||||||
review = RootReviewRecord(
|
|
||||||
review_id=review_id,
|
|
||||||
root_task_id=root.task_id,
|
|
||||||
reviewed_at=reviewed_at,
|
|
||||||
trigger_child_task_id=child.task_id,
|
|
||||||
root_revision_before=root.revision,
|
|
||||||
root_revision_after=root.revision + 1,
|
|
||||||
completed_children=completed,
|
|
||||||
active_children=active,
|
|
||||||
blocked_children=blocked,
|
|
||||||
remaining_acceptance_conditions=acceptance.remaining,
|
|
||||||
next_legal_action=action,
|
|
||||||
root_complete=(action == NextLegalAction.ROOT_COMPLETE),
|
|
||||||
evidence_refs=evidence,
|
|
||||||
)
|
|
||||||
if store.compare_and_swap_root_review(root.task_id, root.revision, review):
|
|
||||||
return review
|
|
||||||
|
|
||||||
# Another sibling may have updated root progress. Re-read authority and
|
|
||||||
# recompute; never overwrite a newer root review.
|
|
||||||
|
|
||||||
raise LineageError("root review CAS retry budget exhausted")
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
if not __package__:
|
|
||||||
from aios_task_lineage import AcceptanceEvaluator, RelationType, TaskLineage, lineage_to_metadata, lineage_from_metadata, root_review_to_record
|
|
||||||
from aios_task_lineage_integration import (
|
|
||||||
DISPLAY_NAME_METADATA_KEY,
|
|
||||||
IntegrationError,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
NewTaskIdentityRequest,
|
|
||||||
PreparedTaskIdentity,
|
|
||||||
TaskIdentityLineageService,
|
|
||||||
TerminalReviewOutcome,
|
|
||||||
)
|
|
||||||
from aios_task_lineage_taskplane_adapter import (
|
|
||||||
AuthenticatedTaskContext,
|
|
||||||
InProcessTaskPlaneLineageAdapter,
|
|
||||||
require_context_matches_task,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
from .aios_task_lineage import AcceptanceEvaluator, RelationType, TaskLineage, lineage_to_metadata, lineage_from_metadata, root_review_to_record
|
|
||||||
from .aios_task_lineage_integration import (
|
|
||||||
DISPLAY_NAME_METADATA_KEY,
|
|
||||||
IntegrationError,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
NewTaskIdentityRequest,
|
|
||||||
PreparedTaskIdentity,
|
|
||||||
TaskIdentityLineageService,
|
|
||||||
TerminalReviewOutcome,
|
|
||||||
)
|
|
||||||
from .aios_task_lineage_taskplane_adapter import (
|
|
||||||
AuthenticatedTaskContext,
|
|
||||||
InProcessTaskPlaneLineageAdapter,
|
|
||||||
require_context_matches_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleHookError(IntegrationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IdentityPersistenceOutcome:
|
|
||||||
prepared: PreparedTaskIdentity
|
|
||||||
persisted_revision_at_least: int
|
|
||||||
independently_verified: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LegacyAttachOutcome:
|
|
||||||
lineage: TaskLineage
|
|
||||||
independently_verified: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TerminalHookOutcome:
|
|
||||||
outcome: TerminalReviewOutcome
|
|
||||||
root_review_persisted: bool
|
|
||||||
dispatch_persisted: bool
|
|
||||||
independently_verified: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TaskLineageLifecycleHooks:
|
|
||||||
"""Concrete lifecycle hook coordinator.
|
|
||||||
|
|
||||||
It does not own TaskPlane state. It orchestrates existing TaskPlane CAS,
|
|
||||||
journal/continuation and optional independent authority readback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, adapter: InProcessTaskPlaneLineageAdapter):
|
|
||||||
self._adapter = adapter
|
|
||||||
self._service = TaskIdentityLineageService(adapter)
|
|
||||||
|
|
||||||
def persist_new_task_identity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
request: NewTaskIdentityRequest,
|
|
||||||
context: AuthenticatedTaskContext,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
require_independent_readback: bool = True,
|
|
||||||
) -> IdentityPersistenceOutcome:
|
|
||||||
if max_cas_retries < 1:
|
|
||||||
raise LifecycleHookError("max_cas_retries must be >= 1")
|
|
||||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
|
||||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
|
||||||
if request.owner_principal_id != context.principal_id:
|
|
||||||
raise LifecycleHookError("request principal does not match authenticated context")
|
|
||||||
if request.mission_id != context.mission_id:
|
|
||||||
raise LifecycleHookError("request mission does not match authenticated context")
|
|
||||||
|
|
||||||
for _ in range(max_cas_retries):
|
|
||||||
current = self._adapter.get_task_snapshot(request.task_id)
|
|
||||||
require_context_matches_task(context, current)
|
|
||||||
if current.created_at != request.created_at:
|
|
||||||
raise LifecycleHookError("request created_at does not match authority")
|
|
||||||
if (request.relation_type == RelationType.ROOT
|
|
||||||
and request.preferred_owner_alias not in (None, context.preferred_alias)):
|
|
||||||
raise LifecycleHookError("request alias does not match authenticated context")
|
|
||||||
request = replace(request, preferred_owner_alias=context.preferred_alias)
|
|
||||||
prepared = self._service.prepare_new_task_identity(request)
|
|
||||||
|
|
||||||
# Idempotent replay and legacy display-name repair are fail-closed:
|
|
||||||
# never overwrite an existing conflicting lineage record.
|
|
||||||
raw = current.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
display = current.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
|
||||||
if raw is not None and not isinstance(raw, Mapping):
|
|
||||||
raise LifecycleHookError("existing lineage metadata is malformed")
|
|
||||||
if isinstance(raw, Mapping):
|
|
||||||
# parent_revision records the creation observation, not the
|
|
||||||
# parent's current progress revision on an idempotent replay.
|
|
||||||
expected = dict(prepared.metadata_patch[LINEAGE_METADATA_KEY])
|
|
||||||
if request.parent_task_id is not None:
|
|
||||||
stored = lineage_from_metadata(dict(raw))
|
|
||||||
if (stored.parent_revision is None
|
|
||||||
or stored.parent_revision > prepared.lineage.parent_revision):
|
|
||||||
raise LifecycleHookError("existing parent revision conflicts with authority")
|
|
||||||
expected["parent_revision"] = stored.parent_revision
|
|
||||||
if raw != expected:
|
|
||||||
raise LifecycleHookError(
|
|
||||||
"existing lineage metadata conflicts with requested identity"
|
|
||||||
)
|
|
||||||
prepared = replace(prepared, lineage=lineage_from_metadata(dict(raw)),
|
|
||||||
metadata_patch={LINEAGE_METADATA_KEY: dict(raw),
|
|
||||||
DISPLAY_NAME_METADATA_KEY: prepared.display_name})
|
|
||||||
if display == prepared.display_name:
|
|
||||||
verified = False
|
|
||||||
if require_independent_readback:
|
|
||||||
self._adapter.verify_task_readback(
|
|
||||||
task_id=request.task_id,
|
|
||||||
expected_revision_at_least=current.revision,
|
|
||||||
expected_metadata=prepared.metadata_patch,
|
|
||||||
)
|
|
||||||
verified = True
|
|
||||||
return IdentityPersistenceOutcome(
|
|
||||||
prepared=prepared,
|
|
||||||
persisted_revision_at_least=current.revision,
|
|
||||||
independently_verified=verified,
|
|
||||||
)
|
|
||||||
if display not in (None, ""):
|
|
||||||
raise LifecycleHookError(
|
|
||||||
"existing display name conflicts with requested identity"
|
|
||||||
)
|
|
||||||
metadata_patch = {
|
|
||||||
DISPLAY_NAME_METADATA_KEY: prepared.display_name,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
if display not in (None, ""):
|
|
||||||
raise LifecycleHookError(
|
|
||||||
"display name exists without authoritative lineage"
|
|
||||||
)
|
|
||||||
metadata_patch = prepared.metadata_patch
|
|
||||||
|
|
||||||
ok = self._adapter.compare_and_swap_task_metadata(
|
|
||||||
task_id=request.task_id,
|
|
||||||
expected_revision=current.revision,
|
|
||||||
metadata_patch=metadata_patch,
|
|
||||||
)
|
|
||||||
if not ok:
|
|
||||||
continue
|
|
||||||
|
|
||||||
verified = False
|
|
||||||
if require_independent_readback:
|
|
||||||
self._adapter.verify_task_readback(
|
|
||||||
task_id=request.task_id,
|
|
||||||
expected_revision_at_least=current.revision + 1,
|
|
||||||
expected_metadata=prepared.metadata_patch,
|
|
||||||
)
|
|
||||||
verified = True
|
|
||||||
return IdentityPersistenceOutcome(
|
|
||||||
prepared=prepared,
|
|
||||||
persisted_revision_at_least=current.revision + 1,
|
|
||||||
independently_verified=verified,
|
|
||||||
)
|
|
||||||
|
|
||||||
raise LifecycleHookError("identity persistence CAS retry budget exhausted")
|
|
||||||
|
|
||||||
def attach_legacy_identity(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
parent_task_id: str,
|
|
||||||
relation_type: RelationType,
|
|
||||||
goal_slug: str,
|
|
||||||
context: AuthenticatedTaskContext,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
require_independent_readback: bool = True,
|
|
||||||
) -> LegacyAttachOutcome:
|
|
||||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
|
||||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
|
||||||
before = self._adapter.get_task_snapshot(task_id)
|
|
||||||
require_context_matches_task(context, before)
|
|
||||||
lineage = self._service.attach_legacy_task(
|
|
||||||
task_id=task_id,
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
relation_type=relation_type,
|
|
||||||
goal_slug=goal_slug,
|
|
||||||
max_cas_retries=max_cas_retries,
|
|
||||||
expected_owner_principal_id=context.principal_id,
|
|
||||||
expected_mission_id=context.mission_id,
|
|
||||||
)
|
|
||||||
verified = False
|
|
||||||
if require_independent_readback:
|
|
||||||
self._adapter.verify_task_readback(
|
|
||||||
task_id=task_id,
|
|
||||||
expected_revision_at_least=before.revision,
|
|
||||||
expected_metadata={
|
|
||||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
|
||||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Domain serialization compatibility check below avoids requiring
|
|
||||||
# TaskLineage to expose a method in older versions.
|
|
||||||
verified = True
|
|
||||||
return LegacyAttachOutcome(lineage=lineage, independently_verified=verified)
|
|
||||||
|
|
||||||
def on_proven_terminal_child(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
child_task_id: str,
|
|
||||||
context: AuthenticatedTaskContext,
|
|
||||||
acceptance_evaluator: AcceptanceEvaluator,
|
|
||||||
reviewed_at: datetime,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
require_independent_readback: bool = True,
|
|
||||||
) -> TerminalHookOutcome:
|
|
||||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
|
||||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
|
||||||
child = self._adapter.get_task_snapshot(child_task_id)
|
|
||||||
require_context_matches_task(context, child)
|
|
||||||
if not child.terminal_evidence_proven:
|
|
||||||
raise LifecycleHookError(
|
|
||||||
"terminal child hook requires proven terminal evidence"
|
|
||||||
)
|
|
||||||
raw = child.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
raise LifecycleHookError("terminal child lineage missing")
|
|
||||||
lineage = lineage_from_metadata(dict(raw))
|
|
||||||
root = self._adapter.get_task_snapshot(lineage.root_task_id)
|
|
||||||
require_context_matches_task(context, root)
|
|
||||||
|
|
||||||
def authorized_acceptance(root_record, descendants):
|
|
||||||
if (root_record.lineage.owner_principal_id != context.principal_id
|
|
||||||
or root_record.lineage.mission_id != context.mission_id):
|
|
||||||
raise LifecycleHookError("root authority changed during terminal review")
|
|
||||||
return acceptance_evaluator(root_record, descendants)
|
|
||||||
|
|
||||||
outcome = self._service.on_proven_terminal_child(
|
|
||||||
child_task_id=child_task_id,
|
|
||||||
acceptance_evaluator=authorized_acceptance,
|
|
||||||
reviewed_at=reviewed_at,
|
|
||||||
max_cas_retries=max_cas_retries,
|
|
||||||
)
|
|
||||||
|
|
||||||
persisted_review = self._adapter.get_root_review_record(
|
|
||||||
outcome.review.review_id
|
|
||||||
)
|
|
||||||
if persisted_review is None:
|
|
||||||
raise LifecycleHookError("root review was not persisted")
|
|
||||||
if persisted_review != root_review_to_record(outcome.review):
|
|
||||||
raise LifecycleHookError("persisted review does not match returned review")
|
|
||||||
|
|
||||||
dispatch = self._adapter.get_root_action_dispatch(outcome.review.review_id)
|
|
||||||
if dispatch is None:
|
|
||||||
raise LifecycleHookError("root action dispatch was not persisted")
|
|
||||||
if dispatch != outcome.dispatch:
|
|
||||||
raise LifecycleHookError("persisted dispatch does not match returned receipt")
|
|
||||||
|
|
||||||
verified = False
|
|
||||||
if require_independent_readback:
|
|
||||||
self._adapter.verify_root_readback(
|
|
||||||
root_task_id=outcome.review.root_task_id,
|
|
||||||
expected_review_id=outcome.review.review_id,
|
|
||||||
)
|
|
||||||
verified = True
|
|
||||||
|
|
||||||
return TerminalHookOutcome(
|
|
||||||
outcome=outcome,
|
|
||||||
root_review_persisted=True,
|
|
||||||
dispatch_persisted=True,
|
|
||||||
independently_verified=verified,
|
|
||||||
)
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Mapping, MutableMapping, Protocol, Sequence
|
|
||||||
|
|
||||||
if not __package__:
|
|
||||||
from aios_task_lineage import (
|
|
||||||
AcceptanceEvaluator,
|
|
||||||
LineageError,
|
|
||||||
NextLegalAction,
|
|
||||||
RelationType,
|
|
||||||
RootReviewRecord,
|
|
||||||
RootReviewStore,
|
|
||||||
TaskLineage,
|
|
||||||
TaskRecord,
|
|
||||||
attach_legacy_lineage,
|
|
||||||
derive_safe_owner_alias,
|
|
||||||
lineage_from_metadata,
|
|
||||||
lineage_to_metadata,
|
|
||||||
make_child_lineage,
|
|
||||||
make_root_lineage,
|
|
||||||
normalize_goal_slug,
|
|
||||||
review_root_after_child_terminal,
|
|
||||||
root_review_from_record,
|
|
||||||
root_review_to_record,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
from .aios_task_lineage import (
|
|
||||||
AcceptanceEvaluator,
|
|
||||||
LineageError,
|
|
||||||
NextLegalAction,
|
|
||||||
RelationType,
|
|
||||||
RootReviewRecord,
|
|
||||||
RootReviewStore,
|
|
||||||
TaskLineage,
|
|
||||||
TaskRecord,
|
|
||||||
attach_legacy_lineage,
|
|
||||||
derive_safe_owner_alias,
|
|
||||||
lineage_from_metadata,
|
|
||||||
lineage_to_metadata,
|
|
||||||
make_child_lineage,
|
|
||||||
make_root_lineage,
|
|
||||||
normalize_goal_slug,
|
|
||||||
review_root_after_child_terminal,
|
|
||||||
root_review_from_record,
|
|
||||||
root_review_to_record,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
LINEAGE_METADATA_KEY = "aios_task_lineage"
|
|
||||||
LINEAGE_REQUEST_DIGEST_KEY = "aios_lineage_request_digest"
|
|
||||||
ROOT_REVIEW_RECORD_KEY = "aios_root_review"
|
|
||||||
DISPLAY_NAME_METADATA_KEY = "aios_task_display_name"
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskAuthoritySnapshot:
|
|
||||||
task_id: str
|
|
||||||
revision: int
|
|
||||||
status: str
|
|
||||||
owner_principal_id: str
|
|
||||||
mission_id: str
|
|
||||||
created_at: datetime
|
|
||||||
metadata: Mapping[str, object]
|
|
||||||
evidence_refs: tuple[str, ...] = ()
|
|
||||||
terminal_evidence_proven: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class NewTaskIdentityRequest:
|
|
||||||
task_id: str
|
|
||||||
owner_principal_id: str
|
|
||||||
preferred_owner_alias: str | None
|
|
||||||
created_at: datetime
|
|
||||||
mission_id: str
|
|
||||||
goal_slug: str
|
|
||||||
root_acceptance_ref: str
|
|
||||||
relation_type: RelationType = RelationType.ROOT
|
|
||||||
parent_task_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PreparedTaskIdentity:
|
|
||||||
task_id: str
|
|
||||||
display_name: str
|
|
||||||
lineage: TaskLineage
|
|
||||||
metadata_patch: Mapping[str, object]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TerminalReviewOutcome:
|
|
||||||
review: RootReviewRecord
|
|
||||||
dispatch: "RootActionDispatchReceipt"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RootActionDispatchReceipt:
|
|
||||||
review_id: str
|
|
||||||
root_task_id: str
|
|
||||||
action: NextLegalAction
|
|
||||||
idempotency_key: str
|
|
||||||
status: str
|
|
||||||
mission_id: str = ""
|
|
||||||
root_revision: int = 0
|
|
||||||
plan_digest: str | None = None
|
|
||||||
execution_key: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
PERSISTED_DISPATCH_STATUSES = frozenset(
|
|
||||||
{
|
|
||||||
"PENDING",
|
|
||||||
"CLAIMED",
|
|
||||||
"EFFECT_UNKNOWN",
|
|
||||||
"EXECUTED_PENDING_REREVIEW",
|
|
||||||
"WAITING_DEPENDENCY",
|
|
||||||
"WAITING_OWNER",
|
|
||||||
"CLOSED",
|
|
||||||
"SUPERSEDED",
|
|
||||||
"BLOCKED",
|
|
||||||
# Read compatibility only. A consumer must reconcile this legacy
|
|
||||||
# state unless a complete durable receipt is present.
|
|
||||||
"DISPATCHED",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneLineagePort(Protocol):
|
|
||||||
"""Narrow authority port required by the lineage integration service.
|
|
||||||
|
|
||||||
The live adapter must implement these methods over the existing TaskPlane /
|
|
||||||
mission / journal / CAS mechanisms. This protocol deliberately avoids
|
|
||||||
guessing concrete DB tables, HTTP endpoints, or handler names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot: ...
|
|
||||||
|
|
||||||
def list_descendant_snapshots(
|
|
||||||
self, root_task_id: str
|
|
||||||
) -> Sequence[TaskAuthoritySnapshot]: ...
|
|
||||||
|
|
||||||
def reserve_sibling_sequence(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
parent_task_id: str,
|
|
||||||
relation_type: RelationType,
|
|
||||||
child_task_id: str,
|
|
||||||
) -> int:
|
|
||||||
"""Idempotently reserve one sibling sequence for child_task_id."""
|
|
||||||
|
|
||||||
def compare_and_swap_task_metadata(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
expected_revision: int,
|
|
||||||
metadata_patch: Mapping[str, object],
|
|
||||||
) -> bool: ...
|
|
||||||
|
|
||||||
def get_root_review_record(
|
|
||||||
self, review_id: str
|
|
||||||
) -> Mapping[str, object] | None: ...
|
|
||||||
|
|
||||||
def compare_and_swap_root_review_record(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_root_revision: int,
|
|
||||||
review_record: Mapping[str, object],
|
|
||||||
) -> bool: ...
|
|
||||||
|
|
||||||
def get_root_action_dispatch(
|
|
||||||
self, review_id: str
|
|
||||||
) -> RootActionDispatchReceipt | None: ...
|
|
||||||
|
|
||||||
def dispatch_root_action(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
review: RootReviewRecord,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> RootActionDispatchReceipt: ...
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_lineage(snapshot: TaskAuthoritySnapshot) -> TaskLineage:
|
|
||||||
raw = snapshot.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise IntegrationError(
|
|
||||||
f"task {snapshot.task_id} does not have authoritative lineage metadata"
|
|
||||||
)
|
|
||||||
lineage = lineage_from_metadata(raw)
|
|
||||||
if lineage.task_id != snapshot.task_id:
|
|
||||||
raise IntegrationError("lineage task_id does not match authority task")
|
|
||||||
if lineage.owner_principal_id != snapshot.owner_principal_id:
|
|
||||||
raise IntegrationError("lineage owner does not match authority owner")
|
|
||||||
if lineage.mission_id != snapshot.mission_id:
|
|
||||||
raise IntegrationError("lineage mission does not match authority mission")
|
|
||||||
if snapshot.created_at.tzinfo is None or snapshot.created_at.utcoffset() is None:
|
|
||||||
raise IntegrationError("authority created_at must be timezone-aware")
|
|
||||||
if lineage.created_at != snapshot.created_at:
|
|
||||||
raise IntegrationError("lineage created_at does not match authority task")
|
|
||||||
display = snapshot.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
|
||||||
if display not in (None, "", lineage.display_name):
|
|
||||||
raise IntegrationError("lineage display name conflicts with authority metadata")
|
|
||||||
return lineage
|
|
||||||
|
|
||||||
|
|
||||||
def _as_task_record(snapshot: TaskAuthoritySnapshot) -> TaskRecord:
|
|
||||||
return TaskRecord(
|
|
||||||
task_id=snapshot.task_id,
|
|
||||||
revision=snapshot.revision,
|
|
||||||
status=snapshot.status,
|
|
||||||
lineage=_extract_lineage(snapshot),
|
|
||||||
evidence_refs=snapshot.evidence_refs,
|
|
||||||
terminal_evidence_proven=snapshot.terminal_evidence_proven,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_record_root_binding(record: TaskRecord, root: TaskRecord) -> None:
|
|
||||||
if root.lineage.relation_type != RelationType.ROOT:
|
|
||||||
raise IntegrationError("root_task_id does not resolve to a ROOT task")
|
|
||||||
if record.lineage.root_task_id != root.task_id:
|
|
||||||
raise IntegrationError("task lineage points to a different root")
|
|
||||||
if record.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise IntegrationError("task lineage owner does not match root owner")
|
|
||||||
if record.lineage.mission_id != root.lineage.mission_id:
|
|
||||||
raise IntegrationError("task lineage mission does not match root mission")
|
|
||||||
if record.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
|
||||||
raise IntegrationError("task lineage acceptance reference does not match root")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_authority_record(
|
|
||||||
port: TaskPlaneLineagePort, task_id: str
|
|
||||||
) -> tuple[TaskRecord, TaskRecord]:
|
|
||||||
record = _as_task_record(port.get_task_snapshot(task_id))
|
|
||||||
if record.lineage.relation_type == RelationType.ROOT:
|
|
||||||
return record, record
|
|
||||||
root = _as_task_record(port.get_task_snapshot(record.lineage.root_task_id))
|
|
||||||
current = record
|
|
||||||
seen = {record.task_id}
|
|
||||||
while current.lineage.relation_type != RelationType.ROOT:
|
|
||||||
_validate_record_root_binding(current, root)
|
|
||||||
parent_id = current.lineage.parent_task_id
|
|
||||||
if not parent_id or parent_id in seen:
|
|
||||||
raise IntegrationError("task lineage contains a parent cycle")
|
|
||||||
seen.add(parent_id)
|
|
||||||
parent = _as_task_record(port.get_task_snapshot(parent_id))
|
|
||||||
_validate_record_root_binding(parent, root)
|
|
||||||
if current.lineage.parent_task_id != parent.task_id:
|
|
||||||
raise IntegrationError("task lineage parent does not match authority parent")
|
|
||||||
if current.lineage.lineage_depth != parent.lineage.lineage_depth + 1:
|
|
||||||
raise IntegrationError("task lineage depth does not match authority parent")
|
|
||||||
if current.lineage.parent_revision is None or (
|
|
||||||
current.lineage.parent_revision > parent.revision
|
|
||||||
):
|
|
||||||
raise IntegrationError("task lineage parent revision is not authoritative")
|
|
||||||
current = parent
|
|
||||||
if current.task_id != root.task_id:
|
|
||||||
raise IntegrationError("task lineage parent chain ends at a different root")
|
|
||||||
return record, root
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_dispatch_receipt(
|
|
||||||
receipt: RootActionDispatchReceipt,
|
|
||||||
*,
|
|
||||||
review: RootReviewRecord,
|
|
||||||
idempotency_key: str,
|
|
||||||
existing: bool,
|
|
||||||
) -> None:
|
|
||||||
if (
|
|
||||||
receipt.review_id != review.review_id
|
|
||||||
or receipt.root_task_id != review.root_task_id
|
|
||||||
or receipt.action != review.next_legal_action
|
|
||||||
or receipt.idempotency_key != idempotency_key
|
|
||||||
or receipt.status not in PERSISTED_DISPATCH_STATUSES
|
|
||||||
):
|
|
||||||
noun = "existing root-action dispatch" if existing else "root-action dispatch receipt"
|
|
||||||
raise IntegrationError(f"{noun} conflicts with persisted review")
|
|
||||||
|
|
||||||
|
|
||||||
class _RootReviewStoreAdapter(RootReviewStore):
|
|
||||||
def __init__(self, port: TaskPlaneLineagePort):
|
|
||||||
self._port = port
|
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> TaskRecord:
|
|
||||||
record, _ = _load_authority_record(self._port, task_id)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def list_descendants(self, root_task_id: str) -> Sequence[TaskRecord]:
|
|
||||||
root, _ = _load_authority_record(self._port, root_task_id)
|
|
||||||
if root.lineage.relation_type != RelationType.ROOT:
|
|
||||||
raise IntegrationError("descendant lookup key is not a ROOT task")
|
|
||||||
records: list[TaskRecord] = []
|
|
||||||
for snapshot in self._port.list_descendant_snapshots(root_task_id):
|
|
||||||
record, resolved_root = _load_authority_record(self._port, snapshot.task_id)
|
|
||||||
if resolved_root.task_id != root_task_id:
|
|
||||||
raise IntegrationError("descendant authority returned a cross-root task")
|
|
||||||
_validate_record_root_binding(record, root)
|
|
||||||
records.append(record)
|
|
||||||
return tuple(records)
|
|
||||||
|
|
||||||
def get_root_review(self, review_id: str) -> RootReviewRecord | None:
|
|
||||||
raw = self._port.get_root_review_record(review_id)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
raise IntegrationError("persisted root-review record must be a mapping")
|
|
||||||
try:
|
|
||||||
return root_review_from_record(
|
|
||||||
raw,
|
|
||||||
expected_review_id=review_id,
|
|
||||||
)
|
|
||||||
except LineageError as exc:
|
|
||||||
raise IntegrationError(
|
|
||||||
f"invalid persisted root-review record: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def compare_and_swap_root_review(
|
|
||||||
self,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_root_revision: int,
|
|
||||||
review: RootReviewRecord,
|
|
||||||
) -> bool:
|
|
||||||
return self._port.compare_and_swap_root_review_record(
|
|
||||||
root_task_id=root_task_id,
|
|
||||||
expected_root_revision=expected_root_revision,
|
|
||||||
review_record=root_review_to_record(review),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskIdentityLineageService:
|
|
||||||
"""Application service that wires the lineage domain to TaskPlane authority."""
|
|
||||||
|
|
||||||
def __init__(self, port: TaskPlaneLineagePort):
|
|
||||||
self._port = port
|
|
||||||
|
|
||||||
def prepare_new_task_identity(
|
|
||||||
self, request: NewTaskIdentityRequest
|
|
||||||
) -> PreparedTaskIdentity:
|
|
||||||
alias = derive_safe_owner_alias(
|
|
||||||
request.owner_principal_id, request.preferred_owner_alias
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.relation_type == RelationType.ROOT:
|
|
||||||
if request.parent_task_id is not None:
|
|
||||||
raise IntegrationError("ROOT task cannot have parent_task_id")
|
|
||||||
lineage = make_root_lineage(
|
|
||||||
task_id=request.task_id,
|
|
||||||
owner_principal_id=request.owner_principal_id,
|
|
||||||
owner_alias=alias,
|
|
||||||
created_at=request.created_at,
|
|
||||||
mission_id=request.mission_id,
|
|
||||||
goal_slug=request.goal_slug,
|
|
||||||
root_acceptance_ref=request.root_acceptance_ref,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not request.parent_task_id:
|
|
||||||
raise IntegrationError("non-root task requires parent_task_id")
|
|
||||||
parent, root = _load_authority_record(
|
|
||||||
self._port, request.parent_task_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise IntegrationError("cross-owner child creation rejected")
|
|
||||||
if request.mission_id != root.lineage.mission_id:
|
|
||||||
raise IntegrationError("cross-mission child creation rejected")
|
|
||||||
if request.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
|
||||||
raise IntegrationError("root acceptance reference mismatch")
|
|
||||||
|
|
||||||
sequence = self._port.reserve_sibling_sequence(
|
|
||||||
parent_task_id=parent.task_id,
|
|
||||||
relation_type=request.relation_type,
|
|
||||||
child_task_id=request.task_id,
|
|
||||||
)
|
|
||||||
lineage = make_child_lineage(
|
|
||||||
task_id=request.task_id,
|
|
||||||
created_at=request.created_at,
|
|
||||||
parent=parent,
|
|
||||||
root=root,
|
|
||||||
relation_type=request.relation_type,
|
|
||||||
sibling_sequence=sequence,
|
|
||||||
goal_slug=request.goal_slug,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PreparedTaskIdentity(
|
|
||||||
task_id=request.task_id,
|
|
||||||
display_name=lineage.display_name,
|
|
||||||
lineage=lineage,
|
|
||||||
metadata_patch={
|
|
||||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
|
||||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def attach_legacy_task(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
parent_task_id: str,
|
|
||||||
relation_type: RelationType,
|
|
||||||
goal_slug: str,
|
|
||||||
expected_owner_principal_id: str | None = None,
|
|
||||||
expected_mission_id: str | None = None,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
) -> TaskLineage:
|
|
||||||
if max_cas_retries < 1:
|
|
||||||
raise IntegrationError("max_cas_retries must be >= 1")
|
|
||||||
|
|
||||||
for _ in range(max_cas_retries):
|
|
||||||
current = self._port.get_task_snapshot(task_id)
|
|
||||||
if (
|
|
||||||
expected_owner_principal_id is not None
|
|
||||||
and current.owner_principal_id != expected_owner_principal_id
|
|
||||||
):
|
|
||||||
raise IntegrationError("legacy task owner changed after authorization")
|
|
||||||
if (
|
|
||||||
expected_mission_id is not None
|
|
||||||
and current.mission_id != expected_mission_id
|
|
||||||
):
|
|
||||||
raise IntegrationError("legacy task mission changed after authorization")
|
|
||||||
raw = current.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if isinstance(raw, Mapping):
|
|
||||||
record, _ = _load_authority_record(self._port, task_id)
|
|
||||||
lineage = record.lineage
|
|
||||||
expected_goal_slug = normalize_goal_slug(goal_slug)
|
|
||||||
if (
|
|
||||||
lineage.parent_task_id != parent_task_id
|
|
||||||
or lineage.relation_type != relation_type
|
|
||||||
or lineage.goal_slug != expected_goal_slug
|
|
||||||
):
|
|
||||||
raise IntegrationError(
|
|
||||||
"existing legacy lineage conflicts with requested binding"
|
|
||||||
)
|
|
||||||
display = current.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
|
||||||
if display == lineage.display_name:
|
|
||||||
return lineage
|
|
||||||
if display not in (None, ""):
|
|
||||||
raise IntegrationError(
|
|
||||||
"legacy display name conflicts with authoritative lineage"
|
|
||||||
)
|
|
||||||
if self._port.compare_and_swap_task_metadata(
|
|
||||||
task_id=current.task_id,
|
|
||||||
expected_revision=current.revision,
|
|
||||||
metadata_patch={
|
|
||||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
|
||||||
},
|
|
||||||
):
|
|
||||||
return lineage
|
|
||||||
continue
|
|
||||||
|
|
||||||
parent, root = _load_authority_record(self._port, parent_task_id)
|
|
||||||
if root.status == "COMPLETED":
|
|
||||||
raise IntegrationError("cannot attach lineage below a completed root")
|
|
||||||
if (
|
|
||||||
expected_owner_principal_id is not None
|
|
||||||
and root.lineage.owner_principal_id != expected_owner_principal_id
|
|
||||||
):
|
|
||||||
raise IntegrationError("legacy root owner does not match authorization")
|
|
||||||
if (
|
|
||||||
expected_mission_id is not None
|
|
||||||
and root.lineage.mission_id != expected_mission_id
|
|
||||||
):
|
|
||||||
raise IntegrationError("legacy root mission does not match authorization")
|
|
||||||
if current.owner_principal_id != root.lineage.owner_principal_id:
|
|
||||||
raise IntegrationError("legacy task owner does not match root owner")
|
|
||||||
if current.mission_id != root.lineage.mission_id:
|
|
||||||
raise IntegrationError("legacy task mission does not match root mission")
|
|
||||||
|
|
||||||
sequence = self._port.reserve_sibling_sequence(
|
|
||||||
parent_task_id=parent.task_id,
|
|
||||||
relation_type=relation_type,
|
|
||||||
child_task_id=current.task_id,
|
|
||||||
)
|
|
||||||
lineage = attach_legacy_lineage(
|
|
||||||
existing_task_id=current.task_id,
|
|
||||||
created_at=current.created_at,
|
|
||||||
parent=parent,
|
|
||||||
root=root,
|
|
||||||
relation_type=relation_type,
|
|
||||||
sibling_sequence=sequence,
|
|
||||||
goal_slug=goal_slug,
|
|
||||||
)
|
|
||||||
if self._port.compare_and_swap_task_metadata(
|
|
||||||
task_id=current.task_id,
|
|
||||||
expected_revision=current.revision,
|
|
||||||
metadata_patch={
|
|
||||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
|
||||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
|
||||||
},
|
|
||||||
):
|
|
||||||
return lineage
|
|
||||||
|
|
||||||
raise IntegrationError("legacy lineage CAS retry budget exhausted")
|
|
||||||
|
|
||||||
def on_proven_terminal_child(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
child_task_id: str,
|
|
||||||
acceptance_evaluator: AcceptanceEvaluator,
|
|
||||||
reviewed_at: datetime,
|
|
||||||
max_cas_retries: int = 4,
|
|
||||||
) -> TerminalReviewOutcome:
|
|
||||||
store = _RootReviewStoreAdapter(self._port)
|
|
||||||
review = review_root_after_child_terminal(
|
|
||||||
store=store,
|
|
||||||
trigger_child_task_id=child_task_id,
|
|
||||||
acceptance_evaluator=acceptance_evaluator,
|
|
||||||
reviewed_at=reviewed_at,
|
|
||||||
max_cas_retries=max_cas_retries,
|
|
||||||
)
|
|
||||||
existing = self._port.get_root_action_dispatch(review.review_id)
|
|
||||||
if existing is not None:
|
|
||||||
idempotency_key = (
|
|
||||||
f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
|
||||||
)
|
|
||||||
_validate_dispatch_receipt(
|
|
||||||
existing,
|
|
||||||
review=review,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
existing=True,
|
|
||||||
)
|
|
||||||
return TerminalReviewOutcome(review=review, dispatch=existing)
|
|
||||||
|
|
||||||
idempotency_key = f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
|
||||||
receipt = self._port.dispatch_root_action(
|
|
||||||
review=review,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
_validate_dispatch_receipt(
|
|
||||||
receipt,
|
|
||||||
review=review,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
existing=False,
|
|
||||||
)
|
|
||||||
return TerminalReviewOutcome(review=review, dispatch=receipt)
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Callable, Mapping, Protocol, Sequence, runtime_checkable
|
|
||||||
|
|
||||||
if not __package__:
|
|
||||||
from aios_task_lineage import RelationType, RootReviewRecord, lineage_from_metadata
|
|
||||||
from aios_task_lineage_integration import (
|
|
||||||
IntegrationError,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
RootActionDispatchReceipt,
|
|
||||||
TaskAuthoritySnapshot,
|
|
||||||
TaskPlaneLineagePort,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
from .aios_task_lineage import RelationType, RootReviewRecord, lineage_from_metadata
|
|
||||||
from .aios_task_lineage_integration import (
|
|
||||||
IntegrationError,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
RootActionDispatchReceipt,
|
|
||||||
TaskAuthoritySnapshot,
|
|
||||||
TaskPlaneLineagePort,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorityAdapterError(IntegrationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: object) -> datetime:
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
if value.tzinfo is None or value.utcoffset() is None:
|
|
||||||
raise AuthorityAdapterError("authority datetime must be timezone-aware")
|
|
||||||
return value.astimezone(timezone.utc)
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise AuthorityAdapterError("authority datetime missing")
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise AuthorityAdapterError("authority datetime invalid") from exc
|
|
||||||
if dt.tzinfo is None or dt.utcoffset() is None:
|
|
||||||
raise AuthorityAdapterError("authority datetime must include timezone")
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json_object(value: object, *, field: str) -> dict[str, object]:
|
|
||||||
if value in (None, ""):
|
|
||||||
return {}
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return dict(value)
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise AuthorityAdapterError(f"{field} must be a JSON object")
|
|
||||||
try:
|
|
||||||
obj = json.loads(value)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise AuthorityAdapterError(f"{field} is invalid JSON") from exc
|
|
||||||
if not isinstance(obj, dict):
|
|
||||||
raise AuthorityAdapterError(f"{field} must decode to an object")
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_identity(metadata: Mapping[str, object]) -> tuple[str, str]:
|
|
||||||
lineage = metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if isinstance(lineage, Mapping):
|
|
||||||
owner = lineage.get("owner_principal_id")
|
|
||||||
mission = lineage.get("mission_id")
|
|
||||||
if isinstance(owner, str) and owner and isinstance(mission, str) and mission:
|
|
||||||
return owner, mission
|
|
||||||
|
|
||||||
# Pre-lineage/legacy compatibility. These keys are accepted only as
|
|
||||||
# authority-provided metadata; missing values fail closed rather than being
|
|
||||||
# invented by this adapter.
|
|
||||||
owner = metadata.get("owner_principal_id")
|
|
||||||
mission = metadata.get("mission_id")
|
|
||||||
if not isinstance(owner, str) or not owner:
|
|
||||||
raise AuthorityAdapterError("task owner_principal_id is not discoverable")
|
|
||||||
if not isinstance(mission, str) or not mission:
|
|
||||||
raise AuthorityAdapterError("task mission_id is not discoverable")
|
|
||||||
return owner, mission
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_evidence(metadata: Mapping[str, object]) -> tuple[str, ...]:
|
|
||||||
raw = metadata.get("evidence_refs")
|
|
||||||
if raw is None:
|
|
||||||
return ()
|
|
||||||
if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
|
|
||||||
raise AuthorityAdapterError("evidence_refs must be a string list")
|
|
||||||
return tuple(dict.fromkeys(raw))
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class TaskPlaneReadback(Protocol):
|
|
||||||
"""Independent authority readback surface.
|
|
||||||
|
|
||||||
This may be backed by the TaskPlane query service, a read replica, or a
|
|
||||||
read-only SQLite authority. It must not mutate task state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot: ...
|
|
||||||
|
|
||||||
def list_descendant_snapshots(
|
|
||||||
self, root_task_id: str
|
|
||||||
) -> Sequence[TaskAuthoritySnapshot]: ...
|
|
||||||
|
|
||||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None: ...
|
|
||||||
|
|
||||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CallableTaskPlaneReadback:
|
|
||||||
"""Readback adapter for an existing official TaskPlane query/service."""
|
|
||||||
|
|
||||||
get_task: Callable[[str], TaskAuthoritySnapshot]
|
|
||||||
list_descendants: Callable[[str], Sequence[TaskAuthoritySnapshot]]
|
|
||||||
get_review: Callable[[str], Mapping[str, object] | None] | None = None
|
|
||||||
get_dispatch: Callable[[str], RootActionDispatchReceipt | None] | None = None
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
|
||||||
snapshot = self.get_task(task_id)
|
|
||||||
if not isinstance(snapshot, TaskAuthoritySnapshot):
|
|
||||||
raise AuthorityAdapterError("readback returned invalid task snapshot")
|
|
||||||
if snapshot.task_id != task_id:
|
|
||||||
raise AuthorityAdapterError("readback returned wrong task identity")
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
def list_descendant_snapshots(
|
|
||||||
self, root_task_id: str
|
|
||||||
) -> Sequence[TaskAuthoritySnapshot]:
|
|
||||||
values = tuple(self.list_descendants(root_task_id))
|
|
||||||
if not all(isinstance(v, TaskAuthoritySnapshot) for v in values):
|
|
||||||
raise AuthorityAdapterError("readback returned invalid descendant snapshot")
|
|
||||||
return values
|
|
||||||
|
|
||||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
|
||||||
if self.get_review is None:
|
|
||||||
raise AuthorityAdapterError("independent review readback is not configured")
|
|
||||||
return self.get_review(review_id)
|
|
||||||
|
|
||||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None:
|
|
||||||
if self.get_dispatch is None:
|
|
||||||
raise AuthorityAdapterError("independent dispatch readback is not configured")
|
|
||||||
return self.get_dispatch(review_id)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthoritySchemaReport:
|
|
||||||
task_table: str
|
|
||||||
columns: tuple[str, ...]
|
|
||||||
required_columns_present: bool
|
|
||||||
missing_columns: tuple[str, ...]
|
|
||||||
task_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ReadOnlySQLiteTaskPlane:
|
|
||||||
"""Read-only discovery/readback helper for the current TaskPlane SQLite authority.
|
|
||||||
|
|
||||||
This class never writes. It opens SQLite read-only, including committed WAL
|
|
||||||
data, and exists only for discovery, diagnostics and independent readback.
|
|
||||||
|
|
||||||
Execution writes must go through the live TaskPlane repository/service and
|
|
||||||
are supplied separately through ``TaskPlaneServiceBindings``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
REQUIRED_TASK_COLUMNS = (
|
|
||||||
"task_id",
|
|
||||||
"status",
|
|
||||||
"revision",
|
|
||||||
"metadata_json",
|
|
||||||
"created_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path):
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
if not self.db_path.is_file():
|
|
||||||
raise AuthorityAdapterError(f"TaskPlane DB missing: {self.db_path}")
|
|
||||||
# A live WAL database is not immutable: ignoring its WAL can return
|
|
||||||
# stale state. as_uri also quotes URI metacharacters in file names.
|
|
||||||
uri = self.db_path.resolve().as_uri() + "?mode=ro"
|
|
||||||
con = sqlite3.connect(uri, uri=True)
|
|
||||||
con.row_factory = sqlite3.Row
|
|
||||||
return con
|
|
||||||
|
|
||||||
def inspect_schema(self) -> AuthoritySchemaReport:
|
|
||||||
con = self._connect()
|
|
||||||
try:
|
|
||||||
rows = list(con.execute("PRAGMA table_info(tasks)"))
|
|
||||||
columns = tuple(str(r["name"]) for r in rows)
|
|
||||||
missing = tuple(c for c in self.REQUIRED_TASK_COLUMNS if c not in columns)
|
|
||||||
count = int(con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]) if rows else 0
|
|
||||||
return AuthoritySchemaReport(
|
|
||||||
task_table="tasks",
|
|
||||||
columns=columns,
|
|
||||||
required_columns_present=not missing,
|
|
||||||
missing_columns=missing,
|
|
||||||
task_count=count,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
def _row_to_snapshot(self, row: sqlite3.Row) -> TaskAuthoritySnapshot:
|
|
||||||
keys = set(row.keys())
|
|
||||||
metadata = _load_json_object(
|
|
||||||
row["metadata_json"] if "metadata_json" in keys else None,
|
|
||||||
field="metadata_json",
|
|
||||||
)
|
|
||||||
owner, mission = _metadata_identity(metadata)
|
|
||||||
created_value = row["created_at"] if "created_at" in keys else None
|
|
||||||
return TaskAuthoritySnapshot(
|
|
||||||
task_id=str(row["task_id"]),
|
|
||||||
revision=int(row["revision"]),
|
|
||||||
status=str(row["status"]),
|
|
||||||
owner_principal_id=owner,
|
|
||||||
mission_id=mission,
|
|
||||||
created_at=_parse_datetime(created_value),
|
|
||||||
metadata=metadata,
|
|
||||||
evidence_refs=_metadata_evidence(metadata),
|
|
||||||
# A metadata flag cannot stand in for execution-integrity proof.
|
|
||||||
# The official service adapter must resolve verified receipts.
|
|
||||||
terminal_evidence_proven=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
|
||||||
con = self._connect()
|
|
||||||
try:
|
|
||||||
row = con.execute("SELECT * FROM tasks WHERE task_id=? LIMIT 1", (task_id,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise AuthorityAdapterError(f"task not found: {task_id}")
|
|
||||||
return self._row_to_snapshot(row)
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
def list_descendant_snapshots(self, root_task_id: str) -> tuple[TaskAuthoritySnapshot, ...]:
|
|
||||||
con = self._connect()
|
|
||||||
try:
|
|
||||||
rows = list(con.execute("SELECT * FROM tasks ORDER BY task_id"))
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
out: list[TaskAuthoritySnapshot] = []
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
snap = self._row_to_snapshot(row)
|
|
||||||
except AuthorityAdapterError:
|
|
||||||
# Legacy tasks without discoverable identity cannot safely be
|
|
||||||
# attached to a root by this read-only adapter.
|
|
||||||
continue
|
|
||||||
raw = snap.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if (
|
|
||||||
isinstance(raw, Mapping)
|
|
||||||
and raw.get("root_task_id") == root_task_id
|
|
||||||
and snap.task_id != root_task_id
|
|
||||||
):
|
|
||||||
out.append(snap)
|
|
||||||
return tuple(out)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskPlaneServiceBindings:
|
|
||||||
"""In-process bindings to the existing TaskPlane authority.
|
|
||||||
|
|
||||||
These callables must be backed by the real TaskPlane repository/service,
|
|
||||||
including its existing transactions, revision/CAS, journal and continuation
|
|
||||||
mechanisms. They must NOT be implemented as out-of-band SQLite mutations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
get_task_snapshot: Callable[[str], TaskAuthoritySnapshot]
|
|
||||||
list_descendant_snapshots: Callable[[str], Sequence[TaskAuthoritySnapshot]]
|
|
||||||
reserve_sibling_sequence: Callable[[str, RelationType, str], int]
|
|
||||||
compare_and_swap_task_metadata: Callable[[str, int, Mapping[str, object]], bool]
|
|
||||||
get_root_review_record: Callable[[str], Mapping[str, object] | None]
|
|
||||||
compare_and_swap_root_review_record: Callable[
|
|
||||||
[str, int, Mapping[str, object]], bool
|
|
||||||
]
|
|
||||||
get_root_action_dispatch: Callable[[str], RootActionDispatchReceipt | None]
|
|
||||||
dispatch_root_action: Callable[
|
|
||||||
[RootReviewRecord, str], RootActionDispatchReceipt
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class InProcessTaskPlaneLineageAdapter(TaskPlaneLineagePort):
|
|
||||||
"""Concrete TaskPlaneLineagePort backed by existing TaskPlane service calls."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
bindings: TaskPlaneServiceBindings,
|
|
||||||
*,
|
|
||||||
readback: TaskPlaneReadback | None = None,
|
|
||||||
):
|
|
||||||
self._b = bindings
|
|
||||||
self._readback = readback
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
|
||||||
return self._b.get_task_snapshot(task_id)
|
|
||||||
|
|
||||||
def list_descendant_snapshots(self, root_task_id: str) -> Sequence[TaskAuthoritySnapshot]:
|
|
||||||
return tuple(self._b.list_descendant_snapshots(root_task_id))
|
|
||||||
|
|
||||||
def reserve_sibling_sequence(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
parent_task_id: str,
|
|
||||||
relation_type: RelationType,
|
|
||||||
child_task_id: str,
|
|
||||||
) -> int:
|
|
||||||
seq = self._b.reserve_sibling_sequence(
|
|
||||||
parent_task_id, relation_type, child_task_id
|
|
||||||
)
|
|
||||||
if not isinstance(seq, int) or seq < 1:
|
|
||||||
raise AuthorityAdapterError("TaskPlane returned invalid sibling sequence")
|
|
||||||
return seq
|
|
||||||
|
|
||||||
def compare_and_swap_task_metadata(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
expected_revision: int,
|
|
||||||
metadata_patch: Mapping[str, object],
|
|
||||||
) -> bool:
|
|
||||||
return bool(
|
|
||||||
self._b.compare_and_swap_task_metadata(
|
|
||||||
task_id, expected_revision, metadata_patch
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
|
||||||
return self._b.get_root_review_record(review_id)
|
|
||||||
|
|
||||||
def compare_and_swap_root_review_record(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_root_revision: int,
|
|
||||||
review_record: Mapping[str, object],
|
|
||||||
) -> bool:
|
|
||||||
return bool(
|
|
||||||
self._b.compare_and_swap_root_review_record(
|
|
||||||
root_task_id, expected_root_revision, review_record
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_root_action_dispatch(
|
|
||||||
self, review_id: str
|
|
||||||
) -> RootActionDispatchReceipt | None:
|
|
||||||
return self._b.get_root_action_dispatch(review_id)
|
|
||||||
|
|
||||||
def dispatch_root_action(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
review: RootReviewRecord,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> RootActionDispatchReceipt:
|
|
||||||
return self._b.dispatch_root_action(review, idempotency_key)
|
|
||||||
|
|
||||||
def verify_task_readback(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
expected_revision_at_least: int,
|
|
||||||
expected_metadata: Mapping[str, object],
|
|
||||||
) -> TaskAuthoritySnapshot:
|
|
||||||
if self._readback is None:
|
|
||||||
raise AuthorityAdapterError("independent TaskPlane readback is not configured")
|
|
||||||
snap = self._readback.get_task_snapshot(task_id)
|
|
||||||
if snap.task_id != task_id:
|
|
||||||
raise AuthorityAdapterError("TaskPlane readback task identity mismatch")
|
|
||||||
if snap.revision < expected_revision_at_least:
|
|
||||||
raise AuthorityAdapterError("TaskPlane readback revision did not advance")
|
|
||||||
for key, expected in expected_metadata.items():
|
|
||||||
if snap.metadata.get(key) != expected:
|
|
||||||
raise AuthorityAdapterError(
|
|
||||||
f"TaskPlane readback metadata mismatch for {key}"
|
|
||||||
)
|
|
||||||
return snap
|
|
||||||
|
|
||||||
def verify_root_readback(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_review_id: str,
|
|
||||||
) -> tuple[TaskAuthoritySnapshot, ...]:
|
|
||||||
"""Verify that independent authority descendants remain bound to root.
|
|
||||||
|
|
||||||
The persisted ROOT_REVIEW record itself may live in a journal rather
|
|
||||||
than the task row. This method verifies the task/lineage projection
|
|
||||||
independently while the service binding verifies the journal receipt.
|
|
||||||
"""
|
|
||||||
if self._readback is None:
|
|
||||||
raise AuthorityAdapterError("independent TaskPlane readback is not configured")
|
|
||||||
review = self.get_root_review_record(expected_review_id)
|
|
||||||
dispatch = self.get_root_action_dispatch(expected_review_id)
|
|
||||||
if (review is None or review.get("review_id") != expected_review_id
|
|
||||||
or review.get("root_task_id") != root_task_id or dispatch is None):
|
|
||||||
raise AuthorityAdapterError("root review or dispatch identity mismatch")
|
|
||||||
read_review = getattr(self._readback, "get_root_review_record", None)
|
|
||||||
read_dispatch = getattr(self._readback, "get_root_action_dispatch", None)
|
|
||||||
if not callable(read_review) or not callable(read_dispatch):
|
|
||||||
raise AuthorityAdapterError("independent review/dispatch readback is not configured")
|
|
||||||
if read_review(expected_review_id) != review:
|
|
||||||
raise AuthorityAdapterError("independent root review readback mismatch")
|
|
||||||
if read_dispatch(expected_review_id) != dispatch:
|
|
||||||
raise AuthorityAdapterError("independent dispatch readback mismatch")
|
|
||||||
root = self._readback.get_task_snapshot(root_task_id)
|
|
||||||
raw_root = root.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if root.task_id != root_task_id or not isinstance(raw_root, Mapping):
|
|
||||||
raise AuthorityAdapterError("root readback identity or lineage missing")
|
|
||||||
root_lineage = lineage_from_metadata(dict(raw_root))
|
|
||||||
if (root_lineage.relation_type != RelationType.ROOT
|
|
||||||
or root_lineage.task_id != root_task_id
|
|
||||||
or root_lineage.owner_principal_id != root.owner_principal_id
|
|
||||||
or root_lineage.mission_id != root.mission_id):
|
|
||||||
raise AuthorityAdapterError("root readback authority mismatch")
|
|
||||||
revision = review.get("root_revision_after")
|
|
||||||
if type(revision) is not int or root.revision < revision:
|
|
||||||
raise AuthorityAdapterError("root readback revision is stale")
|
|
||||||
descendants = tuple(self._readback.list_descendant_snapshots(root_task_id))
|
|
||||||
seen = set()
|
|
||||||
for snap in descendants:
|
|
||||||
raw = snap.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
raise AuthorityAdapterError("descendant lineage missing")
|
|
||||||
lineage = lineage_from_metadata(dict(raw))
|
|
||||||
if (snap.task_id in seen or snap.task_id == root_task_id
|
|
||||||
or lineage.task_id != snap.task_id
|
|
||||||
or lineage.root_task_id != root_task_id
|
|
||||||
or lineage.owner_principal_id != root.owner_principal_id
|
|
||||||
or lineage.mission_id != root.mission_id
|
|
||||||
or snap.owner_principal_id != root.owner_principal_id
|
|
||||||
or snap.mission_id != root.mission_id
|
|
||||||
or lineage.root_acceptance_ref != root_lineage.root_acceptance_ref):
|
|
||||||
raise AuthorityAdapterError(
|
|
||||||
f"descendant {snap.task_id} is projected under wrong root"
|
|
||||||
)
|
|
||||||
seen.add(snap.task_id)
|
|
||||||
if review.get("trigger_child_task_id") not in seen:
|
|
||||||
raise AuthorityAdapterError("trigger child missing from independent readback")
|
|
||||||
return descendants
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_independent_readback(self) -> bool:
|
|
||||||
return self._readback is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthenticatedTaskContext:
|
|
||||||
principal_id: str
|
|
||||||
preferred_alias: str | None
|
|
||||||
mission_id: str
|
|
||||||
|
|
||||||
|
|
||||||
def require_context_matches_task(
|
|
||||||
context: AuthenticatedTaskContext,
|
|
||||||
task: TaskAuthoritySnapshot,
|
|
||||||
) -> None:
|
|
||||||
if context.principal_id != task.owner_principal_id:
|
|
||||||
raise AuthorityAdapterError("authenticated principal does not own task")
|
|
||||||
if context.mission_id != task.mission_id:
|
|
||||||
raise AuthorityAdapterError("authenticated mission does not match task")
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""Narrow staging Mission ingress authenticated by the registered Owner signer.
|
|
||||||
|
|
||||||
The signed payload binds the method, resource, body and idempotency key. This
|
|
||||||
adapter neither signs requests nor grants Git/worker/production capabilities.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .task_plane.errors import (
|
|
||||||
ForbiddenError, TaskPlaneHTTPError, UnauthorizedError, ValidationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
AUDIENCE = "aios.task-mission-authority.staging.v1"
|
|
||||||
NAMESPACE = "aios-owner-approval-v1"
|
|
||||||
MAX_BODY = 131072
|
|
||||||
REQUEST_FIELDS = frozenset({
|
|
||||||
"schema", "audience", "method", "path", "body", "idempotency_key",
|
|
||||||
"issued_at", "expires_at", "nonce",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_bytes(value: Any) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"),
|
|
||||||
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def strict_object(pairs):
|
|
||||||
result = {}
|
|
||||||
for key, value in pairs:
|
|
||||||
if key in result:
|
|
||||||
raise ValidationError("duplicate JSON key")
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class SignedMissionIngress:
|
|
||||||
def __init__(
|
|
||||||
self, service, verifier, *, terminal_proof_importer=None, clock=time.time
|
|
||||||
):
|
|
||||||
if not callable(getattr(verifier, "verify", None)):
|
|
||||||
raise ValueError("registered Owner verifier required")
|
|
||||||
identity = getattr(verifier, "signer_id", None)
|
|
||||||
if not isinstance(identity, str) or not identity.strip():
|
|
||||||
raise ValueError("registered signer identity required")
|
|
||||||
self.service = service
|
|
||||||
self.verifier = verifier
|
|
||||||
self.identity = identity
|
|
||||||
self.terminal_proof_importer = terminal_proof_importer
|
|
||||||
self.clock = clock
|
|
||||||
|
|
||||||
def handle(self, envelope: dict) -> tuple[int, dict]:
|
|
||||||
if not isinstance(envelope, dict) or set(envelope) != {
|
|
||||||
"request", "signature", "key_id"
|
|
||||||
}:
|
|
||||||
raise ValidationError("invalid signed mission envelope")
|
|
||||||
request = envelope["request"]
|
|
||||||
if not isinstance(request, dict) or set(request) != REQUEST_FIELDS:
|
|
||||||
raise ValidationError("invalid mission request fields")
|
|
||||||
if (request["schema"] != "aios.signed-mission-request.v1"
|
|
||||||
or request["audience"] != AUDIENCE):
|
|
||||||
raise ForbiddenError("mission request audience mismatch")
|
|
||||||
issued, expires = request["issued_at"], request["expires_at"]
|
|
||||||
if (type(issued) is not int or type(expires) is not int
|
|
||||||
or not 0 < expires - issued <= 300
|
|
||||||
or not issued <= self.clock() < expires):
|
|
||||||
raise UnauthorizedError("mission request expired or not yet valid")
|
|
||||||
if (not isinstance(request["nonce"], str)
|
|
||||||
or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request["nonce"]) is None):
|
|
||||||
raise ValidationError("invalid mission request nonce")
|
|
||||||
if (not isinstance(envelope["signature"], str)
|
|
||||||
or not isinstance(envelope["key_id"], str)):
|
|
||||||
raise UnauthorizedError("Owner signature required")
|
|
||||||
try:
|
|
||||||
verified = self.verifier.verify(
|
|
||||||
namespace=NAMESPACE, payload=canonical_bytes(request),
|
|
||||||
signature=envelope["signature"], key_id=envelope["key_id"],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
verified = False
|
|
||||||
if verified is not True:
|
|
||||||
raise UnauthorizedError("Owner signature invalid")
|
|
||||||
method, path, body = request["method"], request["path"], request["body"]
|
|
||||||
key = request["idempotency_key"]
|
|
||||||
if method == "POST" and path == "/missions":
|
|
||||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
|
||||||
raise ValidationError("mission body and idempotency key required")
|
|
||||||
return 201, self.service.create_mission(
|
|
||||||
body, key, actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
if (method == "GET" and isinstance(path, str)
|
|
||||||
and re.fullmatch(r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}", path)
|
|
||||||
and body is None and key is None):
|
|
||||||
return 200, self.service.get_mission(
|
|
||||||
path.split("/")[2], actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
if (method == "POST" and isinstance(path, str)
|
|
||||||
and re.fullmatch(r"/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/delegate", path)):
|
|
||||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
|
||||||
raise ValidationError("child body and idempotency key required")
|
|
||||||
return 201, self.service.delegate_mission(
|
|
||||||
path.split("/")[2], body, key, actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
if (method == "POST" and isinstance(path, str)
|
|
||||||
and re.fullmatch(
|
|
||||||
r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/legacy-attachments",
|
|
||||||
path,
|
|
||||||
)):
|
|
||||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
|
||||||
raise ValidationError(
|
|
||||||
"legacy attachment body and idempotency key required"
|
|
||||||
)
|
|
||||||
result = self.service.attach_legacy_attachment(
|
|
||||||
path.split("/")[2], body, key, actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
return (200 if result.get("idempotent") is True else 201), result
|
|
||||||
if (method == "GET" and isinstance(path, str)
|
|
||||||
and re.fullmatch(
|
|
||||||
r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/"
|
|
||||||
r"legacy-attachments/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}",
|
|
||||||
path,
|
|
||||||
) and body is None and key is None):
|
|
||||||
pieces = path.split("/")
|
|
||||||
return 200, self.service.get_legacy_attachment(
|
|
||||||
pieces[2], pieces[4], actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
if (method == "POST" and isinstance(path, str)
|
|
||||||
and re.fullmatch(
|
|
||||||
r"/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/reconcile-terminal-proof",
|
|
||||||
path,
|
|
||||||
)):
|
|
||||||
if body != {} or not isinstance(key, str) or not key.strip():
|
|
||||||
raise ValidationError(
|
|
||||||
"terminal proof trigger requires an empty body and idempotency key"
|
|
||||||
)
|
|
||||||
if self.terminal_proof_importer is None:
|
|
||||||
raise ForbiddenError("terminal proof importer is not configured")
|
|
||||||
return 200, self.terminal_proof_importer.reconcile(
|
|
||||||
path.split("/")[2], key, actor_identity=self.identity,
|
|
||||||
)
|
|
||||||
raise ForbiddenError("operation outside mission ingress scope")
|
|
||||||
|
|
||||||
|
|
||||||
def load_registered_verifier(path: Path):
|
|
||||||
metadata = path.lstat()
|
|
||||||
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
|
|
||||||
or metadata.st_mode & 0o022):
|
|
||||||
raise ValueError("Owner verifier must be a root-owned immutable regular file")
|
|
||||||
spec = importlib.util.spec_from_file_location("aios_mission_owner_verifier", path)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise ValueError("Owner verifier module unavailable")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module.GrantRootOwnerApprovalVerifier()
|
|
||||||
|
|
||||||
|
|
||||||
def make_handler(ingress: SignedMissionIngress):
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, *_args):
|
|
||||||
pass # Do not log signed payloads or transport credentials.
|
|
||||||
|
|
||||||
def respond(self, status, value):
|
|
||||||
raw = canonical_bytes(value)
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.send_header("Content-Length", str(len(raw)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(raw)
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
try:
|
|
||||||
if self.path != "/v1/signed-request":
|
|
||||||
raise ForbiddenError("unsupported mission ingress route")
|
|
||||||
if self.headers.get("Transfer-Encoding"):
|
|
||||||
raise ValidationError("transfer encoding is unsupported")
|
|
||||||
lengths = self.headers.get_all("Content-Length", [])
|
|
||||||
if len(lengths) != 1 or not lengths[0].isdigit():
|
|
||||||
raise ValidationError("one content length required")
|
|
||||||
length = int(lengths[0])
|
|
||||||
if not 0 < length <= MAX_BODY:
|
|
||||||
raise ValidationError("mission request too large or empty")
|
|
||||||
self.connection.settimeout(10)
|
|
||||||
raw = self.rfile.read(length)
|
|
||||||
if len(raw) != length:
|
|
||||||
raise ValidationError("truncated mission request")
|
|
||||||
envelope = json.loads(raw, object_pairs_hook=strict_object)
|
|
||||||
status, result = ingress.handle(envelope)
|
|
||||||
self.respond(status, result)
|
|
||||||
except TaskPlaneHTTPError as exc:
|
|
||||||
self.respond(exc.status_code, exc.to_payload())
|
|
||||||
except (ValueError, UnicodeError, RecursionError):
|
|
||||||
self.respond(400, {"error": "invalid_json"})
|
|
||||||
except Exception:
|
|
||||||
self.respond(500, {"error": "mission_authority_unavailable"})
|
|
||||||
return Handler
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
from .task_plane.api import TaskPlaneService
|
|
||||||
from .task_plane.mission import MissionAuthority, MissionPrincipalBinding
|
|
||||||
from .task_plane.policy import IdentityVerifier, PolicyEngine
|
|
||||||
from .task_plane.store import TaskPlaneStore
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--db", type=Path, required=True)
|
|
||||||
parser.add_argument("--owner-verifier", type=Path, required=True)
|
|
||||||
parser.add_argument("--port", type=int, required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if not 1024 <= args.port <= 65535:
|
|
||||||
parser.error("unprivileged loopback port required")
|
|
||||||
verifier = load_registered_verifier(args.owner_verifier)
|
|
||||||
identity = verifier.signer_id
|
|
||||||
store = TaskPlaneStore(str(args.db), high_assurance=True)
|
|
||||||
authority = MissionAuthority(store, principal_bindings=[MissionPrincipalBinding(
|
|
||||||
authenticated_identity=identity, owner_principal_id=identity,
|
|
||||||
acceptance_verifier_id="identity-lineage-live-closure-v1",
|
|
||||||
allowed_task_capabilities=(
|
|
||||||
"code.read", "code.write", "test.execute",
|
|
||||||
"governed.write.file", "governed.write.branch",
|
|
||||||
),
|
|
||||||
max_risk="medium",
|
|
||||||
)])
|
|
||||||
service = TaskPlaneService(store, policy=PolicyEngine(
|
|
||||||
identity_verifier=IdentityVerifier({identity}), execution_enabled=False,
|
|
||||||
identity_capabilities={identity: {"task.creator", "task.read", "task.delegate"}},
|
|
||||||
), mission_authority=authority)
|
|
||||||
ingress = SignedMissionIngress(service, verifier)
|
|
||||||
# SQLite's existing connection and transaction lock share one request thread.
|
|
||||||
HTTPServer(("127.0.0.1", args.port), make_handler(ingress)).serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
"""Authenticated, trigger-only import of authoritative terminal proof.
|
|
||||||
|
|
||||||
The caller authorizes reconciliation but supplies no proof fields. Proof is
|
|
||||||
read by a configured server-side provider and is accepted only after the
|
|
||||||
provider validates an authenticated authority document and its immutable
|
|
||||||
cross-system tuple.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from hashlib import sha256
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Any, Callable, Mapping, Protocol
|
|
||||||
|
|
||||||
from .aios_task_lineage_integration import LINEAGE_METADATA_KEY
|
|
||||||
from .task_plane.errors import ConflictError, ForbiddenError, ValidationError
|
|
||||||
from .task_plane.models import ResultEnvelope, TaskRecord
|
|
||||||
from .task_plane.policy import PolicyDecision
|
|
||||||
|
|
||||||
|
|
||||||
PROOF_SCHEMA = "aios.task-terminal-authority-proof.v1"
|
|
||||||
RECEIPT_SCHEMA = "aios.task-terminal-import-receipt.v1"
|
|
||||||
REQUIRED_SOURCES = frozenset(
|
|
||||||
{
|
|
||||||
"agent_task",
|
|
||||||
"admission",
|
|
||||||
"job_producer",
|
|
||||||
"execution_alias",
|
|
||||||
"governed_job",
|
|
||||||
"bridge",
|
|
||||||
"authority_git",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DOCUMENT_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"schema",
|
|
||||||
"task_id",
|
|
||||||
"mission_id",
|
|
||||||
"root_task_id",
|
|
||||||
"parent_task_id",
|
|
||||||
"acceptance_condition",
|
|
||||||
"agent_task_status",
|
|
||||||
"admission_status",
|
|
||||||
"execution_outcome",
|
|
||||||
"external_job_id",
|
|
||||||
"internal_job_id",
|
|
||||||
"repository",
|
|
||||||
"base_revision",
|
|
||||||
"target_branch",
|
|
||||||
"generation",
|
|
||||||
"execution_plan_digest",
|
|
||||||
"task_principal_ref",
|
|
||||||
"task_lease_ref",
|
|
||||||
"writer_lease_ref",
|
|
||||||
"governed_commit_oid",
|
|
||||||
"authority_ref_oid",
|
|
||||||
"postimage_hashes",
|
|
||||||
"source_receipt_digests",
|
|
||||||
"attestation_ref",
|
|
||||||
"started_at",
|
|
||||||
"completed_at",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
||||||
_GIT_OID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?")
|
|
||||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{2,255}")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_bytes(value: Mapping[str, object]) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _nonempty_string(value: object, field: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value or len(value) > 1024:
|
|
||||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: object, field: str) -> str:
|
|
||||||
text = _nonempty_string(value, field)
|
|
||||||
if _IDENTIFIER.fullmatch(text) is None:
|
|
||||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_time(value: object, field: str) -> str:
|
|
||||||
text = _nonempty_string(value, field)
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ForbiddenError(f"authoritative proof {field} invalid") from exc
|
|
||||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
||||||
raise ForbiddenError(f"authoritative proof {field} lacks timezone")
|
|
||||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VerifiedTerminalProof:
|
|
||||||
document: dict[str, object]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def task_id(self) -> str:
|
|
||||||
return str(self.document["task_id"])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def attestation_ref(self) -> str:
|
|
||||||
return str(self.document["attestation_ref"])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def started_at(self) -> str:
|
|
||||||
return str(self.document["started_at"])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def completed_at(self) -> str:
|
|
||||||
return str(self.document["completed_at"])
|
|
||||||
|
|
||||||
def evidence(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"kind": "acceptance-proof",
|
|
||||||
"proof_schema": PROOF_SCHEMA,
|
|
||||||
"authority_document_digest": sha256(
|
|
||||||
_canonical_bytes(self.document)
|
|
||||||
).hexdigest(),
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"mission_id": self.document["mission_id"],
|
|
||||||
"root_task_id": self.document["root_task_id"],
|
|
||||||
"parent_task_id": self.document["parent_task_id"],
|
|
||||||
"acceptance_condition": self.document["acceptance_condition"],
|
|
||||||
"external_job_id": self.document["external_job_id"],
|
|
||||||
"internal_job_id": self.document["internal_job_id"],
|
|
||||||
"repository": self.document["repository"],
|
|
||||||
"base_revision": self.document["base_revision"],
|
|
||||||
"target_branch": self.document["target_branch"],
|
|
||||||
"generation": self.document["generation"],
|
|
||||||
"execution_plan_digest": self.document["execution_plan_digest"],
|
|
||||||
"task_principal_ref": self.document["task_principal_ref"],
|
|
||||||
"task_lease_ref": self.document["task_lease_ref"],
|
|
||||||
"writer_lease_ref": self.document["writer_lease_ref"],
|
|
||||||
"governed_commit_oid": self.document["governed_commit_oid"],
|
|
||||||
"authority_ref_oid": self.document["authority_ref_oid"],
|
|
||||||
"postimage_hashes": self.document["postimage_hashes"],
|
|
||||||
"source_receipt_digests": self.document["source_receipt_digests"],
|
|
||||||
"attestation_ref": self.attestation_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
def outputs(self) -> list[dict[str, object]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"kind": "governed-commit-readback",
|
|
||||||
"repository": self.document["repository"],
|
|
||||||
"target_branch": self.document["target_branch"],
|
|
||||||
"governed_commit_oid": self.document["governed_commit_oid"],
|
|
||||||
"postimage_hashes": self.document["postimage_hashes"],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorityDocumentReader(Protocol):
|
|
||||||
def __call__(self, task_id: str) -> Mapping[str, object]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorityDocumentVerifier(Protocol):
|
|
||||||
def __call__(self, document: Mapping[str, object]) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedTerminalProofProvider:
|
|
||||||
"""Read and validate one aggregate document backed by existing authorities.
|
|
||||||
|
|
||||||
``reader`` and ``verifier`` are mandatory runtime bindings. The verifier
|
|
||||||
must authenticate the referenced AgentTask/admission/Job/Bridge/Git
|
|
||||||
receipts using already configured trust roots. This class does not confer
|
|
||||||
trust on an arbitrary mapping.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
reader: AuthorityDocumentReader,
|
|
||||||
verifier: AuthorityDocumentVerifier,
|
|
||||||
task_reader: Callable[[str], TaskRecord],
|
|
||||||
worker_identity: str,
|
|
||||||
) -> None:
|
|
||||||
if not callable(reader) or not callable(verifier) or not callable(task_reader):
|
|
||||||
raise ValueError("authenticated proof reader, verifier and task reader required")
|
|
||||||
self._reader = reader
|
|
||||||
self._verifier = verifier
|
|
||||||
self._task_reader = task_reader
|
|
||||||
self.worker_identity = _identifier(worker_identity, "worker_identity")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse(raw: Mapping[str, object]) -> dict[str, object]:
|
|
||||||
if not isinstance(raw, Mapping) or set(raw) != DOCUMENT_FIELDS:
|
|
||||||
raise ForbiddenError("authoritative proof document shape invalid")
|
|
||||||
data = dict(raw)
|
|
||||||
if data["schema"] != PROOF_SCHEMA:
|
|
||||||
raise ForbiddenError("authoritative proof schema invalid")
|
|
||||||
for field in (
|
|
||||||
"task_id", "mission_id", "root_task_id", "parent_task_id",
|
|
||||||
"external_job_id", "internal_job_id", "task_principal_ref", "task_lease_ref",
|
|
||||||
"writer_lease_ref",
|
|
||||||
):
|
|
||||||
data[field] = _identifier(data[field], field)
|
|
||||||
for field in ("acceptance_condition", "repository", "base_revision", "target_branch"):
|
|
||||||
data[field] = _nonempty_string(data[field], field)
|
|
||||||
if data["agent_task_status"] != "SUCCEEDED":
|
|
||||||
raise ForbiddenError("AgentTask is not authoritatively successful")
|
|
||||||
if data["admission_status"] != "ADMITTED":
|
|
||||||
raise ForbiddenError("task admission is not authoritative")
|
|
||||||
if data["execution_outcome"] != "APPLIED_VERIFIED":
|
|
||||||
raise ForbiddenError("governed execution outcome is not verified")
|
|
||||||
if type(data["generation"]) is not int or int(data["generation"]) < 1:
|
|
||||||
raise ForbiddenError("authoritative proof generation invalid")
|
|
||||||
if not isinstance(data["execution_plan_digest"], str) or _HEX64.fullmatch(
|
|
||||||
data["execution_plan_digest"]
|
|
||||||
) is None:
|
|
||||||
raise ForbiddenError("authoritative execution plan digest invalid")
|
|
||||||
for field in ("governed_commit_oid", "authority_ref_oid"):
|
|
||||||
if not isinstance(data[field], str) or _GIT_OID.fullmatch(data[field]) is None:
|
|
||||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
|
||||||
if data["governed_commit_oid"] != data["authority_ref_oid"]:
|
|
||||||
raise ForbiddenError("authority ref does not read back governed commit")
|
|
||||||
images = data["postimage_hashes"]
|
|
||||||
if (
|
|
||||||
not isinstance(images, Mapping)
|
|
||||||
or not images
|
|
||||||
or len(images) > 256
|
|
||||||
or any(
|
|
||||||
not isinstance(path, str)
|
|
||||||
or not path
|
|
||||||
or path.startswith(("/", "\\"))
|
|
||||||
or ".." in path.replace("\\", "/").split("/")
|
|
||||||
or not isinstance(digest, str)
|
|
||||||
or _HEX64.fullmatch(digest) is None
|
|
||||||
for path, digest in images.items()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ForbiddenError("authoritative postimage hashes invalid")
|
|
||||||
data["postimage_hashes"] = dict(sorted(images.items()))
|
|
||||||
sources = data["source_receipt_digests"]
|
|
||||||
if (
|
|
||||||
not isinstance(sources, Mapping)
|
|
||||||
or set(sources) != REQUIRED_SOURCES
|
|
||||||
or any(
|
|
||||||
not isinstance(digest, str) or _HEX64.fullmatch(digest) is None
|
|
||||||
for digest in sources.values()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ForbiddenError("authoritative source receipt set invalid")
|
|
||||||
data["source_receipt_digests"] = dict(sorted(sources.items()))
|
|
||||||
data["attestation_ref"] = _nonempty_string(data["attestation_ref"], "attestation_ref")
|
|
||||||
data["started_at"] = _utc_time(data["started_at"], "started_at")
|
|
||||||
data["completed_at"] = _utc_time(data["completed_at"], "completed_at")
|
|
||||||
if datetime.fromisoformat(data["completed_at"].replace("Z", "+00:00")) < datetime.fromisoformat(
|
|
||||||
data["started_at"].replace("Z", "+00:00")
|
|
||||||
):
|
|
||||||
raise ForbiddenError("authoritative proof time order invalid")
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _assert_task_binding(task: TaskRecord, data: Mapping[str, object]) -> None:
|
|
||||||
lineage = task.task_plan.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if not isinstance(lineage, Mapping) or lineage.get("relation_type") == "ROOT":
|
|
||||||
raise ForbiddenError("terminal proof requires a canonical lineage child")
|
|
||||||
expected = {
|
|
||||||
"task_id": task.task_id,
|
|
||||||
"mission_id": task.mission_id,
|
|
||||||
"root_task_id": task.root_task_id,
|
|
||||||
"parent_task_id": task.task_plan.parent_task_id,
|
|
||||||
"acceptance_condition": task.task_plan.metadata.get("acceptance_condition"),
|
|
||||||
}
|
|
||||||
if any(data.get(field) != value or value in (None, "") for field, value in expected.items()):
|
|
||||||
raise ForbiddenError("authoritative proof TaskPlane binding mismatch")
|
|
||||||
if any(lineage.get(field) != expected[field] for field in expected if field != "acceptance_condition"):
|
|
||||||
raise ForbiddenError("authoritative proof lineage binding mismatch")
|
|
||||||
|
|
||||||
def collect(self, task: TaskRecord) -> VerifiedTerminalProof:
|
|
||||||
raw = self._reader(task.task_id)
|
|
||||||
if not isinstance(raw, Mapping) or self._verifier(raw) is not True:
|
|
||||||
raise ForbiddenError("authoritative terminal proof authentication failed")
|
|
||||||
data = self._parse(raw)
|
|
||||||
self._assert_task_binding(task, data)
|
|
||||||
return VerifiedTerminalProof(data)
|
|
||||||
|
|
||||||
def verify_evidence(self, task: TaskRecord, evidences: list[dict]) -> bool:
|
|
||||||
proof = self.collect(task)
|
|
||||||
return evidences == [proof.evidence()]
|
|
||||||
|
|
||||||
def verify_result(self, result: ResultEnvelope) -> bool:
|
|
||||||
if result.worker_identity != self.worker_identity or result.status != "COMPLETED":
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
task = self._task_reader(result.task_id)
|
|
||||||
proof = self.collect(task)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return bool(
|
|
||||||
result.attestation_ref == proof.attestation_ref
|
|
||||||
and result.evidence == [proof.evidence()]
|
|
||||||
and result.outputs == proof.outputs()
|
|
||||||
and result.started_at == proof.started_at
|
|
||||||
and result.completed_at == proof.completed_at
|
|
||||||
and result.operation_digest == task.task_plan.operation_digest()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalProofEvidenceVerifier:
|
|
||||||
"""PolicyEngine evidence verifier backed by authenticated authority reads."""
|
|
||||||
|
|
||||||
def __init__(self, provider: AuthenticatedTerminalProofProvider) -> None:
|
|
||||||
self.provider = provider
|
|
||||||
|
|
||||||
def verify(self, task: TaskRecord, evidences: list[dict]) -> PolicyDecision:
|
|
||||||
try:
|
|
||||||
allowed = self.provider.verify_evidence(task, evidences)
|
|
||||||
except Exception:
|
|
||||||
allowed = False
|
|
||||||
return PolicyDecision(allowed, "" if allowed else "terminal proof evidence invalid")
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalProofImporter:
|
|
||||||
"""Reconcile a server-verified proof through TaskPlane claim/result paths."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: Any,
|
|
||||||
*,
|
|
||||||
provider: AuthenticatedTerminalProofProvider | None,
|
|
||||||
worker_id: str | None,
|
|
||||||
worker_identity: str | None,
|
|
||||||
lease_seconds: int = 300,
|
|
||||||
) -> None:
|
|
||||||
self.service = service
|
|
||||||
self.provider = provider
|
|
||||||
self.worker_id = worker_id
|
|
||||||
self.worker_identity = worker_identity
|
|
||||||
if provider is not None:
|
|
||||||
if not worker_id or not worker_identity:
|
|
||||||
raise ValueError("terminal proof worker binding required")
|
|
||||||
_identifier(worker_id, "worker_id")
|
|
||||||
_identifier(worker_identity, "worker_identity")
|
|
||||||
if provider.worker_identity != worker_identity:
|
|
||||||
raise ValueError("terminal proof provider identity mismatch")
|
|
||||||
if not 30 <= int(lease_seconds) <= 3600:
|
|
||||||
raise ValueError("terminal proof lease out of range")
|
|
||||||
self.lease_seconds = int(lease_seconds)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def expected_idempotency_key(task: TaskRecord) -> str:
|
|
||||||
return f"terminal-proof:{task.mission_id}:{task.task_id}"
|
|
||||||
|
|
||||||
def _authorized_task(self, task_id: str, actor_identity: str) -> TaskRecord:
|
|
||||||
self.service.get_task(task_id, actor_identity)
|
|
||||||
task = self.service.store.get_task(task_id)
|
|
||||||
lineage = task.task_plan.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if (
|
|
||||||
not isinstance(lineage, Mapping)
|
|
||||||
or lineage.get("relation_type") == "ROOT"
|
|
||||||
or lineage.get("task_id") != task.task_id
|
|
||||||
or lineage.get("mission_id") != task.mission_id
|
|
||||||
or lineage.get("root_task_id") != task.root_task_id
|
|
||||||
or lineage.get("parent_task_id") != task.task_plan.parent_task_id
|
|
||||||
):
|
|
||||||
raise ForbiddenError("terminal proof target is not a canonical child")
|
|
||||||
return task
|
|
||||||
|
|
||||||
def _existing_receipt(
|
|
||||||
self, task: TaskRecord, actor_identity: str
|
|
||||||
) -> dict[str, object] | None:
|
|
||||||
if self.service.lineage is None:
|
|
||||||
raise ForbiddenError("TaskPlane lineage is not configured")
|
|
||||||
snapshot = self.service.lineage.binding.get_task_snapshot(task.task_id)
|
|
||||||
if not snapshot.terminal_evidence_proven:
|
|
||||||
return None
|
|
||||||
if snapshot.status != "COMPLETED":
|
|
||||||
raise ConflictError("terminal proof target did not complete successfully")
|
|
||||||
review = self.service.lineage.binding.get_root_review_for_trigger(task.task_id)
|
|
||||||
if review is None:
|
|
||||||
raise ConflictError("terminal proof lacks durable root review")
|
|
||||||
evidence_rows = self.service.store.list_evidence(task.task_id)
|
|
||||||
by_id = {str(row.get("evidence_id")): row for row in evidence_rows}
|
|
||||||
if not snapshot.evidence_refs or any(ref not in by_id for ref in snapshot.evidence_refs):
|
|
||||||
raise ConflictError("terminal proof evidence readback mismatch")
|
|
||||||
envelopes = []
|
|
||||||
for ref in snapshot.evidence_refs:
|
|
||||||
try:
|
|
||||||
envelope = json.loads(str(by_id[ref]["envelope_json"]))
|
|
||||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
raise ConflictError("terminal proof evidence readback invalid") from exc
|
|
||||||
envelopes.append(envelope)
|
|
||||||
claim_ids = {item.get("claim_id") for item in envelopes}
|
|
||||||
evidence_payloads = [item.get("payload") for item in envelopes]
|
|
||||||
if (
|
|
||||||
len(claim_ids) != 1
|
|
||||||
or len(evidence_payloads) != 1
|
|
||||||
or any(item.get("worker_identity") != self.worker_identity for item in envelopes)
|
|
||||||
or envelopes[0].get("kind") != "acceptance-proof"
|
|
||||||
or not isinstance(evidence_payloads[0], dict)
|
|
||||||
or not self.provider.verify_evidence(task, evidence_payloads)
|
|
||||||
or not self.service.store.verify_evidence_integrity(task.task_id)
|
|
||||||
or not self.service.store.verify_event_chain(task.task_id)
|
|
||||||
):
|
|
||||||
raise ConflictError("terminal proof receipt integrity failed")
|
|
||||||
claim = self.service.store.get_claim(str(next(iter(claim_ids))))
|
|
||||||
if (
|
|
||||||
claim.task_id != task.task_id
|
|
||||||
or claim.worker_id != self.worker_id
|
|
||||||
or claim.worker_identity != self.worker_identity
|
|
||||||
or claim.operation_digest != task.task_plan.operation_digest()
|
|
||||||
or claim.status != "COMPLETED"
|
|
||||||
):
|
|
||||||
raise ConflictError("terminal proof claim binding mismatch")
|
|
||||||
dispatch = self.service.lineage.binding.get_root_action_dispatch(str(review["review_id"]))
|
|
||||||
if dispatch is None:
|
|
||||||
raise ConflictError("terminal proof lacks durable root action dispatch")
|
|
||||||
body: dict[str, object] = {
|
|
||||||
"schema": RECEIPT_SCHEMA,
|
|
||||||
"task_id": task.task_id,
|
|
||||||
"mission_id": task.mission_id,
|
|
||||||
"root_task_id": task.root_task_id,
|
|
||||||
"claim_id": next(iter(claim_ids)),
|
|
||||||
"task_status": snapshot.status,
|
|
||||||
"task_revision": snapshot.revision,
|
|
||||||
"evidence_refs": list(snapshot.evidence_refs),
|
|
||||||
"review_id": review["review_id"],
|
|
||||||
"root_revision_after": review["root_revision_after"],
|
|
||||||
"next_legal_action": review["next_legal_action"],
|
|
||||||
"dispatch_status": dispatch.status,
|
|
||||||
"dispatch_idempotency_key": dispatch.idempotency_key,
|
|
||||||
}
|
|
||||||
body["receipt_digest"] = sha256(_canonical_bytes(body)).hexdigest()
|
|
||||||
return body
|
|
||||||
|
|
||||||
def _exact_active_claim(self, task: TaskRecord):
|
|
||||||
active = self.service.store.active_claim(task.task_id)
|
|
||||||
if active is None:
|
|
||||||
return None
|
|
||||||
if (
|
|
||||||
active.worker_id != self.worker_id
|
|
||||||
or active.worker_identity != self.worker_identity
|
|
||||||
or active.operation_digest != task.task_plan.operation_digest()
|
|
||||||
):
|
|
||||||
raise ConflictError("active claim is owned by another execution binding")
|
|
||||||
return active
|
|
||||||
|
|
||||||
def reconcile(
|
|
||||||
self, task_id: str, idempotency_key: str | None, *, actor_identity: str
|
|
||||||
) -> dict[str, object]:
|
|
||||||
if self.provider is None:
|
|
||||||
raise ForbiddenError("terminal proof provider is not configured")
|
|
||||||
task = self._authorized_task(task_id, actor_identity)
|
|
||||||
if idempotency_key != self.expected_idempotency_key(task):
|
|
||||||
raise ValidationError("terminal proof idempotency key mismatch")
|
|
||||||
existing = self._existing_receipt(task, actor_identity)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
proof = self.provider.collect(task)
|
|
||||||
claim = self._exact_active_claim(task)
|
|
||||||
if claim is None:
|
|
||||||
try:
|
|
||||||
claimed = self.service.claim(
|
|
||||||
task.task_id,
|
|
||||||
{
|
|
||||||
"worker_id": self.worker_id,
|
|
||||||
"lease_seconds": self.lease_seconds,
|
|
||||||
"actor_identity": self.worker_identity,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
claim_id = str(claimed["claim_id"])
|
|
||||||
except Exception:
|
|
||||||
refreshed = self._authorized_task(task_id, actor_identity)
|
|
||||||
replay = self._existing_receipt(refreshed, actor_identity)
|
|
||||||
if replay is not None:
|
|
||||||
return replay
|
|
||||||
claim = self._exact_active_claim(refreshed)
|
|
||||||
if claim is None:
|
|
||||||
raise
|
|
||||||
claim_id = claim.claim_id
|
|
||||||
else:
|
|
||||||
claim_id = claim.claim_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.service.complete(
|
|
||||||
task.task_id,
|
|
||||||
{
|
|
||||||
"claim_id": claim_id,
|
|
||||||
"actor_identity": self.worker_identity,
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"outputs": proof.outputs(),
|
|
||||||
"evidence": [proof.evidence()],
|
|
||||||
"started_at": proof.started_at,
|
|
||||||
"completed_at": proof.completed_at,
|
|
||||||
"attestation_ref": proof.attestation_ref,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
refreshed = self._authorized_task(task_id, actor_identity)
|
|
||||||
replay = self._existing_receipt(refreshed, actor_identity)
|
|
||||||
if replay is None:
|
|
||||||
raise
|
|
||||||
return replay
|
|
||||||
refreshed = self._authorized_task(task_id, actor_identity)
|
|
||||||
receipt = self._existing_receipt(refreshed, actor_identity)
|
|
||||||
if receipt is None:
|
|
||||||
raise ConflictError("terminal proof completion lacks authoritative readback")
|
|
||||||
return receipt
|
|
||||||
@@ -15,7 +15,7 @@ import struct
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Mapping, Protocol
|
from typing import Any, Callable, Mapping, Protocol
|
||||||
|
|
||||||
@@ -133,8 +133,6 @@ class TrustedCandidateBuilderConfig:
|
|||||||
recipes: Mapping[str, Mapping[str, str]]
|
recipes: Mapping[str, Mapping[str, str]]
|
||||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
governed_recipes: Mapping[str, Mapping[str, str]]
|
||||||
policy_sha256: str
|
policy_sha256: str
|
||||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
|
||||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||||
@@ -229,63 +227,14 @@ class TrustedCandidateBuilderConfig:
|
|||||||
return recipe
|
return recipe
|
||||||
|
|
||||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||||
recipe = self.recipes.get(version)
|
recipe = self.fixed_recipe(version)
|
||||||
if recipe is not None:
|
# Intent-only callers do not choose a candidate identity. The
|
||||||
resolved = self.fixed_recipe(version)
|
# policy-owned recipe supplies it; explicit worker requests are
|
||||||
# Fixed/governed-derived policy recipes remain immutable. A
|
# still checked against the same immutable recipe.
|
||||||
# source admission can never override one of these identities.
|
effective_candidate_id = str(candidate_id or recipe.get("candidate_id") or "")
|
||||||
effective_candidate_id = str(candidate_id or resolved.get("candidate_id") or "")
|
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": effective_candidate_id, "source_commit": source_commit}:
|
||||||
if {
|
|
||||||
"version": resolved.get("version"),
|
|
||||||
"candidate_id": resolved.get("candidate_id"),
|
|
||||||
"source_commit": resolved.get("source_commit"),
|
|
||||||
} != {
|
|
||||||
"version": version,
|
|
||||||
"candidate_id": effective_candidate_id,
|
|
||||||
"source_commit": source_commit,
|
|
||||||
}:
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
resolver = self.source_admission_resolver
|
|
||||||
if resolver is None:
|
|
||||||
raise BuilderError("RECIPE_UNKNOWN")
|
|
||||||
try:
|
|
||||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
|
||||||
except Exception as exc:
|
|
||||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
|
||||||
required = {
|
|
||||||
"mode",
|
|
||||||
"task_id",
|
|
||||||
"candidate_id",
|
|
||||||
"base_oid",
|
|
||||||
"source_commit",
|
|
||||||
"version",
|
|
||||||
"purpose",
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
set(admitted) != required
|
|
||||||
or admitted.get("mode") != "governed-source-admission"
|
|
||||||
or admitted.get("version") != version
|
|
||||||
or admitted.get("source_commit") != source_commit
|
|
||||||
or not isinstance(admitted.get("candidate_id"), str)
|
|
||||||
or not admitted.get("candidate_id")
|
|
||||||
):
|
|
||||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
||||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
|
||||||
if effective_candidate_id != admitted["candidate_id"]:
|
|
||||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||||
for name in ("base_oid", "source_commit"):
|
return recipe
|
||||||
value = admitted.get(name)
|
|
||||||
if (
|
|
||||||
not isinstance(value, str)
|
|
||||||
or len(value) != 40
|
|
||||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
|
||||||
):
|
|
||||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
||||||
if admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
|
||||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
|
||||||
return admitted
|
|
||||||
|
|
||||||
|
|
||||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
||||||
@@ -348,35 +297,6 @@ class ServerOwnedEvidenceSource:
|
|||||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def assert_ready(self) -> None:
|
|
||||||
"""Validate the server-owned evidence facility without requiring recipe evidence.
|
|
||||||
|
|
||||||
Readiness is an infrastructure/property check. Candidate-specific evidence
|
|
||||||
remains mandatory for ``BuildTrustedCandidateV1`` and is validated by
|
|
||||||
:meth:`__call__`; an empty evidence root therefore no longer makes the
|
|
||||||
builder globally unavailable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for path, code in (
|
|
||||||
(self._authority_git_dir, "COLLECTOR_AUTHORITY_GIT_UNAVAILABLE"),
|
|
||||||
(self._evidence_root, "COLLECTOR_EVIDENCE_ROOT_UNAVAILABLE"),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
metadata = path.stat(follow_symlinks=False)
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError(code) from exc
|
|
||||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
|
||||||
raise BuilderError(code)
|
|
||||||
if path == self._evidence_root:
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
|
||||||
if (
|
|
||||||
os.name != "nt"
|
|
||||||
and self._expected_owner_uid is not None
|
|
||||||
and metadata.st_uid != self._expected_owner_uid
|
|
||||||
):
|
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
|
||||||
|
|
||||||
def _digest(self, path: Path) -> str:
|
def _digest(self, path: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with path.open("rb") as handle:
|
with path.open("rb") as handle:
|
||||||
@@ -516,17 +436,9 @@ class DeterministicEvidenceCollector:
|
|||||||
self._config = config
|
self._config = config
|
||||||
self._evidence_source = evidence_source
|
self._evidence_source = evidence_source
|
||||||
|
|
||||||
def assert_ready(self) -> None:
|
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||||
"""Fail closed on collector configuration, not on recipe evidence presence."""
|
|
||||||
|
|
||||||
if self._evidence_source is None:
|
if self._evidence_source is None:
|
||||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||||
checker = getattr(self._evidence_source, "assert_ready", None)
|
|
||||||
if checker is not None:
|
|
||||||
checker()
|
|
||||||
|
|
||||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
|
||||||
self.assert_ready()
|
|
||||||
bundle = self._evidence_source(recipe_name, recipe)
|
bundle = self._evidence_source(recipe_name, recipe)
|
||||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||||
@@ -552,32 +464,11 @@ class DeterministicEvidenceCollector:
|
|||||||
try:
|
try:
|
||||||
from silent_release_controller import ReleaseCandidate
|
from silent_release_controller import ReleaseCandidate
|
||||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
||||||
from release_migration_plan import (
|
|
||||||
MigrationPlanError,
|
|
||||||
MigrationPlanRequired,
|
|
||||||
)
|
|
||||||
|
|
||||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||||
release_plan_payload = dict(bundle["release_plan"])
|
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
||||||
migration_resolver = self._config.migration_plan_resolver
|
|
||||||
if migration_resolver is not None:
|
|
||||||
try:
|
|
||||||
migration = migration_resolver(
|
|
||||||
recipe_name,
|
|
||||||
recipe["candidate_id"],
|
|
||||||
recipe["source_commit"],
|
|
||||||
)
|
|
||||||
except MigrationPlanRequired as exc:
|
|
||||||
raise BuilderError("MIGRATION_PLAN_REQUIRED") from exc
|
|
||||||
except MigrationPlanError as exc:
|
|
||||||
raise BuilderError("MIGRATION_PLAN_INVALID") from exc
|
|
||||||
if migration is not None:
|
|
||||||
release_plan_payload["migration"] = migration.to_dict()
|
|
||||||
release_plan = ReleasePlan.from_dict(release_plan_payload)
|
|
||||||
checks = dict(raw_checks)
|
checks = dict(raw_checks)
|
||||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||||
except BuilderError:
|
|
||||||
raise
|
|
||||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
||||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
||||||
@@ -731,12 +622,14 @@ class TrustedCandidateBuilder:
|
|||||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
||||||
if {key: request.get(key) for key in bound} != bound:
|
if {key: request.get(key) for key in bound} != bound:
|
||||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
raise BuilderError("REQUEST_SCOPE_INVALID")
|
||||||
|
bundle = dict(self.collector.collect(recipe_name, recipe))
|
||||||
|
candidate = bundle["candidate"]
|
||||||
|
release_plan = bundle["release_plan"]
|
||||||
|
checks = bundle.get("checks")
|
||||||
|
evidence_digests = bundle["evidence_digests"]
|
||||||
|
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
||||||
|
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||||
if request["operation"] == self.config.readiness_operation:
|
if request["operation"] == self.config.readiness_operation:
|
||||||
self.collector.assert_ready()
|
|
||||||
# Accessing the identity properties validates that the signer is
|
|
||||||
# configured and usable without invoking the private key.
|
|
||||||
builder_key_id = self.signer.key_id
|
|
||||||
builder_key_generation = self.signer.key_generation
|
|
||||||
result = {
|
result = {
|
||||||
"schema": self.config.response_schema,
|
"schema": self.config.response_schema,
|
||||||
"status": "READY",
|
"status": "READY",
|
||||||
@@ -744,8 +637,8 @@ class TrustedCandidateBuilder:
|
|||||||
"recipe": recipe_name,
|
"recipe": recipe_name,
|
||||||
"candidate_id": recipe["candidate_id"],
|
"candidate_id": recipe["candidate_id"],
|
||||||
"source_commit": recipe["source_commit"],
|
"source_commit": recipe["source_commit"],
|
||||||
"builder_key_id": builder_key_id,
|
"builder_key_id": self.signer.key_id,
|
||||||
"builder_key_generation": builder_key_generation,
|
"builder_key_generation": self.signer.key_generation,
|
||||||
"signature_algorithm": "Ed25519",
|
"signature_algorithm": "Ed25519",
|
||||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||||
"policy_sha256": self.config.policy_sha256,
|
"policy_sha256": self.config.policy_sha256,
|
||||||
@@ -760,15 +653,6 @@ class TrustedCandidateBuilder:
|
|||||||
)
|
)
|
||||||
self._persist_nonce_ledger()
|
self._persist_nonce_ledger()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
|
||||||
candidate = bundle["candidate"]
|
|
||||||
release_plan = bundle["release_plan"]
|
|
||||||
checks = bundle.get("checks")
|
|
||||||
evidence_digests = bundle["evidence_digests"]
|
|
||||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
|
||||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
|
||||||
|
|
||||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
attestation = {
|
attestation = {
|
||||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
||||||
@@ -892,50 +776,6 @@ def _main() -> None:
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
||||||
source_admission_root = os.environ.get(
|
|
||||||
"AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT", ""
|
|
||||||
).strip()
|
|
||||||
if source_admission_root:
|
|
||||||
from release_source_admission import open_source_admission_store
|
|
||||||
|
|
||||||
try:
|
|
||||||
source_admission_hmac = (
|
|
||||||
Path(credentials) / "source_admission_hmac"
|
|
||||||
).read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise BuilderError("SOURCE_ADMISSION_HMAC_UNAVAILABLE") from exc
|
|
||||||
source_store = open_source_admission_store(
|
|
||||||
Path(source_admission_root),
|
|
||||||
repository=config.repository,
|
|
||||||
policy=arguments.policy,
|
|
||||||
hmac_key=source_admission_hmac,
|
|
||||||
expected_owner_uid=0,
|
|
||||||
writable=False,
|
|
||||||
)
|
|
||||||
config = replace(
|
|
||||||
config,
|
|
||||||
source_admission_resolver=source_store.resolve_recipe,
|
|
||||||
)
|
|
||||||
migration_plan_root = os.environ.get(
|
|
||||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT", ""
|
|
||||||
).strip()
|
|
||||||
migration_profile_root = os.environ.get(
|
|
||||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT", ""
|
|
||||||
).strip()
|
|
||||||
if bool(migration_plan_root) != bool(migration_profile_root):
|
|
||||||
raise BuilderError("MIGRATION_PLAN_CONFIGURATION_INVALID")
|
|
||||||
if migration_plan_root:
|
|
||||||
from release_migration_plan import FileMigrationPlanResolver
|
|
||||||
|
|
||||||
migration_resolver = FileMigrationPlanResolver(
|
|
||||||
Path(migration_plan_root),
|
|
||||||
profile_root=Path(migration_profile_root),
|
|
||||||
expected_owner_uid=0,
|
|
||||||
)
|
|
||||||
config = replace(
|
|
||||||
config,
|
|
||||||
migration_plan_resolver=migration_resolver.resolve,
|
|
||||||
)
|
|
||||||
signer = CredentialPathEd25519Signer(
|
signer = CredentialPathEd25519Signer(
|
||||||
keyring.signing_private_key_path,
|
keyring.signing_private_key_path,
|
||||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
|||||||
from http import cookies
|
from http import cookies
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||||
@@ -14,11 +13,7 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
|||||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
|
||||||
if SECRET in ('', 'change-me-before-production'):
|
|
||||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
|
||||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
|
||||||
ROLES = ['owner','admin','user']
|
ROLES = ['owner','admin','user']
|
||||||
NODES = ['server-hw','server-local']
|
NODES = ['server-hw','server-local']
|
||||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||||
@@ -48,46 +43,19 @@ def audit(actor, action, target='', payload=None):
|
|||||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||||
def notify(event_type,payload):
|
def notify(event_type,payload):
|
||||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||||
def hash_pw(p):
|
|
||||||
salt=os.urandom(16).hex()
|
|
||||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
|
||||||
return f'pbkdf2${salt}${dk}'
|
|
||||||
def check_pw(stored,p):
|
|
||||||
if not stored or not isinstance(stored,str) or not stored: return False
|
|
||||||
if _is_pbkdf2(stored):
|
|
||||||
_,salt,dk=stored.split('$')
|
|
||||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
|
||||||
return hmac.compare_digest(dk,calc)
|
|
||||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
|
||||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
|
||||||
# credential and never for the known bootstrap default; anything else is
|
|
||||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
|
||||||
if stored == p: return False
|
|
||||||
try:
|
|
||||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
|
||||||
def token(username,role):
|
def token(username,role):
|
||||||
exp=int(time.time())+SESSION_TTL
|
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||||
data=f'{username}|{role}|{exp}'
|
|
||||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
|
||||||
return data+'|'+sig
|
|
||||||
def parse_session(header):
|
def parse_session(header):
|
||||||
if not header: return None
|
if not header: return None
|
||||||
c=cookies.SimpleCookie(); c.load(header)
|
c=cookies.SimpleCookie(); c.load(header)
|
||||||
if 'aios_session' not in c: return None
|
if 'aios_session' not in c: return None
|
||||||
parts=c['aios_session'].value.split('|')
|
parts=c['aios_session'].value.split('|')
|
||||||
if len(parts)!=4: return None
|
if len(parts)!=4: return None
|
||||||
username,role,exp,sig=parts
|
data='|'.join(parts[:3]); sig=parts[3]
|
||||||
data=f'{username}|{role}|{exp}'
|
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||||
try:
|
|
||||||
if int(exp) <= int(time.time()): return None
|
|
||||||
except Exception: return None
|
|
||||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
|
||||||
def require(user, roles): return bool(user and user['role'] in roles)
|
def require(user, roles): return bool(user and user['role'] in roles)
|
||||||
def table(data):
|
def table(data):
|
||||||
if not data: return '<p class="muted">No records.</p>'
|
if not data: return '<p class="muted">No records.</p>'
|
||||||
@@ -247,7 +215,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if path=='/login':
|
if path=='/login':
|
||||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||||
if path=='/register':
|
if path=='/register':
|
||||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
|||||||
from http import cookies
|
from http import cookies
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||||
@@ -14,11 +13,7 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
|||||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
|
||||||
if SECRET in ('', 'change-me-before-production'):
|
|
||||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
|
||||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
|
||||||
ROLES = ['owner','admin','user']
|
ROLES = ['owner','admin','user']
|
||||||
NODES = ['server-hw','server-local']
|
NODES = ['server-hw','server-local']
|
||||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||||
@@ -48,46 +43,19 @@ def audit(actor, action, target='', payload=None):
|
|||||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||||
def notify(event_type,payload):
|
def notify(event_type,payload):
|
||||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||||
def hash_pw(p):
|
|
||||||
salt=os.urandom(16).hex()
|
|
||||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
|
||||||
return f'pbkdf2${salt}${dk}'
|
|
||||||
def check_pw(stored,p):
|
|
||||||
if not stored or not isinstance(stored,str) or not stored: return False
|
|
||||||
if _is_pbkdf2(stored):
|
|
||||||
_,salt,dk=stored.split('$')
|
|
||||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
|
||||||
return hmac.compare_digest(dk,calc)
|
|
||||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
|
||||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
|
||||||
# credential and never for the known bootstrap default; anything else is
|
|
||||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
|
||||||
if stored == p: return False
|
|
||||||
try:
|
|
||||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
|
||||||
def token(username,role):
|
def token(username,role):
|
||||||
exp=int(time.time())+SESSION_TTL
|
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||||
data=f'{username}|{role}|{exp}'
|
|
||||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
|
||||||
return data+'|'+sig
|
|
||||||
def parse_session(header):
|
def parse_session(header):
|
||||||
if not header: return None
|
if not header: return None
|
||||||
c=cookies.SimpleCookie(); c.load(header)
|
c=cookies.SimpleCookie(); c.load(header)
|
||||||
if 'aios_session' not in c: return None
|
if 'aios_session' not in c: return None
|
||||||
parts=c['aios_session'].value.split('|')
|
parts=c['aios_session'].value.split('|')
|
||||||
if len(parts)!=4: return None
|
if len(parts)!=4: return None
|
||||||
username,role,exp,sig=parts
|
data='|'.join(parts[:3]); sig=parts[3]
|
||||||
data=f'{username}|{role}|{exp}'
|
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||||
try:
|
|
||||||
if int(exp) <= int(time.time()): return None
|
|
||||||
except Exception: return None
|
|
||||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
|
||||||
def require(user, roles): return bool(user and user['role'] in roles)
|
def require(user, roles): return bool(user and user['role'] in roles)
|
||||||
def table(data):
|
def table(data):
|
||||||
if not data: return '<p class="muted">No records.</p>'
|
if not data: return '<p class="muted">No records.</p>'
|
||||||
@@ -247,7 +215,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if path=='/login':
|
if path=='/login':
|
||||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||||
if path=='/register':
|
if path=='/register':
|
||||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Authenticated Gitea consistency provider using the real governed Git transport.
|
|
||||||
|
|
||||||
The provider proves Gitea consistency without inventing a REST API:
|
|
||||||
|
|
||||||
* local HEAD == source_oid,
|
|
||||||
* local source_oid^{tree} == tree_oid,
|
|
||||||
* origin identifies the governed repository,
|
|
||||||
* exact checked-out branch exists remotely, and
|
|
||||||
* git ls-remote reports exactly source_oid for that branch.
|
|
||||||
|
|
||||||
It then writes an append-only root-owned v2 provider receipt using only governed
|
|
||||||
policy for provider identity, operation, store root, store id and modes.
|
|
||||||
|
|
||||||
The result explicitly records ``transport=git-ls-remote`` and
|
|
||||||
``api_verified=false``. It must never be described as REST/API evidence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
from typing import Callable, Mapping
|
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
|
||||||
RECEIPT_VERSION = 2
|
|
||||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_REF_RE = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$")
|
|
||||||
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaProviderError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GitCommandResult:
|
|
||||||
returncode: int
|
|
||||||
stdout: str = ""
|
|
||||||
stderr: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
GitRunner = Callable[[tuple[str, ...], Path], GitCommandResult]
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json_bytes(payload: Mapping) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
dict(payload),
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_sha256(payload: Mapping) -> str:
|
|
||||||
return hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _runner(args: tuple[str, ...], cwd: Path) -> GitCommandResult:
|
|
||||||
proc = subprocess.run(
|
|
||||||
list(args),
|
|
||||||
cwd=str(cwd),
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
timeout=45,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return GitCommandResult(proc.returncode, proc.stdout, proc.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def _git(run: GitRunner, cwd: Path, *args: str, error: str) -> str:
|
|
||||||
result = run(("git", *args), cwd)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise GiteaProviderError(f"{error}: {result.stderr.strip()[:300]}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: str, field: str) -> str:
|
|
||||||
value = str(value).strip().lower()
|
|
||||||
if not _OID_RE.fullmatch(value):
|
|
||||||
raise GiteaProviderError(f"{field.upper()}_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _mode(value) -> int:
|
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
text = str(value).strip()
|
|
||||||
if text.isdigit():
|
|
||||||
return int(text, 8)
|
|
||||||
raise GiteaProviderError("PROVIDER_MODE_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_ref(value: str) -> Path:
|
|
||||||
raw = str(value).strip()
|
|
||||||
p = Path(raw)
|
|
||||||
if not raw or p.is_absolute() or any(part in ("", ".", "..") for part in p.parts):
|
|
||||||
raise GiteaProviderError("PROVIDER_RECEIPT_REF_INVALID")
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_remote_url(url: str) -> str:
|
|
||||||
url = str(url).strip()
|
|
||||||
if "://" in url:
|
|
||||||
parts = urlsplit(url)
|
|
||||||
host = parts.hostname or ""
|
|
||||||
port = f":{parts.port}" if parts.port else ""
|
|
||||||
return urlunsplit((parts.scheme, host + port, parts.path, "", ""))
|
|
||||||
if ":" in url and not url.startswith(("/", "./", "../")):
|
|
||||||
left, right = url.split(":", 1)
|
|
||||||
return f"{left.rsplit('@', 1)[-1]}:{right}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def repository_identity_from_remote(url: str) -> str:
|
|
||||||
safe = _safe_remote_url(url)
|
|
||||||
if "://" in safe:
|
|
||||||
path = urlsplit(safe).path
|
|
||||||
elif ":" in safe and not safe.startswith("/"):
|
|
||||||
path = safe.split(":", 1)[1]
|
|
||||||
else:
|
|
||||||
path = safe
|
|
||||||
parts = [x for x in path.strip("/").split("/") if x]
|
|
||||||
if parts and parts[0] == "git":
|
|
||||||
parts = parts[1:]
|
|
||||||
if len(parts) < 2:
|
|
||||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_UNAVAILABLE")
|
|
||||||
owner, repo = parts[-2], parts[-1]
|
|
||||||
if repo.endswith(".git"):
|
|
||||||
repo = repo[:-4]
|
|
||||||
identity = f"{owner}/{repo}".lower()
|
|
||||||
if not _REPO_RE.fullmatch(identity):
|
|
||||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_INVALID")
|
|
||||||
return identity
|
|
||||||
|
|
||||||
|
|
||||||
def load_gitea_policy(policy_path: str | Path) -> dict:
|
|
||||||
raw = json.loads(Path(policy_path).read_text(encoding="utf-8"))
|
|
||||||
defaults = dict(raw.get("defaults") or {})
|
|
||||||
providers = raw.get("providers")
|
|
||||||
if not isinstance(providers, dict) or not isinstance(providers.get("gitea"), dict):
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_POLICY_MISSING")
|
|
||||||
cfg = dict(providers["gitea"])
|
|
||||||
return {
|
|
||||||
"provider_identity": str(cfg["provider_identity"]),
|
|
||||||
"store_id": str(cfg["store_id"]),
|
|
||||||
"store_root": Path(str(cfg["store_root"])),
|
|
||||||
"provider_operation": str(cfg["allowed_operations"][0]),
|
|
||||||
"expected_owner_uid": int(cfg.get("expected_owner_uid", defaults.get("expected_owner_uid", 0))),
|
|
||||||
"directory_mode": min(
|
|
||||||
_mode(x) for x in cfg.get(
|
|
||||||
"allowed_directory_modes",
|
|
||||||
defaults.get("allowed_directory_modes", ["0700", "0750"]),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"receipt_mode": min(
|
|
||||||
_mode(x) for x in cfg.get(
|
|
||||||
"allowed_receipt_modes",
|
|
||||||
defaults.get("allowed_receipt_modes", ["0600", "0640"]),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"authentication_kind": str(
|
|
||||||
cfg.get(
|
|
||||||
"authentication_kind",
|
|
||||||
defaults.get("authentication_kind", "root-owned-store-v1"),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def collect_gitea_transport_consistency(
|
|
||||||
*,
|
|
||||||
repo_root: str | Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
expected_repository: str = "aios/aios",
|
|
||||||
remote: str = "origin",
|
|
||||||
git_runner: GitRunner | None = None,
|
|
||||||
) -> dict:
|
|
||||||
repo_root = Path(repo_root).resolve()
|
|
||||||
if not repo_root.is_dir():
|
|
||||||
raise GiteaProviderError("GITEA_CHECKOUT_UNAVAILABLE")
|
|
||||||
source_oid = _oid(source_oid, "source_oid")
|
|
||||||
tree_oid = _oid(tree_oid, "tree_oid")
|
|
||||||
expected_repository = str(expected_repository).strip().lower()
|
|
||||||
if not _REPO_RE.fullmatch(expected_repository):
|
|
||||||
raise GiteaProviderError("GITEA_EXPECTED_REPOSITORY_INVALID")
|
|
||||||
if not re.fullmatch(r"[A-Za-z0-9._/-]+", remote) or remote.startswith(("/", "-")):
|
|
||||||
raise GiteaProviderError("GITEA_REMOTE_NAME_INVALID")
|
|
||||||
|
|
||||||
run = git_runner or _runner
|
|
||||||
|
|
||||||
if _git(run, repo_root, "rev-parse", "--is-inside-work-tree",
|
|
||||||
error="GITEA_LOCAL_REPOSITORY_REQUIRED").lower() != "true":
|
|
||||||
raise GiteaProviderError("GITEA_LOCAL_REPOSITORY_REQUIRED")
|
|
||||||
|
|
||||||
actual_source = _oid(
|
|
||||||
_git(run, repo_root, "rev-parse", "HEAD^{commit}",
|
|
||||||
error="GITEA_LOCAL_SOURCE_UNAVAILABLE"),
|
|
||||||
"actual_source_oid",
|
|
||||||
)
|
|
||||||
if actual_source != source_oid:
|
|
||||||
raise GiteaProviderError("GITEA_LOCAL_SOURCE_OID_MISMATCH")
|
|
||||||
|
|
||||||
actual_tree = _oid(
|
|
||||||
_git(run, repo_root, "rev-parse", f"{source_oid}^{{tree}}",
|
|
||||||
error="GITEA_LOCAL_TREE_UNAVAILABLE"),
|
|
||||||
"actual_tree_oid",
|
|
||||||
)
|
|
||||||
if actual_tree != tree_oid:
|
|
||||||
raise GiteaProviderError("GITEA_LOCAL_TREE_OID_MISMATCH")
|
|
||||||
|
|
||||||
branch = _git(
|
|
||||||
run, repo_root, "symbolic-ref", "--quiet", "HEAD",
|
|
||||||
error="GITEA_GOVERNED_BRANCH_REQUIRED",
|
|
||||||
)
|
|
||||||
if not _REF_RE.fullmatch(branch):
|
|
||||||
raise GiteaProviderError("GITEA_GOVERNED_BRANCH_INVALID")
|
|
||||||
|
|
||||||
remote_url = _git(
|
|
||||||
run, repo_root, "remote", "get-url", remote,
|
|
||||||
error="GITEA_REMOTE_UNAVAILABLE",
|
|
||||||
)
|
|
||||||
identity = repository_identity_from_remote(remote_url)
|
|
||||||
if identity != expected_repository:
|
|
||||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_MISMATCH")
|
|
||||||
|
|
||||||
output = _git(
|
|
||||||
run, repo_root, "ls-remote", "--exit-code", remote, branch,
|
|
||||||
error="GITEA_REMOTE_REF_UNAVAILABLE",
|
|
||||||
)
|
|
||||||
rows = []
|
|
||||||
for line in output.splitlines():
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 2 and parts[1] == branch:
|
|
||||||
rows.append((parts[0].lower(), parts[1]))
|
|
||||||
if len(rows) != 1:
|
|
||||||
raise GiteaProviderError("GITEA_REMOTE_REF_AMBIGUOUS")
|
|
||||||
remote_oid = _oid(rows[0][0], "remote_ref_oid")
|
|
||||||
if remote_oid != source_oid:
|
|
||||||
raise GiteaProviderError("GITEA_REMOTE_REF_OID_MISMATCH")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"consistent": True,
|
|
||||||
"repository": identity,
|
|
||||||
"source_commit": source_oid,
|
|
||||||
"remote_ref_oid": remote_oid,
|
|
||||||
"tree_oid": tree_oid,
|
|
||||||
"remote_ref": branch,
|
|
||||||
"remote_url": _safe_remote_url(remote_url),
|
|
||||||
"transport": "git-ls-remote",
|
|
||||||
"api_verified": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_root_directory(path: Path, mode: int, expected_uid: int) -> None:
|
|
||||||
if os.geteuid() != expected_uid:
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_ROOT_REQUIRED")
|
|
||||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
|
||||||
st = os.lstat(path)
|
|
||||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_INVALID")
|
|
||||||
if st.st_uid != expected_uid:
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_OWNER_INVALID")
|
|
||||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_MODE_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _write_receipt_exclusive(path: Path, payload: Mapping, mode: int) -> str:
|
|
||||||
data = canonical_json_bytes(payload)
|
|
||||||
if path.exists() or path.is_symlink():
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_EXISTS")
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
||||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
flags |= getattr(os, "O_BINARY", 0)
|
|
||||||
fd = os.open(path, flags, mode)
|
|
||||||
try:
|
|
||||||
view = memoryview(data)
|
|
||||||
while view:
|
|
||||||
written = os.write(fd, view)
|
|
||||||
if written <= 0:
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_WRITE_FAILED")
|
|
||||||
view = view[written:]
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chmod(path, mode)
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def produce_gitea_consistency_receipt(
|
|
||||||
*,
|
|
||||||
repo_root: str | Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
policy_path: str | Path,
|
|
||||||
receipt_ref: str,
|
|
||||||
expected_repository: str = "aios/aios",
|
|
||||||
) -> tuple[str, dict]:
|
|
||||||
cfg = load_gitea_policy(policy_path)
|
|
||||||
if cfg["authentication_kind"] != "root-owned-store-v1":
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_AUTH_KIND_UNSUPPORTED")
|
|
||||||
result = collect_gitea_transport_consistency(
|
|
||||||
repo_root=repo_root,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
expected_repository=expected_repository,
|
|
||||||
)
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
run_id = f"gitea-git-transport-{source_oid[:12]}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
|
||||||
rel = _safe_ref(receipt_ref)
|
|
||||||
target = cfg["store_root"].joinpath(rel)
|
|
||||||
root = cfg["store_root"].resolve()
|
|
||||||
if not str(target.resolve(strict=False)).startswith(str(root) + os.sep):
|
|
||||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_REF_ESCAPE")
|
|
||||||
_ensure_root_directory(root, cfg["directory_mode"], cfg["expected_owner_uid"])
|
|
||||||
parent = root
|
|
||||||
for part in rel.parts[:-1]:
|
|
||||||
parent = parent / part
|
|
||||||
_ensure_root_directory(parent, cfg["directory_mode"], cfg["expected_owner_uid"])
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"schema": RECEIPT_SCHEMA,
|
|
||||||
"version": RECEIPT_VERSION,
|
|
||||||
"provider_identity": cfg["provider_identity"],
|
|
||||||
"provider_operation": cfg["provider_operation"],
|
|
||||||
"operation_run_id": run_id,
|
|
||||||
"result_reference": f"git:{result['remote_url']}#{result['remote_ref']}",
|
|
||||||
"content_sha256": canonical_sha256(result),
|
|
||||||
"source_oid": _oid(source_oid, "source_oid"),
|
|
||||||
"tree_oid": _oid(tree_oid, "tree_oid"),
|
|
||||||
"environment": "hw-isolated-linux-git-transport",
|
|
||||||
"issued_at": now,
|
|
||||||
"result": result,
|
|
||||||
"authentication": {
|
|
||||||
"kind": "root-owned-store-v1",
|
|
||||||
"store_id": cfg["store_id"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sha = _write_receipt_exclusive(target, payload, cfg["receipt_mode"])
|
|
||||||
return sha, payload
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from governed_provider_auth_adapter import (
|
|
||||||
ALL_RELEASE_EVIDENCE_KINDS,
|
|
||||||
authenticate_build_artifacts,
|
|
||||||
gate_w_authentication_status,
|
|
||||||
)
|
|
||||||
from trusted_provider_receipts import (
|
|
||||||
ProviderAuthenticationError,
|
|
||||||
TrustedProviderReceiptResolver,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def produce_authenticated_evidence_closure(
|
|
||||||
request: Any,
|
|
||||||
*,
|
|
||||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
source_root: Path,
|
|
||||||
builder_policy: Path,
|
|
||||||
legacy_producer=None,
|
|
||||||
):
|
|
||||||
"""Production wrapper for the existing evidence producer.
|
|
||||||
|
|
||||||
This is the governed operation entrypoint. It refuses to call the legacy
|
|
||||||
closure assembler until *all* final-release provider receipts are resolved
|
|
||||||
from authenticated stores. For Gate-W, use `validate_gate_w_provider_auth`
|
|
||||||
instead; canary/rollback remain pending until Gate-R.
|
|
||||||
"""
|
|
||||||
if provider_receipt_resolver is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
|
||||||
if legacy_producer is None:
|
|
||||||
from governed_release_evidence_producer import produce_evidence_closure as legacy_producer
|
|
||||||
|
|
||||||
authenticated_request = authenticate_build_artifacts(
|
|
||||||
request,
|
|
||||||
resolver=provider_receipt_resolver,
|
|
||||||
required_kinds=ALL_RELEASE_EVIDENCE_KINDS,
|
|
||||||
)
|
|
||||||
return legacy_producer(
|
|
||||||
authenticated_request,
|
|
||||||
source_root=source_root,
|
|
||||||
builder_policy=builder_policy,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_gate_w_provider_auth(
|
|
||||||
request: Any,
|
|
||||||
*,
|
|
||||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Gate-W authentication check without pretending Gate-R evidence exists."""
|
|
||||||
return gate_w_authentication_status(
|
|
||||||
request,
|
|
||||||
resolver=provider_receipt_resolver,
|
|
||||||
)
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Governed evidence runtime registration chain (V2R2C2 P0).
|
|
||||||
|
|
||||||
Establishes the REAL runtime chain for the governed release evidence producer:
|
|
||||||
|
|
||||||
Inventory -> Resolver -> operation -> Producer -> closure
|
|
||||||
|
|
||||||
The Producer alone (governed_release_evidence_producer) is not "runtime
|
|
||||||
registered" just because its OperationDefinition exists — registration only
|
|
||||||
holds when a runtime can actually discover the operation in an inventory,
|
|
||||||
resolve its ``handler_ref`` to the real callable, and invoke it to produce a
|
|
||||||
closure. That full path is what this module wires and what the
|
|
||||||
``PRODUCER_RUNTIME_REGISTERED_PASS`` verdict proves.
|
|
||||||
|
|
||||||
- Inventory : EvidenceOperationInventory — the set of governed evidence
|
|
||||||
operations the authority can execute (unions the producer
|
|
||||||
registry; extensible to future governed operations).
|
|
||||||
- Resolver : resolve_handler(handler_ref) maps the operation's declared
|
|
||||||
handler reference to the actual ``produce_evidence_closure``
|
|
||||||
callable. Resolution is exact and restrictive — it never
|
|
||||||
imports by arbitrary string eval.
|
|
||||||
- Producer : the resolved callable executes with validated, receipt-bound
|
|
||||||
build artifacts and materializes the evidence closure.
|
|
||||||
- closure : the produced source_root/<version>/ closure.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Iterable, Mapping
|
|
||||||
|
|
||||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
|
||||||
from governed_authenticated_evidence_operation import (
|
|
||||||
produce_authenticated_evidence_closure,
|
|
||||||
)
|
|
||||||
from governed_provider_auth_adapter import gate_w_authentication_status
|
|
||||||
from governed_release_evidence_producer import (
|
|
||||||
EvidenceProduceError,
|
|
||||||
GovernedBuildArtifacts,
|
|
||||||
ProduceResult,
|
|
||||||
TRUSTED_PROVIDER_EVIDENCE_TYPES,
|
|
||||||
AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
|
||||||
produce_evidence_closure,
|
|
||||||
producer_operation_definition,
|
|
||||||
producer_registry,
|
|
||||||
)
|
|
||||||
from trusted_provider_receipts import (
|
|
||||||
ProviderAuthenticationError,
|
|
||||||
TrustedProviderPolicy,
|
|
||||||
TrustedProviderReceiptResolver,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Exact, restrictive handler_ref -> callable map. This is the ONLY way
|
|
||||||
# handler_refs are resolved; no arbitrary import/eval.
|
|
||||||
_HANDLERS: Mapping[str, Callable[..., ProduceResult]] = {
|
|
||||||
"governed_release_evidence_producer.produce_evidence_closure": produce_evidence_closure,
|
|
||||||
AUTHENTICATED_EVIDENCE_HANDLER_REF: produce_authenticated_evidence_closure,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceOperationInventory:
|
|
||||||
"""Inventory of governed evidence operations available to the authority."""
|
|
||||||
|
|
||||||
def __init__(self, *registries: OperationRegistry) -> None:
|
|
||||||
definitions: list[OperationDefinition] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for reg in registries:
|
|
||||||
for definition in reg.definitions:
|
|
||||||
if definition.operation_id in seen:
|
|
||||||
continue
|
|
||||||
seen.add(definition.operation_id)
|
|
||||||
definitions.append(definition)
|
|
||||||
self._registry = OperationRegistry(definitions)
|
|
||||||
self._by_id = {d.operation_id: d for d in definitions}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def default(cls) -> "EvidenceOperationInventory":
|
|
||||||
"""The standard inventory: the governed release evidence producer."""
|
|
||||||
return cls(producer_registry())
|
|
||||||
|
|
||||||
def contains(self, operation_id: str) -> bool:
|
|
||||||
return operation_id in self._by_id
|
|
||||||
|
|
||||||
def operation_ids(self) -> tuple[str, ...]:
|
|
||||||
return tuple(self._by_id)
|
|
||||||
|
|
||||||
def get(self, operation_id: str) -> OperationDefinition | None:
|
|
||||||
return self._by_id.get(operation_id)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_handler(handler_ref: str) -> Callable[..., ProduceResult]:
|
|
||||||
"""Resolver: turn an OperationDefinition.handler_ref into the real callable.
|
|
||||||
|
|
||||||
Exact lookup in a fixed allowlist. Unknown or non-producer references raise.
|
|
||||||
"""
|
|
||||||
handler = _HANDLERS.get(handler_ref)
|
|
||||||
if handler is None:
|
|
||||||
raise EvidenceProduceError("HANDLER_REF_UNRESOLVED")
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_to_artifacts(payload: Mapping[str, Any]) -> GovernedBuildArtifacts:
|
|
||||||
try:
|
|
||||||
return GovernedBuildArtifacts(
|
|
||||||
version=payload["version"],
|
|
||||||
candidate_id=payload["candidate_id"],
|
|
||||||
source_commit=payload["source_commit"],
|
|
||||||
tree_oid=payload["tree_oid"],
|
|
||||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
|
||||||
manifest=payload["manifest"],
|
|
||||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
|
||||||
policy=payload["policy"],
|
|
||||||
tests=payload["tests"],
|
|
||||||
canary=payload["canary"],
|
|
||||||
gitea=payload["gitea"],
|
|
||||||
rollback=payload["rollback"],
|
|
||||||
secret_scan=payload["secret_scan"],
|
|
||||||
deployment=payload["deployment"],
|
|
||||||
tag=payload["tag"],
|
|
||||||
finalization=payload["finalization"],
|
|
||||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
|
||||||
issued_at=payload.get("issued_at", ""),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise EvidenceProduceError("PAYLOAD_SCHEMA_INVALID") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def invoke_governed_produce(
|
|
||||||
*,
|
|
||||||
builder_policy: Path,
|
|
||||||
source_root: Path,
|
|
||||||
payload: Mapping[str, Any],
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Real runtime chain: Inventory -> Resolver -> operation -> Producer -> closure.
|
|
||||||
|
|
||||||
Returns a verdict dict. Only a genuine end-to-end invocation that reaches
|
|
||||||
evidence production reports ``status == "PRODUCER_RUNTIME_REGISTERED_PASS"``.
|
|
||||||
"""
|
|
||||||
inventory = EvidenceOperationInventory.default()
|
|
||||||
operation_id = "governed.release.evidence.produce"
|
|
||||||
if not inventory.contains(operation_id):
|
|
||||||
raise EvidenceProduceError("OPERATION_NOT_IN_INVENTORY")
|
|
||||||
|
|
||||||
definition = inventory.get(operation_id)
|
|
||||||
assert definition is not None # guaranteed by contains()
|
|
||||||
handler = resolve_handler(definition.handler_ref)
|
|
||||||
|
|
||||||
request = _payload_to_artifacts(payload)
|
|
||||||
result = handler(
|
|
||||||
builder_policy=builder_policy,
|
|
||||||
source_root=source_root,
|
|
||||||
request=request,
|
|
||||||
expected_owner_uid=expected_owner_uid,
|
|
||||||
)
|
|
||||||
# The resolved handler returned a real ProduceResult from a real closure;
|
|
||||||
# the registered operation genuinely produced evidence.
|
|
||||||
return {
|
|
||||||
"status": "PRODUCER_RUNTIME_REGISTERED_PASS",
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"handler_ref": definition.handler_ref,
|
|
||||||
"handler_resolved": handler.__module__ + "." + handler.__name__,
|
|
||||||
"evidence_types_bound": list(TRUSTED_PROVIDER_EVIDENCE_TYPES),
|
|
||||||
"result": {
|
|
||||||
"produce_status": result.status,
|
|
||||||
"idempotent": result.idempotent,
|
|
||||||
"receipt_sha256": result.receipt_sha256,
|
|
||||||
"closure_dir": result.closure_dir,
|
|
||||||
"evidence_digests": dict(result.evidence_digests),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# V2R2C2 Round-3: Provider-Authenticated runtime chain.
|
|
||||||
#
|
|
||||||
# Inventory -> Resolver -> authenticated operation -> TrustedProviderReceiptResolver
|
|
||||||
# -> Producer assembler (second-layer bound)
|
|
||||||
#
|
|
||||||
# The Round-3 runtime builds TrustedProviderReceiptResolver from the governed
|
|
||||||
# evidence-provider policy (never from request-supplied store paths, provider
|
|
||||||
# identities, key ids, or HMAC material). Gate-W authenticates only the
|
|
||||||
# provider classes that can exist before staging (tests/Gitea/secret-scan);
|
|
||||||
# canary/rollback remain pending until Gate-R.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Sequence used for a full final release closure (Gate-R).
|
|
||||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
|
||||||
# Provider classes that are real before staging (Gate-W).
|
|
||||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
|
||||||
|
|
||||||
_DEFAULT_PROVIDER_POLICY = Path(__file__).resolve().parents[2] / "config" / "gitops" / "trusted-evidence-providers.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_provider_policy(path: Path) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
|
||||||
if not isinstance(decoded, Mapping) or decoded.get("schema") != "aios.trusted-evidence-providers.v1":
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
def _policy_from_config(name: str, raw: Mapping[str, Any], defaults: Mapping[str, Any]) -> TrustedProviderPolicy:
|
|
||||||
def _mode(value: object, field: str) -> int:
|
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str) and value.isdigit():
|
|
||||||
return int(value, 8)
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
|
||||||
|
|
||||||
try:
|
|
||||||
store_root = Path(str(raw["store_root"])).expanduser()
|
|
||||||
allowed_ops = frozenset(str(op) for op in raw["allowed_operations"])
|
|
||||||
return TrustedProviderPolicy(
|
|
||||||
evidence_kind=name,
|
|
||||||
provider_identity=str(raw["provider_identity"]),
|
|
||||||
store_id=str(raw["store_id"]),
|
|
||||||
store_root=store_root,
|
|
||||||
allowed_operations=allowed_ops,
|
|
||||||
expected_owner_uid=_mode(raw.get("expected_owner_uid", defaults.get("expected_owner_uid", 0)), "expected_owner_uid"),
|
|
||||||
allowed_directory_modes=frozenset(
|
|
||||||
_mode(v, "allowed_directory_modes")
|
|
||||||
for v in raw.get("allowed_directory_modes", defaults.get("allowed_directory_modes", ["0700", "0750"]))
|
|
||||||
),
|
|
||||||
allowed_receipt_modes=frozenset(
|
|
||||||
_mode(v, "allowed_receipt_modes")
|
|
||||||
for v in raw.get("allowed_receipt_modes", defaults.get("allowed_receipt_modes", ["0600", "0640"]))
|
|
||||||
),
|
|
||||||
authentication_kind=str(raw.get("authentication_kind", defaults.get("authentication_kind", "root-owned-store-v1"))),
|
|
||||||
max_age_seconds=raw.get("max_age_seconds", defaults.get("max_age_seconds")),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def build_provider_receipt_resolver(
|
|
||||||
provider_policy_path: Path = _DEFAULT_PROVIDER_POLICY,
|
|
||||||
*,
|
|
||||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
|
||||||
now_fn: Callable[[], Any] | None = None,
|
|
||||||
) -> TrustedProviderReceiptResolver:
|
|
||||||
"""Build TrustedProviderReceiptResolver from the governed evidence-provider policy.
|
|
||||||
|
|
||||||
Store paths, provider identities, key ids and HMAC material come only from
|
|
||||||
the governed policy/secret resolver — never from a request payload.
|
|
||||||
"""
|
|
||||||
policy = _load_provider_policy(provider_policy_path)
|
|
||||||
defaults = dict(policy.get("defaults") or {})
|
|
||||||
providers = policy.get("providers")
|
|
||||||
if not isinstance(providers, Mapping) or not providers:
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
|
||||||
policies: dict[str, TrustedProviderPolicy] = {}
|
|
||||||
for name, raw in providers.items():
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
|
||||||
policies[name] = _policy_from_config(name, raw, defaults)
|
|
||||||
return TrustedProviderReceiptResolver(policies, hmac_key_resolver=hmac_key_resolver, now_fn=now_fn)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolver_guard(resolver: TrustedProviderReceiptResolver | None) -> TrustedProviderReceiptResolver:
|
|
||||||
if resolver is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
|
||||||
return resolver
|
|
||||||
|
|
||||||
|
|
||||||
def validate_gate_w_provider_auth(
|
|
||||||
request: GovernedBuildArtifacts,
|
|
||||||
*,
|
|
||||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Gate-W: authenticate only the provider classes real before staging.
|
|
||||||
|
|
||||||
Returns PRODUCER_RUNTIME_REGISTRATION_PASS / PROVIDER_AUTH_ENFORCEMENT_PASS /
|
|
||||||
FINAL_RELEASE_CLOSURE_PENDING_GATE_R. Canary/rollback are intentionally not
|
|
||||||
required here — they remain pending until Gate-R.
|
|
||||||
"""
|
|
||||||
resolver = _resolver_guard(provider_receipt_resolver)
|
|
||||||
gate_w_authentication_status(request, resolver=resolver)
|
|
||||||
return {
|
|
||||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
|
||||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
|
||||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_closure_adapter(authenticated_request: GovernedBuildArtifacts, *, source_root: Path, builder_policy: Path) -> ProduceResult:
|
|
||||||
"""Invoke the legacy closure assembler on the resolver-authenticated request.
|
|
||||||
|
|
||||||
The classified evidence fields now carry ``provider_receipt`` provenance;
|
|
||||||
the producer's second-layer validator re-asserts content/OID binding on it.
|
|
||||||
"""
|
|
||||||
return produce_evidence_closure(
|
|
||||||
builder_policy=builder_policy,
|
|
||||||
source_root=source_root,
|
|
||||||
request=authenticated_request,
|
|
||||||
expected_owner_uid=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def produce_authenticated_evidence(
|
|
||||||
request: GovernedBuildArtifacts,
|
|
||||||
*,
|
|
||||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
source_root: Path,
|
|
||||||
builder_policy: Path,
|
|
||||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
|
||||||
) -> ProduceResult:
|
|
||||||
"""Gate-R production path: authenticate all five final-release provider
|
|
||||||
receipts, then invoke the legacy assembler only after authentication."""
|
|
||||||
resolver = _resolver_guard(provider_receipt_resolver)
|
|
||||||
try:
|
|
||||||
return produce_authenticated_evidence_closure(
|
|
||||||
request,
|
|
||||||
provider_receipt_resolver=resolver,
|
|
||||||
source_root=source_root,
|
|
||||||
builder_policy=builder_policy,
|
|
||||||
legacy_producer=_legacy_closure_adapter,
|
|
||||||
)
|
|
||||||
except ProviderAuthenticationError as exc:
|
|
||||||
raise EvidenceProduceError(str(exc)) from exc
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import is_dataclass, replace
|
|
||||||
from typing import Any, Iterable, Mapping
|
|
||||||
|
|
||||||
from trusted_provider_receipts import (
|
|
||||||
ProviderAuthenticationError,
|
|
||||||
ResolvedEvidence,
|
|
||||||
TrustedProviderReceiptResolver,
|
|
||||||
)
|
|
||||||
|
|
||||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
|
||||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_evidence_request(
|
|
||||||
evidence_kind: str,
|
|
||||||
evidence_request: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if resolver is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
|
||||||
resolved: ResolvedEvidence = resolver.resolve_evidence(
|
|
||||||
evidence_kind,
|
|
||||||
evidence_request,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
**dict(resolved.result),
|
|
||||||
"provider_receipt": dict(resolved.provider_receipt),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_build_artifacts(
|
|
||||||
request: Any,
|
|
||||||
*,
|
|
||||||
resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
|
||||||
) -> Any:
|
|
||||||
"""Authenticate receipt_ref-only evidence and return a replaced request.
|
|
||||||
|
|
||||||
The input is expected to be the existing GovernedBuildArtifacts dataclass.
|
|
||||||
The function deliberately relies only on field names already used by the
|
|
||||||
producer (`source_commit`, `tree_oid`, and the evidence kind fields), so it
|
|
||||||
can be integrated without changing the deployment/release-plan contract.
|
|
||||||
"""
|
|
||||||
if resolver is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
|
||||||
if not is_dataclass(request):
|
|
||||||
raise TypeError("request must be a dataclass instance")
|
|
||||||
source_oid = getattr(request, "source_commit")
|
|
||||||
tree_oid = getattr(request, "tree_oid")
|
|
||||||
replacements: dict[str, Any] = {}
|
|
||||||
for kind in tuple(required_kinds):
|
|
||||||
evidence_request = getattr(request, kind, None)
|
|
||||||
if evidence_request is None:
|
|
||||||
raise ProviderAuthenticationError(f"EVIDENCE_{kind.upper()}_REQUIRED")
|
|
||||||
replacements[kind] = authenticate_evidence_request(
|
|
||||||
kind,
|
|
||||||
evidence_request,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
resolver=resolver,
|
|
||||||
)
|
|
||||||
return replace(request, **replacements)
|
|
||||||
|
|
||||||
|
|
||||||
def gate_w_authentication_status(
|
|
||||||
request: Any,
|
|
||||||
*,
|
|
||||||
resolver: TrustedProviderReceiptResolver | None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Authenticate Gate-W evidence without pretending final closure exists."""
|
|
||||||
authenticate_build_artifacts(
|
|
||||||
request,
|
|
||||||
resolver=resolver,
|
|
||||||
required_kinds=GATE_W_EVIDENCE_KINDS,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
|
||||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
|
||||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
|
||||||
}
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Governed release evidence producer (V2R2C2 P0, operation governed.release.evidence.produce).
|
|
||||||
|
|
||||||
This module is the *producer* half of the governed evidence pipeline. It never
|
|
||||||
invents candidate/release evidence: every digest and pass/fail flag in the closure
|
|
||||||
is derived from real build artifacts and real verification executions supplied as
|
|
||||||
input. It assembles a self-consistent, root-owned evidence closure below
|
|
||||||
``source_root/<version>`` that ``TrustedBuilderEvidenceMaterializer`` (the projection
|
|
||||||
half) can re-validate and project byte-for-byte.
|
|
||||||
|
|
||||||
Contract (see docs/V2R2C2-P0-convergence-20260909.md section 1):
|
|
||||||
- operation_id : governed.release.evidence.produce
|
|
||||||
- input : governed build artifacts + deployment conventions (no hand-write)
|
|
||||||
- output : source_root/<version>/ full evidence_layout closure + receipt.json
|
|
||||||
- fail-closed : mismatched secret-scan digest, tampered artifact, non-passing
|
|
||||||
tests/canary/rollback/secret-scan, or inconsistent gitea all abort
|
|
||||||
without writing a closure.
|
|
||||||
- idempotent : an existing identical closure is accepted; a conflicting one fails.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
|
||||||
from aios_trusted_candidate_builder import (
|
|
||||||
load_builder_policy,
|
|
||||||
TrustedCandidateBuilderConfig,
|
|
||||||
)
|
|
||||||
from release_evidence_materializer import EvidenceMaterializationError
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
PRODUCE_SCHEMA = "aios.governed-release-evidence-producer.v1"
|
|
||||||
EVIDENCE_RECEIPT_SCHEMA = "aios.trusted-candidate-builder.evidence.v1"
|
|
||||||
# Each governed evidence type (tests/canary/gitea/rollback/secret_scan) must be
|
|
||||||
# bound to a trusted-provider receipt. A plain caller-supplied {"passed": true}
|
|
||||||
# is never accepted on its own: the producer requires the receipt that proves a
|
|
||||||
# trusted provider actually ran the check and produced the referenced result.
|
|
||||||
TRUSTED_PROVIDER_RECEIPT_SCHEMA = "aios.trusted-provider.evidence.v1"
|
|
||||||
TRUSTED_PROVIDER_EVIDENCE_TYPES = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceProduceError(RuntimeError):
|
|
||||||
"""Stable, non-sensitive producer failure."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
ensure_ascii=True,
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _file_sha256(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with Path(path).open("rb") as handle:
|
|
||||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256_bytes(value: bytes) -> str:
|
|
||||||
return hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_oid(value: object, name: str) -> str:
|
|
||||||
normalized = str(value or "").strip().lower()
|
|
||||||
if (
|
|
||||||
len(normalized) != 40
|
|
||||||
or any(char not in "0123456789abcdef" for char in normalized)
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_identifier(value: object, name: str) -> str:
|
|
||||||
normalized = str(value or "").strip()
|
|
||||||
if (
|
|
||||||
not normalized
|
|
||||||
or len(normalized) > 255
|
|
||||||
or normalized[0] in ".-"
|
|
||||||
or any(
|
|
||||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
|
||||||
for char in normalized
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_sha256(value: object, name: str, *, allow_empty: bool = False) -> str:
|
|
||||||
normalized = str(value or "").strip().lower()
|
|
||||||
if allow_empty and not normalized:
|
|
||||||
return ""
|
|
||||||
if (
|
|
||||||
len(normalized) != 64
|
|
||||||
or any(char not in "0123456789abcdef" for char in normalized)
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GovernedBuildArtifacts:
|
|
||||||
"""Real build artifacts + real verification results. Producer never invents these."""
|
|
||||||
|
|
||||||
version: str
|
|
||||||
candidate_id: str
|
|
||||||
source_commit: str
|
|
||||||
tree_oid: str
|
|
||||||
artifact: bytes
|
|
||||||
manifest: Mapping[str, Any]
|
|
||||||
package_closure: bytes
|
|
||||||
policy: Mapping[str, Any]
|
|
||||||
tests: Mapping[str, Any]
|
|
||||||
canary: Mapping[str, Any]
|
|
||||||
gitea: Mapping[str, Any]
|
|
||||||
rollback: Mapping[str, Any]
|
|
||||||
secret_scan: Mapping[str, Any]
|
|
||||||
# Deployment conventions (supplied by release planning, not evidence invention).
|
|
||||||
deployment: Mapping[str, Any]
|
|
||||||
tag: Mapping[str, Any]
|
|
||||||
finalization: Mapping[str, Any]
|
|
||||||
rollback_sha256: str = ""
|
|
||||||
issued_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProduceResult:
|
|
||||||
status: str
|
|
||||||
idempotent: bool
|
|
||||||
receipt_sha256: str
|
|
||||||
closure_dir: str
|
|
||||||
evidence_digests: Mapping[str, str] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_trusted_provider_receipt(
|
|
||||||
name: str,
|
|
||||||
evidence: Mapping[str, Any],
|
|
||||||
source_commit: str,
|
|
||||||
tree_oid: str,
|
|
||||||
) -> None:
|
|
||||||
"""Require a trusted-provider binding for `evidence` (second-layer check).
|
|
||||||
|
|
||||||
A caller may never pass a bare ``{"passed": true}``: each governed evidence
|
|
||||||
type must be bound to a trusted provider. Two accepted forms, both
|
|
||||||
fail-closed on any missing/structurally invalid/digest-mismatched binding:
|
|
||||||
|
|
||||||
1. Round-2 native ``receipt`` (schema ``aios.trusted-provider.evidence.v1``)
|
|
||||||
— a caller-carried receipt.
|
|
||||||
2. Round-3 resolver-authenticated ``provider_receipt`` provenance (schema
|
|
||||||
``aios.trusted-evidence-provider-receipt.v2``) — produced by
|
|
||||||
``TrustedProviderReceiptResolver`` from an authenticated store. Its input
|
|
||||||
is the resolver-authenticated provenance, never a caller-supplied body.
|
|
||||||
|
|
||||||
This helper is a *second-layer* binding validator: for the Round-3 path the
|
|
||||||
authoritative proof is the resolver's authenticated provenance embedded as
|
|
||||||
``provider_receipt``, and this check only re-asserts content/OID binding on
|
|
||||||
top of it.
|
|
||||||
"""
|
|
||||||
receipt = evidence.get("receipt")
|
|
||||||
if isinstance(receipt, Mapping) and receipt:
|
|
||||||
body = {k: v for k, v in dict(evidence).items() if k != "receipt"}
|
|
||||||
expected = _sha256_bytes(_canonical_json(body))
|
|
||||||
if receipt.get("schema") != TRUSTED_PROVIDER_RECEIPT_SCHEMA:
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
|
||||||
if receipt.get("content_sha256") != expected:
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_CONTENT_MISMATCH")
|
|
||||||
for f in ("provider_identity", "operation_run_id", "result_reference",
|
|
||||||
"environment", "issued_at"):
|
|
||||||
if not str(receipt.get(f, "") or "").strip():
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
|
||||||
if receipt.get("source_oid") not in (None, "", source_commit):
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
|
||||||
if receipt.get("tree_oid") not in (None, "", tree_oid):
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
|
||||||
return
|
|
||||||
|
|
||||||
provider_receipt = evidence.get("provider_receipt")
|
|
||||||
if isinstance(provider_receipt, Mapping) and provider_receipt:
|
|
||||||
if (
|
|
||||||
provider_receipt.get("schema")
|
|
||||||
!= "aios.trusted-evidence-provider-receipt.v2"
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
|
||||||
for f in ("provider_identity", "provider_operation", "operation_run_id",
|
|
||||||
"result_reference", "content_sha256", "source_oid", "tree_oid",
|
|
||||||
"issued_at"):
|
|
||||||
if not str(provider_receipt.get(f, "") or "").strip():
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
|
||||||
if provider_receipt.get("source_oid") != source_commit:
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
|
||||||
if provider_receipt.get("tree_oid") != tree_oid:
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
|
||||||
return
|
|
||||||
|
|
||||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_MISSING")
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_artifacts(
|
|
||||||
config: TrustedCandidateBuilderConfig,
|
|
||||||
request: GovernedBuildArtifacts,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Validate input consistency (fail-closed) and return the evidence digests."""
|
|
||||||
try:
|
|
||||||
version = validate_release_version(request.version)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
|
||||||
if version != request.version:
|
|
||||||
raise EvidenceProduceError("VERSION_INVALID")
|
|
||||||
|
|
||||||
candidate_id = _validate_identifier(request.candidate_id, "candidate_id")
|
|
||||||
source_commit = _validate_oid(request.source_commit, "source_commit")
|
|
||||||
tree_oid = _validate_oid(request.tree_oid, "tree_oid")
|
|
||||||
|
|
||||||
if (
|
|
||||||
set(request.manifest) != {
|
|
||||||
"artifact_sha256", "package_closure_sha256",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError("MANIFEST_SCHEMA_INVALID")
|
|
||||||
closure_sha = _validate_sha256(
|
|
||||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
|
||||||
)
|
|
||||||
artifact_sha = _sha256_bytes(request.artifact)
|
|
||||||
if request.manifest["artifact_sha256"] != artifact_sha:
|
|
||||||
raise EvidenceProduceError("MANIFEST_ARTIFACT_DIGEST_MISMATCH")
|
|
||||||
|
|
||||||
# manifest_sha256 is the digest of the manifest evidence file itself. It is
|
|
||||||
# derived (never invented) from the manifest content; the manifest does not
|
|
||||||
# embed its own digest to avoid a self-referential fixed point.
|
|
||||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
|
||||||
if _sha256_bytes(request.package_closure) != closure_sha:
|
|
||||||
raise EvidenceProduceError("PACKAGE_CLOSURE_DIGEST_MISMATCH")
|
|
||||||
|
|
||||||
# policy_sha256 is the digest of the policy evidence file content; derived
|
|
||||||
# here (never invented, never self-referential).
|
|
||||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
|
||||||
|
|
||||||
# Secret scan must cover the exact artifact bytes.
|
|
||||||
secret_scan_artifact = _validate_sha256(
|
|
||||||
request.secret_scan.get("artifact_sha256"), "secret_scan.artifact_sha256"
|
|
||||||
)
|
|
||||||
if secret_scan_artifact != artifact_sha:
|
|
||||||
raise EvidenceProduceError("SECRET_SCAN_ARTIFACT_DIGEST_MISMATCH")
|
|
||||||
|
|
||||||
# Every governed evidence type must be bound to a trusted-provider receipt.
|
|
||||||
# A bare caller-supplied {"passed": true} (no receipt) fails closed here;
|
|
||||||
# the producer never accepts an invented pass flag.
|
|
||||||
for etype in TRUSTED_PROVIDER_EVIDENCE_TYPES:
|
|
||||||
_require_trusted_provider_receipt(
|
|
||||||
etype, getattr(request, etype), source_commit, tree_oid
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.secret_scan.get("passed") is not True:
|
|
||||||
raise EvidenceProduceError("SECRET_SCAN_NOT_PASSED")
|
|
||||||
|
|
||||||
if request.tests.get("passed") is not True:
|
|
||||||
raise EvidenceProduceError("TESTS_NOT_PASSED")
|
|
||||||
if request.canary.get("passed") is not True:
|
|
||||||
raise EvidenceProduceError("CANARY_NOT_PASSED")
|
|
||||||
if request.rollback.get("ready") is not True:
|
|
||||||
raise EvidenceProduceError("ROLLBACK_NOT_READY")
|
|
||||||
if (
|
|
||||||
request.gitea.get("consistent") is not True
|
|
||||||
or request.gitea.get("repository") != config.repository
|
|
||||||
or request.gitea.get("source_commit") != source_commit
|
|
||||||
or request.gitea.get("remote_ref_oid") != source_commit
|
|
||||||
or request.gitea.get("tree_oid") != tree_oid
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError("GITEA_INCONSISTENT")
|
|
||||||
|
|
||||||
digests = {
|
|
||||||
"artifact": artifact_sha,
|
|
||||||
"manifest": manifest_sha,
|
|
||||||
"package_closure": closure_sha,
|
|
||||||
"policy": policy_sha,
|
|
||||||
"tests": _sha256_bytes(_canonical_json(dict(request.tests))),
|
|
||||||
"canary": _sha256_bytes(_canonical_json(dict(request.canary))),
|
|
||||||
"gitea": _sha256_bytes(_canonical_json(dict(request.gitea))),
|
|
||||||
"rollback": _sha256_bytes(_canonical_json(dict(request.rollback))),
|
|
||||||
"secret_scan": _sha256_bytes(_canonical_json(dict(request.secret_scan))),
|
|
||||||
}
|
|
||||||
return digests
|
|
||||||
|
|
||||||
|
|
||||||
def _build_receipt(
|
|
||||||
config: TrustedCandidateBuilderConfig,
|
|
||||||
request: GovernedBuildArtifacts,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
"""Assemble the self-consistent receipt from verified artifacts."""
|
|
||||||
from silent_release_controller import ReleaseCandidate, ReleaseState
|
|
||||||
|
|
||||||
artifact_sha = _sha256_bytes(request.artifact)
|
|
||||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
|
||||||
closure_sha = _validate_sha256(
|
|
||||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
|
||||||
)
|
|
||||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
|
||||||
|
|
||||||
candidate_kwargs: dict[str, Any] = {}
|
|
||||||
issued_at = str(request.issued_at or "").strip()
|
|
||||||
if issued_at:
|
|
||||||
# The evidence was produced at the build execution instant; bind it so the
|
|
||||||
# same verified artifacts reproduce an identical, idempotent closure.
|
|
||||||
candidate_kwargs["created_at"] = issued_at
|
|
||||||
candidate_kwargs["updated_at"] = issued_at
|
|
||||||
candidate = ReleaseCandidate(
|
|
||||||
candidate_id=request.candidate_id,
|
|
||||||
source_commit=request.source_commit,
|
|
||||||
tree_oid=request.tree_oid,
|
|
||||||
artifact_sha256=artifact_sha,
|
|
||||||
manifest_sha256=manifest_sha,
|
|
||||||
tests=dict(request.tests),
|
|
||||||
canary=dict(request.canary),
|
|
||||||
rollback_ready=request.rollback.get("ready") is True,
|
|
||||||
git_consistency={
|
|
||||||
"consistent": True,
|
|
||||||
"repository": config.repository,
|
|
||||||
"source_commit": request.source_commit,
|
|
||||||
"tree_oid": request.tree_oid,
|
|
||||||
},
|
|
||||||
gitea_consistency=dict(request.gitea),
|
|
||||||
state=ReleaseState.READY_FOR_APPROVAL,
|
|
||||||
**candidate_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
deployment_payload = dict(request.deployment)
|
|
||||||
deployment_payload.update(
|
|
||||||
{
|
|
||||||
"service": request.deployment["service"],
|
|
||||||
"source_commit": request.source_commit,
|
|
||||||
"tree_oid": request.tree_oid,
|
|
||||||
"artifact_sha256": artifact_sha,
|
|
||||||
"manifest_sha256": manifest_sha,
|
|
||||||
"package_closure_sha256": closure_sha,
|
|
||||||
"policy_sha256": policy_sha,
|
|
||||||
"rollback_sha256": _validate_sha256(
|
|
||||||
request.rollback_sha256, "rollback_sha256"
|
|
||||||
)
|
|
||||||
or request.rollback.get("rollback_sha256", ""),
|
|
||||||
"repository_identity": config.repository,
|
|
||||||
"authoritative_git_dir": request.deployment["authoritative_git_dir"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not deployment_payload["rollback_sha256"]:
|
|
||||||
raise EvidenceProduceError("ROLLBACK_SHA256_REQUIRED")
|
|
||||||
|
|
||||||
from silent_release_hardening import (
|
|
||||||
DeploymentPlan,
|
|
||||||
FinalizationPlan,
|
|
||||||
ReleasePlan,
|
|
||||||
TagPlan,
|
|
||||||
TrustedCandidateBundle,
|
|
||||||
)
|
|
||||||
|
|
||||||
release_plan = ReleasePlan(
|
|
||||||
deployment=DeploymentPlan.from_dict(deployment_payload),
|
|
||||||
tag=TagPlan.from_dict(dict(request.tag)),
|
|
||||||
finalization=FinalizationPlan.from_dict(dict(request.finalization)),
|
|
||||||
policy_sha256=policy_sha,
|
|
||||||
)
|
|
||||||
checks = {name: True for name in config.required_checks}
|
|
||||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"schema": EVIDENCE_RECEIPT_SCHEMA,
|
|
||||||
"version": request.version,
|
|
||||||
"candidate": candidate.to_dict(),
|
|
||||||
"release_plan": release_plan.to_dict(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_target(source_root: Path, version: str) -> Path:
|
|
||||||
relative = Path(version)
|
|
||||||
if relative.is_absolute() or any(
|
|
||||||
part in {"", ".", ".."} for part in relative.parts
|
|
||||||
):
|
|
||||||
raise EvidenceProduceError("CLOSURE_PATH_INVALID")
|
|
||||||
candidate = Path(source_root) / version
|
|
||||||
try:
|
|
||||||
metadata = Path(source_root).stat(follow_symlinks=False)
|
|
||||||
except OSError as exc:
|
|
||||||
raise EvidenceProduceError("SOURCE_ROOT_UNAVAILABLE") from exc
|
|
||||||
if not stat.S_ISDIR(metadata.st_mode) or Path(source_root).is_symlink():
|
|
||||||
raise EvidenceProduceError("SOURCE_ROOT_INVALID")
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _write_durable_json(path: Path, payload: Mapping[str, Any], mode: int = 0o600) -> str:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
# The deterministic collector compares file digests against the canonical
|
|
||||||
# payload hash (no trailing newline); write the exact canonical bytes so the
|
|
||||||
# producer's closure re-validates under TrustedBuilderEvidenceMaterializer.
|
|
||||||
raw = _canonical_json(payload)
|
|
||||||
descriptor = os.open(
|
|
||||||
path,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
| getattr(os, "O_BINARY", 0),
|
|
||||||
mode,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
os.write(descriptor, raw)
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
os.chmod(path, mode)
|
|
||||||
return hashlib.sha256(raw).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_durable_bytes(path: Path, value: bytes, mode: int = 0o600) -> str:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
descriptor = os.open(
|
|
||||||
path,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
| getattr(os, "O_BINARY", 0),
|
|
||||||
mode,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
os.write(descriptor, value)
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
os.chmod(path, mode)
|
|
||||||
return hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def produce_evidence_closure(
|
|
||||||
*,
|
|
||||||
builder_policy: Path,
|
|
||||||
source_root: Path,
|
|
||||||
request: GovernedBuildArtifacts,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
) -> ProduceResult:
|
|
||||||
"""Produce a self-consistent, root-owned evidence closure, fail-closed and idempotent."""
|
|
||||||
try:
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(
|
|
||||||
load_builder_policy(builder_policy)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise EvidenceProduceError(str(exc)) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
version = validate_release_version(request.version)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
|
||||||
|
|
||||||
digests = _verify_artifacts(config, request)
|
|
||||||
receipt = _build_receipt(config, request)
|
|
||||||
|
|
||||||
# Resolve layout paths from policy (aligned with evidence_layout; never invented).
|
|
||||||
target = _safe_target(source_root, version)
|
|
||||||
layout_paths = {
|
|
||||||
name: target / Path(template.format(version=version)).relative_to(version)
|
|
||||||
for name, template in config.evidence_layout.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
if target.exists() or target.is_symlink():
|
|
||||||
# Idempotency: an existing identical closure (same receipt digest + same
|
|
||||||
# artifact bytes) is accepted; anything else is a conflict.
|
|
||||||
existing_receipt = target / "receipt.json"
|
|
||||||
if not existing_receipt.is_file():
|
|
||||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
|
||||||
try:
|
|
||||||
existing = json.loads(existing_receipt.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise EvidenceProduceError("CLOSURE_CONFLICT") from exc
|
|
||||||
if _canonical_json(dict(existing)) != _canonical_json(dict(receipt)):
|
|
||||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
|
||||||
for name, path in layout_paths.items():
|
|
||||||
if not (path.is_file() and _file_sha256(path) == digests[name]):
|
|
||||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
|
||||||
return ProduceResult(
|
|
||||||
status="EVIDENCE_PRODUCED",
|
|
||||||
idempotent=True,
|
|
||||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
|
||||||
closure_dir=str(target),
|
|
||||||
evidence_digests=dict(digests),
|
|
||||||
)
|
|
||||||
|
|
||||||
temporary = source_root / (
|
|
||||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.prod.tmp"
|
|
||||||
)
|
|
||||||
if temporary.exists() or temporary.is_symlink():
|
|
||||||
raise EvidenceProduceError("PRODUCE_TEMP_CONFLICT")
|
|
||||||
temporary.mkdir(mode=0o700)
|
|
||||||
# The closure directory mirrors source_root/<version>/; every written file uses
|
|
||||||
# the policy-declared basename so the layout is aligned with evidence_layout.
|
|
||||||
try:
|
|
||||||
_write_durable_json(temporary / "receipt.json", receipt)
|
|
||||||
_write_durable_bytes(temporary / "artifact.tar.gz", request.artifact)
|
|
||||||
_write_durable_json(temporary / "manifest.json", request.manifest)
|
|
||||||
_write_durable_bytes(temporary / "checksums.sha256", request.package_closure)
|
|
||||||
_write_durable_json(temporary / "trusted-release-policy.json", request.policy)
|
|
||||||
_write_durable_json(temporary / "tests.json", request.tests)
|
|
||||||
_write_durable_json(temporary / "canary.json", request.canary)
|
|
||||||
_write_durable_json(temporary / "gitea.json", request.gitea)
|
|
||||||
_write_durable_json(temporary / "rollback.json", request.rollback)
|
|
||||||
_write_durable_json(temporary / "secret-scan.json", request.secret_scan)
|
|
||||||
os.replace(temporary, target)
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(temporary, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
return ProduceResult(
|
|
||||||
status="EVIDENCE_PRODUCED",
|
|
||||||
idempotent=False,
|
|
||||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
|
||||||
closure_dir=str(target),
|
|
||||||
evidence_digests=dict(digests),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def producer_operation_definition() -> OperationDefinition:
|
|
||||||
"""Register the governed.release.evidence.produce operation (lookup-only registry).
|
|
||||||
|
|
||||||
This is the *legacy closure assembler* entrypoint. V2R2C2 Round-3 keeps it
|
|
||||||
registered for backward compatibility with the existing runtime chain; the
|
|
||||||
governed production path now resolves
|
|
||||||
``governed_authenticated_evidence_operation.produce_authenticated_evidence_closure``
|
|
||||||
(see ``authenticated_producer_operation_definition``).
|
|
||||||
"""
|
|
||||||
return OperationDefinition.from_dict(
|
|
||||||
{
|
|
||||||
"operation_id": "governed.release.evidence.produce",
|
|
||||||
"version": 1,
|
|
||||||
"effect_class": "RECONCILABLE",
|
|
||||||
"handler_ref": "governed_release_evidence_producer.produce_evidence_closure",
|
|
||||||
"input_schema": PRODUCE_SCHEMA,
|
|
||||||
"required_permissions": ["governed.release.evidence.produce"],
|
|
||||||
"timeout_seconds": 60.0,
|
|
||||||
"readback": {"mode": "STATE", "required": True},
|
|
||||||
"retry": {"mode": "NEVER"},
|
|
||||||
"reconcile": {"mode": "EXACT_READBACK"},
|
|
||||||
"rollback": {"mode": "COMPENSATE"},
|
|
||||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
AUTHENTICATED_EVIDENCE_HANDLER_REF = (
|
|
||||||
"governed_authenticated_evidence_operation.produce_authenticated_evidence_closure"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def authenticated_producer_operation_definition() -> OperationDefinition:
|
|
||||||
"""Register the governed, provider-authenticated release evidence operation.
|
|
||||||
|
|
||||||
V2R2C2 Round-3 production entrypoint. Its ``handler_ref`` points at the
|
|
||||||
authenticated wrapper, which refuses to invoke the legacy closure assembler
|
|
||||||
until every final-release provider receipt has been resolved from an
|
|
||||||
authenticated store (Gate-W only authenticates tests/Gitea/secret-scan;
|
|
||||||
canary/rollback require Gate-R). See Round-3 Provider Authentication.
|
|
||||||
"""
|
|
||||||
return OperationDefinition.from_dict(
|
|
||||||
{
|
|
||||||
"operation_id": "governed.authenticated.release.evidence.produce",
|
|
||||||
"version": 1,
|
|
||||||
"effect_class": "RECONCILABLE",
|
|
||||||
"handler_ref": AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
|
||||||
"input_schema": PRODUCE_SCHEMA,
|
|
||||||
"required_permissions": ["governed.release.evidence.produce"],
|
|
||||||
"timeout_seconds": 60.0,
|
|
||||||
"readback": {"mode": "STATE", "required": True},
|
|
||||||
"retry": {"mode": "NEVER"},
|
|
||||||
"reconcile": {"mode": "EXACT_READBACK"},
|
|
||||||
"rollback": {"mode": "COMPENSATE"},
|
|
||||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def producer_registry() -> OperationRegistry:
|
|
||||||
"""Union of the legacy assembler and the authenticated production operation."""
|
|
||||||
return OperationRegistry(
|
|
||||||
[producer_operation_definition(), authenticated_producer_operation_definition()]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("operation", choices=("produce",))
|
|
||||||
parser.add_argument("--version", required=True)
|
|
||||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
|
||||||
parser.add_argument("--source-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--artifacts-json", required=True, type=Path)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(arguments.artifacts_json.read_text(encoding="utf-8"))
|
|
||||||
request = GovernedBuildArtifacts(
|
|
||||||
version=arguments.version,
|
|
||||||
candidate_id=payload["candidate_id"],
|
|
||||||
source_commit=payload["source_commit"],
|
|
||||||
tree_oid=payload["tree_oid"],
|
|
||||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
|
||||||
manifest=payload["manifest"],
|
|
||||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
|
||||||
policy=payload["policy"],
|
|
||||||
tests=payload["tests"],
|
|
||||||
canary=payload["canary"],
|
|
||||||
gitea=payload["gitea"],
|
|
||||||
rollback=payload["rollback"],
|
|
||||||
secret_scan=payload["secret_scan"],
|
|
||||||
deployment=payload["deployment"],
|
|
||||||
tag=payload["tag"],
|
|
||||||
finalization=payload["finalization"],
|
|
||||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
|
||||||
)
|
|
||||||
result = produce_evidence_closure(
|
|
||||||
builder_policy=arguments.builder_policy,
|
|
||||||
source_root=arguments.source_root,
|
|
||||||
request=request,
|
|
||||||
)
|
|
||||||
except (EvidenceProduceError, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise SystemExit(str(exc)) from exc
|
|
||||||
from dataclasses import asdict
|
|
||||||
|
|
||||||
print(json.dumps(asdict(result), sort_keys=True, separators=(",", ":")))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Governed, append-only reopen of one expired Owner approval cycle."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
REQUEST_SCHEMA = "aios.owner-approval-reopen-request.v1"
|
|
||||||
EVENT_NAME = "owner_approval_reopen_authorized"
|
|
||||||
_IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
|
||||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalReopenError(RuntimeError):
|
|
||||||
def __init__(self, code: str, detail: str | None = None):
|
|
||||||
self.code = code
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(code if not detail else f"{code}:{detail}")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReopenRequest:
|
|
||||||
candidate_id: str
|
|
||||||
expected_approval_fingerprint: str
|
|
||||||
expected_source_commit: str
|
|
||||||
expected_tree_oid: str
|
|
||||||
expected_artifact_sha256: str
|
|
||||||
expected_manifest_sha256: str
|
|
||||||
expected_release_plan_sha256: str
|
|
||||||
expected_policy_sha256: str
|
|
||||||
reason: str
|
|
||||||
idempotency_key: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "ReopenRequest":
|
|
||||||
if raw.get("schema") != REQUEST_SCHEMA:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SCHEMA_INVALID")
|
|
||||||
allowed = {
|
|
||||||
"schema", "candidate_id", "expected_approval_fingerprint",
|
|
||||||
"expected_source_commit", "expected_tree_oid",
|
|
||||||
"expected_artifact_sha256", "expected_manifest_sha256",
|
|
||||||
"expected_release_plan_sha256", "expected_policy_sha256",
|
|
||||||
"reason", "idempotency_key",
|
|
||||||
}
|
|
||||||
if set(raw) != allowed:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_REQUEST_FIELDS_INVALID")
|
|
||||||
req = cls(
|
|
||||||
candidate_id=str(raw["candidate_id"]),
|
|
||||||
expected_approval_fingerprint=str(raw["expected_approval_fingerprint"]),
|
|
||||||
expected_source_commit=str(raw["expected_source_commit"]),
|
|
||||||
expected_tree_oid=str(raw["expected_tree_oid"]),
|
|
||||||
expected_artifact_sha256=str(raw["expected_artifact_sha256"]),
|
|
||||||
expected_manifest_sha256=str(raw["expected_manifest_sha256"]),
|
|
||||||
expected_release_plan_sha256=str(raw["expected_release_plan_sha256"]),
|
|
||||||
expected_policy_sha256=str(raw["expected_policy_sha256"]),
|
|
||||||
reason=str(raw["reason"]),
|
|
||||||
idempotency_key=str(raw["idempotency_key"]),
|
|
||||||
)
|
|
||||||
if not req.candidate_id or "/" in req.candidate_id or ".." in req.candidate_id:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_ID_INVALID")
|
|
||||||
if not _HEX64.fullmatch(req.expected_approval_fingerprint):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_FINGERPRINT_INVALID")
|
|
||||||
if not _HEX40.fullmatch(req.expected_source_commit):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SOURCE_INVALID")
|
|
||||||
if not _HEX40.fullmatch(req.expected_tree_oid):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_TREE_INVALID")
|
|
||||||
for value, code in (
|
|
||||||
(req.expected_artifact_sha256, "APPROVAL_REOPEN_ARTIFACT_INVALID"),
|
|
||||||
(req.expected_manifest_sha256, "APPROVAL_REOPEN_MANIFEST_INVALID"),
|
|
||||||
(req.expected_release_plan_sha256, "APPROVAL_REOPEN_RELEASE_PLAN_INVALID"),
|
|
||||||
(req.expected_policy_sha256, "APPROVAL_REOPEN_POLICY_INVALID"),
|
|
||||||
):
|
|
||||||
if not _HEX64.fullmatch(value):
|
|
||||||
raise ApprovalReopenError(code)
|
|
||||||
if not (1 <= len(req.reason.strip()) <= 512):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_REASON_INVALID")
|
|
||||||
if not _IDEMPOTENCY_RE.fullmatch(req.idempotency_key):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_IDEMPOTENCY_KEY_INVALID")
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
|
||||||
def _canon(value: Any) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _time(value: Any, code: str) -> datetime:
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ApprovalReopenError(code)
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ApprovalReopenError(code) from exc
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
raise ApprovalReopenError(code)
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _secure_json(path: Path) -> dict[str, Any]:
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
|
||||||
st = path.stat()
|
|
||||||
if st.st_mode & 0o022:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_WRITABLE")
|
|
||||||
if st.st_uid not in {0, os.geteuid()}:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_OWNER_INVALID")
|
|
||||||
try:
|
|
||||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID") from exc
|
|
||||||
if not isinstance(obj, dict):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _fsync_dir(path: Path) -> None:
|
|
||||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _paths(root: Path, cid: str) -> dict[str, Path]:
|
|
||||||
return {
|
|
||||||
"candidate": root / "candidates" / f"{cid}.json",
|
|
||||||
"saga": root / "sagas" / f"{cid}.json",
|
|
||||||
"plan": root / "release-plans" / f"{cid}.json",
|
|
||||||
"acceptance": root / "acceptance" / f"{cid}.json",
|
|
||||||
"approval": root / "approvals" / f"{cid}.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _history(root: Path, cid: str, fp: str) -> Path:
|
|
||||||
return root / "approval-history" / cid / f"{fp}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _archive(active: Path, history: Path) -> None:
|
|
||||||
history.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
||||||
try:
|
|
||||||
os.chmod(history.parent, 0o750)
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
original = active.read_bytes()
|
|
||||||
if history.exists():
|
|
||||||
if history.is_symlink() or not history.is_file() or history.read_bytes() != original:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COLLISION")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.link(active, history)
|
|
||||||
except OSError:
|
|
||||||
fd = os.open(history, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
||||||
active.stat().st_mode & 0o777)
|
|
||||||
try:
|
|
||||||
view = memoryview(original)
|
|
||||||
while view:
|
|
||||||
n = os.write(fd, view)
|
|
||||||
view = view[n:]
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
if history.read_bytes() != original:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COPY_FAILED")
|
|
||||||
_fsync_dir(history.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _details(event: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
||||||
val = event.get("details")
|
|
||||||
return val if isinstance(val, Mapping) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _cycle_event(saga: Mapping[str, Any], cycle_id: str) -> Mapping[str, Any] | None:
|
|
||||||
for event in saga.get("events") or []:
|
|
||||||
if isinstance(event, Mapping) and event.get("event") == EVENT_NAME \
|
|
||||||
and _details(event).get("approval_cycle_id") == cycle_id:
|
|
||||||
return event
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _state(name: str) -> Any:
|
|
||||||
from silent_release_hardening import SagaState # lazy: avoids import cycle
|
|
||||||
return getattr(SagaState, name)
|
|
||||||
|
|
||||||
|
|
||||||
def _cycle_id(req: ReopenRequest) -> str:
|
|
||||||
return "approval-reopen-" + _sha(_canon({
|
|
||||||
"schema": REQUEST_SCHEMA,
|
|
||||||
"candidate_id": req.candidate_id,
|
|
||||||
"old_fingerprint": req.expected_approval_fingerprint,
|
|
||||||
"source_commit": req.expected_source_commit,
|
|
||||||
"tree_oid": req.expected_tree_oid,
|
|
||||||
"artifact_sha256": req.expected_artifact_sha256,
|
|
||||||
"manifest_sha256": req.expected_manifest_sha256,
|
|
||||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
|
||||||
"policy_sha256": req.expected_policy_sha256,
|
|
||||||
"idempotency_key": req.idempotency_key,
|
|
||||||
}))[:24]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(req: ReopenRequest, candidate: Mapping[str, Any],
|
|
||||||
saga: Mapping[str, Any], plan: Mapping[str, Any],
|
|
||||||
acceptance: Mapping[str, Any], approval: Mapping[str, Any],
|
|
||||||
now: datetime) -> None:
|
|
||||||
if saga.get("journal_integrity") is False:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_JOURNAL_INTEGRITY_INVALID")
|
|
||||||
if saga.get("state") != "APPROVED":
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_STATE_INVALID")
|
|
||||||
if candidate.get("candidate_id") != req.candidate_id:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_MISMATCH")
|
|
||||||
if candidate.get("state") not in {"READY_FOR_APPROVAL", "APPROVED"}:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_STATE_INVALID")
|
|
||||||
if candidate.get("approval_state") not in {None, "PENDING"}:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_APPROVAL_STATE_INVALID")
|
|
||||||
# ReleaseCandidate v1 durable records do not persist side_effects_executed.
|
|
||||||
# Compatibility rule is intentionally fail-closed:
|
|
||||||
# - an absent key is accepted only together with the independent Saga
|
|
||||||
# invariant below that the accepted Owner-approval event is the final
|
|
||||||
# Saga event;
|
|
||||||
# - if a future durable schema persists the key, it must be exactly False;
|
|
||||||
# - explicit True/None/number/string values are rejected.
|
|
||||||
if "side_effects_executed" in candidate and candidate.get("side_effects_executed") is not False:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SIDE_EFFECTS_ALREADY_EXECUTED")
|
|
||||||
|
|
||||||
checks = {
|
|
||||||
"source": candidate.get("source_commit") == req.expected_source_commit,
|
|
||||||
"tree": candidate.get("tree_oid") == req.expected_tree_oid,
|
|
||||||
"artifact": candidate.get("artifact_sha256") == req.expected_artifact_sha256,
|
|
||||||
"manifest": candidate.get("manifest_sha256") == req.expected_manifest_sha256,
|
|
||||||
"approval_fingerprint": approval.get("fingerprint") == req.expected_approval_fingerprint,
|
|
||||||
"approval_source": approval.get("source_commit") == req.expected_source_commit,
|
|
||||||
"approval_tree": approval.get("tree_oid") == req.expected_tree_oid,
|
|
||||||
"approval_artifact": approval.get("artifact_sha256") == req.expected_artifact_sha256,
|
|
||||||
"approval_manifest": approval.get("manifest_sha256") == req.expected_manifest_sha256,
|
|
||||||
"approval_release_plan": approval.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
|
||||||
"approval_policy": approval.get("policy_sha256") == req.expected_policy_sha256,
|
|
||||||
"acceptance": acceptance.get("status") == "ACCEPTED",
|
|
||||||
"acceptance_release_plan": acceptance.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
|
||||||
"plan_sha": (plan.get("sha256") or plan.get("release_plan_sha256")) == req.expected_release_plan_sha256,
|
|
||||||
"plan_policy": plan.get("policy_sha256") == req.expected_policy_sha256,
|
|
||||||
}
|
|
||||||
bad = sorted(k for k, ok in checks.items() if not ok)
|
|
||||||
if bad:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_BINDING_MISMATCH", ",".join(bad))
|
|
||||||
|
|
||||||
if now <= _time(approval.get("expires_at"), "APPROVAL_REOPEN_APPROVAL_EXPIRY_INVALID"):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_APPROVAL_NOT_EXPIRED")
|
|
||||||
|
|
||||||
events = [e for e in saga.get("events") or [] if isinstance(e, Mapping)]
|
|
||||||
accepted = None
|
|
||||||
for i, event in enumerate(events):
|
|
||||||
if event.get("event") == "signed_owner_approval_accepted" \
|
|
||||||
and _details(event).get("approval_fingerprint") == req.expected_approval_fingerprint:
|
|
||||||
accepted = i
|
|
||||||
if accepted is None:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACCEPTED_EVENT_MISSING")
|
|
||||||
if accepted != len(events) - 1:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_ALREADY_ADVANCED")
|
|
||||||
|
|
||||||
|
|
||||||
def reopen_owner_approval(service: Any, raw: Mapping[str, Any], *,
|
|
||||||
state_root: Path | None = None,
|
|
||||||
now: datetime | None = None) -> dict[str, Any]:
|
|
||||||
req = ReopenRequest.from_mapping(raw)
|
|
||||||
raw_root = state_root or os.environ.get("AIOS_SILENT_RELEASE_STATE_ROOT", "")
|
|
||||||
if not str(raw_root):
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_ROOT_MISSING")
|
|
||||||
root = Path(raw_root).expanduser().resolve()
|
|
||||||
paths = _paths(root, req.candidate_id)
|
|
||||||
cycle = _cycle_id(req)
|
|
||||||
history = _history(root, req.candidate_id, req.expected_approval_fingerprint)
|
|
||||||
saga = _secure_json(paths["saga"])
|
|
||||||
|
|
||||||
prior = _cycle_event(saga, cycle)
|
|
||||||
if prior is not None:
|
|
||||||
if saga.get("state") != "READY_FOR_APPROVAL":
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_CYCLE_ALREADY_CONSUMED")
|
|
||||||
if not history.is_file():
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_MISSING")
|
|
||||||
if paths["approval"].exists():
|
|
||||||
active = _secure_json(paths["approval"])
|
|
||||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
|
||||||
paths["approval"].unlink()
|
|
||||||
_fsync_dir(paths["approval"].parent)
|
|
||||||
return {
|
|
||||||
"schema": "aios.owner-approval-reopen-result.v1",
|
|
||||||
"status": "IDEMPOTENT",
|
|
||||||
"candidate_id": req.candidate_id,
|
|
||||||
"approval_cycle_id": cycle,
|
|
||||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
|
||||||
"saga_state": "READY_FOR_APPROVAL",
|
|
||||||
"history_ref": str(history.relative_to(root)),
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate = _secure_json(paths["candidate"])
|
|
||||||
plan = _secure_json(paths["plan"])
|
|
||||||
acceptance = _secure_json(paths["acceptance"])
|
|
||||||
approval = _secure_json(paths["approval"])
|
|
||||||
_validate(req, candidate, saga, plan, acceptance, approval,
|
|
||||||
(now or datetime.now(timezone.utc)).astimezone(timezone.utc))
|
|
||||||
|
|
||||||
_archive(paths["approval"], history)
|
|
||||||
event_details = {
|
|
||||||
"schema": "aios.owner-approval-reopen-event.v1",
|
|
||||||
"approval_cycle_id": cycle,
|
|
||||||
"supersedes_approval_fingerprint": req.expected_approval_fingerprint,
|
|
||||||
"expired_at": approval.get("expires_at"),
|
|
||||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
|
||||||
"source_commit": req.expected_source_commit,
|
|
||||||
"tree_oid": req.expected_tree_oid,
|
|
||||||
"artifact_sha256": req.expected_artifact_sha256,
|
|
||||||
"reason": req.reason.strip(),
|
|
||||||
"idempotency_digest": _sha(req.idempotency_key.encode()),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
service.sagas.compare_and_set(
|
|
||||||
req.candidate_id,
|
|
||||||
expected_revision=saga.get("revision"),
|
|
||||||
expected_states={_state("APPROVED")},
|
|
||||||
target=_state("READY_FOR_APPROVAL"),
|
|
||||||
event=EVENT_NAME,
|
|
||||||
details=event_details,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_CAS_FAILED") from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
active = _secure_json(paths["approval"])
|
|
||||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
|
||||||
paths["approval"].unlink()
|
|
||||||
_fsync_dir(paths["approval"].parent)
|
|
||||||
except ApprovalReopenError:
|
|
||||||
raise
|
|
||||||
except OSError as exc:
|
|
||||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_REMOVE_FAILED") from exc
|
|
||||||
|
|
||||||
return {
|
|
||||||
"schema": "aios.owner-approval-reopen-result.v1",
|
|
||||||
"status": "REOPENED",
|
|
||||||
"candidate_id": req.candidate_id,
|
|
||||||
"approval_cycle_id": cycle,
|
|
||||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
|
||||||
"saga_state": "READY_FOR_APPROVAL",
|
|
||||||
"history_ref": str(history.relative_to(root)),
|
|
||||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
|
||||||
"source_commit": req.expected_source_commit,
|
|
||||||
"tree_oid": req.expected_tree_oid,
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Governed, fail-closed projection of verified release evidence into Builder storage.
|
|
||||||
|
|
||||||
This module never invents candidate/release evidence. A separate governed release
|
|
||||||
producer must first write a complete, root-owned evidence closure below
|
|
||||||
``source_root/<version>``. The materializer re-validates that closure with the
|
|
||||||
same ``ServerOwnedEvidenceSource``/``DeterministicEvidenceCollector`` code used by
|
|
||||||
Trusted Candidate Builder and atomically projects the exact bytes into the
|
|
||||||
Builder-owned evidence root.
|
|
||||||
|
|
||||||
Readiness is deliberately orthogonal: an empty Builder evidence root does not make
|
|
||||||
Builder infrastructure globally unavailable, but every BuildTrustedCandidateV1
|
|
||||||
request still requires a valid materialized closure.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from dataclasses import replace
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
from typing import Any, Callable, Mapping
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from aios_trusted_candidate_builder import (
|
|
||||||
BuilderError,
|
|
||||||
DeterministicEvidenceCollector,
|
|
||||||
ServerOwnedEvidenceSource,
|
|
||||||
TrustedCandidateBuilderConfig,
|
|
||||||
load_builder_policy,
|
|
||||||
)
|
|
||||||
from release_migration_plan import FileMigrationPlanResolver
|
|
||||||
from release_source_admission import (
|
|
||||||
ReleaseSourceAdmissionStore,
|
|
||||||
open_source_admission_store,
|
|
||||||
source_admission_policy_sha256,
|
|
||||||
)
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
MATERIALIZATION_SCHEMA = "aios.trusted-builder-evidence-materialization.v1"
|
|
||||||
SOURCE_ADMISSION_CREDENTIAL = "source_admission_hmac"
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceMaterializationError(RuntimeError):
|
|
||||||
"""Stable, non-sensitive materialization failure."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
ensure_ascii=True,
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _file_sha256(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with Path(path).open("rb") as handle:
|
|
||||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_oid(value: object, name: str) -> str:
|
|
||||||
normalized = str(value or "").strip().lower()
|
|
||||||
if (
|
|
||||||
len(normalized) != 40
|
|
||||||
or any(char not in "0123456789abcdef" for char in normalized)
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_identifier(value: object, name: str) -> str:
|
|
||||||
normalized = str(value or "").strip()
|
|
||||||
if (
|
|
||||||
not normalized
|
|
||||||
or len(normalized) > 255
|
|
||||||
or normalized[0] in ".-"
|
|
||||||
or any(
|
|
||||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
|
||||||
for char in normalized
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_directory(path: Path, *, expected_owner_uid: int | None) -> Path:
|
|
||||||
candidate = Path(path)
|
|
||||||
try:
|
|
||||||
metadata = candidate.stat(follow_symlinks=False)
|
|
||||||
resolved = candidate.resolve(strict=True)
|
|
||||||
except OSError as exc:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_UNAVAILABLE") from exc
|
|
||||||
if candidate.is_symlink() or not stat.S_ISDIR(metadata.st_mode):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_INVALID")
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_PERMISSIONS_INVALID")
|
|
||||||
if (
|
|
||||||
os.name != "nt"
|
|
||||||
and expected_owner_uid is not None
|
|
||||||
and metadata.st_uid != expected_owner_uid
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_OWNER_INVALID")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_regular(
|
|
||||||
root: Path,
|
|
||||||
relative: str,
|
|
||||||
*,
|
|
||||||
expected_owner_uid: int | None,
|
|
||||||
) -> Path:
|
|
||||||
root = _safe_directory(root, expected_owner_uid=expected_owner_uid)
|
|
||||||
relative_path = Path(relative)
|
|
||||||
if relative_path.is_absolute() or any(
|
|
||||||
part in {"", ".", ".."} for part in relative_path.parts
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_PATH_INVALID")
|
|
||||||
current = root
|
|
||||||
for part in relative_path.parts:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_SYMLINK_DENIED")
|
|
||||||
try:
|
|
||||||
resolved = (root / relative_path).resolve(strict=True)
|
|
||||||
resolved.relative_to(root)
|
|
||||||
metadata = resolved.stat(follow_symlinks=False)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_UNAVAILABLE") from exc
|
|
||||||
if not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_SPECIAL_FILE_DENIED")
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_PERMISSIONS_INVALID")
|
|
||||||
if (
|
|
||||||
os.name != "nt"
|
|
||||||
and expected_owner_uid is not None
|
|
||||||
and metadata.st_uid != expected_owner_uid
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_OWNER_INVALID")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: Path) -> None:
|
|
||||||
if os.name == "nt":
|
|
||||||
return
|
|
||||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
||||||
try:
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_exact(source: Path, target: Path, *, mode: int = 0o640) -> None:
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
||||||
descriptor = os.open(
|
|
||||||
target,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
| getattr(os, "O_BINARY", 0),
|
|
||||||
mode,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with source.open("rb") as src:
|
|
||||||
while True:
|
|
||||||
chunk = src.read(1024 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
os.write(descriptor, chunk)
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
os.chmod(target, mode)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json_exclusive(path: Path, payload: Mapping[str, Any]) -> str:
|
|
||||||
raw = _canonical_json(payload) + b"\n"
|
|
||||||
descriptor = os.open(
|
|
||||||
path,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
| getattr(os, "O_BINARY", 0),
|
|
||||||
0o640,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
os.write(descriptor, raw)
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
os.chmod(path, 0o640)
|
|
||||||
return hashlib.sha256(raw).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class TrustedBuilderEvidenceMaterializer:
|
|
||||||
"""Verify a governed evidence closure and atomically project exact bytes."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
builder_policy: Path,
|
|
||||||
authority_git_dir: Path,
|
|
||||||
source_root: Path,
|
|
||||||
output_root: Path,
|
|
||||||
source_admission_root: Path | None = None,
|
|
||||||
source_admission_hmac: bytes | None = None,
|
|
||||||
migration_plan_root: Path | None = None,
|
|
||||||
migration_profile_root: Path | None = None,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
git_runner: Callable[..., Any] = subprocess.run,
|
|
||||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None,
|
|
||||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.builder_policy = Path(builder_policy)
|
|
||||||
self.authority_git_dir = Path(authority_git_dir)
|
|
||||||
self.source_root = Path(source_root)
|
|
||||||
self.output_root = Path(output_root)
|
|
||||||
self.expected_owner_uid = expected_owner_uid
|
|
||||||
self.git_runner = git_runner
|
|
||||||
|
|
||||||
try:
|
|
||||||
policy = load_builder_policy(self.builder_policy)
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
|
||||||
except BuilderError as exc:
|
|
||||||
raise EvidenceMaterializationError(str(exc)) from exc
|
|
||||||
|
|
||||||
self.source_admission_store: ReleaseSourceAdmissionStore | None = None
|
|
||||||
if source_admission_resolver is not None:
|
|
||||||
config = replace(
|
|
||||||
config,
|
|
||||||
source_admission_resolver=source_admission_resolver,
|
|
||||||
)
|
|
||||||
elif source_admission_root is not None:
|
|
||||||
if not source_admission_hmac or len(source_admission_hmac) < 32:
|
|
||||||
raise EvidenceMaterializationError("SOURCE_ADMISSION_HMAC_UNAVAILABLE")
|
|
||||||
self.source_admission_store = open_source_admission_store(
|
|
||||||
Path(source_admission_root),
|
|
||||||
repository=config.repository,
|
|
||||||
policy=self.builder_policy,
|
|
||||||
hmac_key=source_admission_hmac,
|
|
||||||
expected_owner_uid=expected_owner_uid,
|
|
||||||
writable=False,
|
|
||||||
)
|
|
||||||
config = replace(
|
|
||||||
config,
|
|
||||||
source_admission_resolver=self.source_admission_store.resolve_recipe,
|
|
||||||
)
|
|
||||||
|
|
||||||
if migration_plan_resolver is not None:
|
|
||||||
config = replace(config, migration_plan_resolver=migration_plan_resolver)
|
|
||||||
elif migration_plan_root is not None or migration_profile_root is not None:
|
|
||||||
if migration_plan_root is None or migration_profile_root is None:
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"MIGRATION_PLAN_CONFIGURATION_INVALID"
|
|
||||||
)
|
|
||||||
resolver = FileMigrationPlanResolver(
|
|
||||||
Path(migration_plan_root),
|
|
||||||
profile_root=Path(migration_profile_root),
|
|
||||||
expected_owner_uid=expected_owner_uid,
|
|
||||||
)
|
|
||||||
config = replace(config, migration_plan_resolver=resolver.resolve)
|
|
||||||
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@property
|
|
||||||
def policy_file_sha256(self) -> str:
|
|
||||||
return source_admission_policy_sha256(self.builder_policy)
|
|
||||||
|
|
||||||
def _collector_for(self, evidence_root: Path) -> DeterministicEvidenceCollector:
|
|
||||||
source = ServerOwnedEvidenceSource(
|
|
||||||
self.config,
|
|
||||||
self.authority_git_dir,
|
|
||||||
evidence_root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
git_runner=self.git_runner,
|
|
||||||
)
|
|
||||||
return DeterministicEvidenceCollector(
|
|
||||||
self.config,
|
|
||||||
evidence_source=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _relative_files(self, version: str) -> tuple[str, ...]:
|
|
||||||
relative = [f"{version}/receipt.json"]
|
|
||||||
relative.extend(
|
|
||||||
template.format(version=version)
|
|
||||||
for template in self.config.evidence_layout.values()
|
|
||||||
)
|
|
||||||
return tuple(dict.fromkeys(relative))
|
|
||||||
|
|
||||||
def _validate_existing(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
version: str,
|
|
||||||
candidate_id: str,
|
|
||||||
source_commit: str,
|
|
||||||
expected_bundle: Mapping[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
observed = self._collector_for(self.output_root).collect(
|
|
||||||
version,
|
|
||||||
self.config.resolve_recipe(version, candidate_id, source_commit),
|
|
||||||
)
|
|
||||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
|
||||||
if _canonical_json(dict(observed)) != _canonical_json(dict(expected_bundle)):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
|
||||||
record_path = self.output_root / version / "materialization.json"
|
|
||||||
if not record_path.is_file():
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
|
||||||
try:
|
|
||||||
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
|
||||||
if not isinstance(record, Mapping):
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
|
||||||
return dict(record)
|
|
||||||
|
|
||||||
def materialize(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
version: str,
|
|
||||||
candidate_id: str,
|
|
||||||
source_commit: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
version = validate_release_version(version)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise EvidenceMaterializationError("VERSION_INVALID") from exc
|
|
||||||
candidate_id = _validate_identifier(candidate_id, "candidate_id")
|
|
||||||
source_commit = _validate_oid(source_commit, "source_commit")
|
|
||||||
|
|
||||||
_safe_directory(
|
|
||||||
self.source_root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
)
|
|
||||||
_safe_directory(
|
|
||||||
self.output_root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
recipe = self.config.resolve_recipe(
|
|
||||||
version,
|
|
||||||
candidate_id,
|
|
||||||
source_commit,
|
|
||||||
)
|
|
||||||
bundle = self._collector_for(self.source_root).collect(
|
|
||||||
version,
|
|
||||||
recipe,
|
|
||||||
)
|
|
||||||
except BuilderError as exc:
|
|
||||||
raise EvidenceMaterializationError(str(exc)) from exc
|
|
||||||
except (RuntimeError, ValueError) as exc:
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_SOURCE_INVALID") from exc
|
|
||||||
|
|
||||||
admission_id = ""
|
|
||||||
if self.source_admission_store is not None:
|
|
||||||
try:
|
|
||||||
admission_id = str(
|
|
||||||
self.source_admission_store.lookup_source(source_commit)[
|
|
||||||
"admission_id"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"SOURCE_ADMISSION_INVALID"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
receipt_path = _safe_regular(
|
|
||||||
self.source_root,
|
|
||||||
f"{version}/receipt.json",
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
)
|
|
||||||
receipt_sha256 = _file_sha256(receipt_path)
|
|
||||||
release_plan = dict(bundle["release_plan"])
|
|
||||||
migration = release_plan.get("migration")
|
|
||||||
migration_sha256 = ""
|
|
||||||
if isinstance(migration, Mapping):
|
|
||||||
migration_sha256 = str(migration.get("sha256") or "")
|
|
||||||
|
|
||||||
materialization_payload = {
|
|
||||||
"schema": MATERIALIZATION_SCHEMA,
|
|
||||||
"repository": self.config.repository,
|
|
||||||
"version": version,
|
|
||||||
"candidate_id": candidate_id,
|
|
||||||
"source_commit": source_commit,
|
|
||||||
"tree_oid": str(bundle["candidate"]["tree_oid"]),
|
|
||||||
"source_admission_id": admission_id,
|
|
||||||
"builder_policy_file_sha256": self.policy_file_sha256,
|
|
||||||
"receipt_sha256": receipt_sha256,
|
|
||||||
"migration_sha256": migration_sha256,
|
|
||||||
"evidence_digests": dict(bundle["evidence_digests"]),
|
|
||||||
}
|
|
||||||
materialization_id = hashlib.sha256(
|
|
||||||
_canonical_json(materialization_payload)
|
|
||||||
).hexdigest()
|
|
||||||
materialization_payload["materialization_id"] = (
|
|
||||||
"evidence-materialization-" + materialization_id[:24]
|
|
||||||
)
|
|
||||||
|
|
||||||
target = self.output_root / version
|
|
||||||
if target.exists() or target.is_symlink():
|
|
||||||
existing = self._validate_existing(
|
|
||||||
version=version,
|
|
||||||
candidate_id=candidate_id,
|
|
||||||
source_commit=source_commit,
|
|
||||||
expected_bundle=bundle,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
existing.get("materialization_id")
|
|
||||||
!= materialization_payload["materialization_id"]
|
|
||||||
):
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"EVIDENCE_DESTINATION_CONFLICT"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
**materialization_payload,
|
|
||||||
"status": "EVIDENCE_MATERIALIZED",
|
|
||||||
"idempotent": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
temporary = self.output_root / (
|
|
||||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
||||||
)
|
|
||||||
if temporary.exists() or temporary.is_symlink():
|
|
||||||
raise EvidenceMaterializationError("EVIDENCE_TEMP_CONFLICT")
|
|
||||||
temporary.mkdir(mode=0o750)
|
|
||||||
try:
|
|
||||||
for relative in self._relative_files(version):
|
|
||||||
source = _safe_regular(
|
|
||||||
self.source_root,
|
|
||||||
relative,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
)
|
|
||||||
destination_relative = Path(relative).relative_to(version)
|
|
||||||
destination = temporary / destination_relative
|
|
||||||
_copy_exact(source, destination)
|
|
||||||
if _file_sha256(destination) != _file_sha256(source):
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"EVIDENCE_COPY_DIGEST_MISMATCH"
|
|
||||||
)
|
|
||||||
materialization_sha256 = _write_json_exclusive(
|
|
||||||
temporary / "materialization.json",
|
|
||||||
materialization_payload,
|
|
||||||
)
|
|
||||||
_fsync_directory(temporary)
|
|
||||||
os.replace(temporary, target)
|
|
||||||
_fsync_directory(self.output_root)
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(temporary, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
observed = self._collector_for(self.output_root).collect(
|
|
||||||
version,
|
|
||||||
recipe,
|
|
||||||
)
|
|
||||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
|
||||||
) from exc
|
|
||||||
if _canonical_json(dict(observed)) != _canonical_json(dict(bundle)):
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
**materialization_payload,
|
|
||||||
"materialization_sha256": materialization_sha256,
|
|
||||||
"status": "EVIDENCE_MATERIALIZED",
|
|
||||||
"idempotent": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _credential(name: str) -> bytes:
|
|
||||||
root = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
|
||||||
if not root:
|
|
||||||
raise EvidenceMaterializationError("CREDENTIALS_DIRECTORY_REQUIRED")
|
|
||||||
path = Path(root) / name
|
|
||||||
try:
|
|
||||||
value = path.read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise EvidenceMaterializationError(
|
|
||||||
f"{name.upper()}_UNAVAILABLE"
|
|
||||||
) from exc
|
|
||||||
if len(value) < 32:
|
|
||||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("operation", choices=("materialize",))
|
|
||||||
parser.add_argument("--version", required=True)
|
|
||||||
parser.add_argument("--candidate-id", required=True)
|
|
||||||
parser.add_argument("--source-commit", required=True)
|
|
||||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
|
||||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
|
||||||
parser.add_argument("--source-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--output-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--migration-plan-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--migration-profile-root", required=True, type=Path)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = TrustedBuilderEvidenceMaterializer(
|
|
||||||
builder_policy=arguments.builder_policy,
|
|
||||||
authority_git_dir=arguments.authority_git_dir,
|
|
||||||
source_root=arguments.source_root,
|
|
||||||
output_root=arguments.output_root,
|
|
||||||
source_admission_root=arguments.source_admission_root,
|
|
||||||
source_admission_hmac=_credential(SOURCE_ADMISSION_CREDENTIAL),
|
|
||||||
migration_plan_root=arguments.migration_plan_root,
|
|
||||||
migration_profile_root=arguments.migration_profile_root,
|
|
||||||
).materialize(
|
|
||||||
version=arguments.version,
|
|
||||||
candidate_id=arguments.candidate_id,
|
|
||||||
source_commit=arguments.source_commit,
|
|
||||||
)
|
|
||||||
except EvidenceMaterializationError as exc:
|
|
||||||
raise SystemExit(str(exc)) from exc
|
|
||||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -1,989 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed cross-code-line release migration planning.
|
|
||||||
|
|
||||||
The migration plan is data, not a shell script. It binds an admitted source to
|
|
||||||
the exact currently observed staging code lines, the compatible state contract,
|
|
||||||
and the rollback/verification contract. The plan can then be embedded in the
|
|
||||||
existing signed ReleasePlan so Owner approval covers migration semantics without
|
|
||||||
creating a second release authority.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping, Sequence
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{2,255}$")
|
|
||||||
PLAN_SCHEMA = "aios.release-migration-plan.v1"
|
|
||||||
REQUIREMENT_SCHEMA = "aios.release-migration-requirement.v1"
|
|
||||||
PROFILE_SCHEMA = "aios.release-migration-profile.v1"
|
|
||||||
ALLOWED_STATE_MODES = frozenset({"REUSE_IN_PLACE_WITH_SNAPSHOT", "PRESERVE_UNUSED"})
|
|
||||||
REQUIRED_PRECONDITIONS = (
|
|
||||||
"writer_disabled",
|
|
||||||
"task_quiescent",
|
|
||||||
"state_snapshot_verified",
|
|
||||||
"credentials_ready",
|
|
||||||
"rollback_ready",
|
|
||||||
)
|
|
||||||
REQUIRED_VERIFICATION_CONTRACTS = (
|
|
||||||
"runtime_identity_exact",
|
|
||||||
"task_principal_no_bearer_e2e",
|
|
||||||
"restart_recovery",
|
|
||||||
"credential_projection",
|
|
||||||
"release_consistency_no_unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationPlanError(RuntimeError):
|
|
||||||
"""Migration evidence is missing, malformed, stale, or unsafe."""
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationPlanRequired(MigrationPlanError):
|
|
||||||
"""A cross-code-line release cannot proceed without an exact migration plan."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: object) -> bytes:
|
|
||||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(value: object, label: str) -> str:
|
|
||||||
text = str(value or "").lower()
|
|
||||||
if not SHA_RE.fullmatch(text):
|
|
||||||
raise MigrationPlanError(f"{label}_invalid")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: object, label: str) -> str:
|
|
||||||
text = str(value or "").lower()
|
|
||||||
if not OID_RE.fullmatch(text):
|
|
||||||
raise MigrationPlanError(f"{label}_invalid")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _ident(value: object, label: str) -> str:
|
|
||||||
text = str(value or "")
|
|
||||||
if not IDENT_RE.fullmatch(text):
|
|
||||||
raise MigrationPlanError(f"{label}_invalid")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _secure_json(path: Path, *, expected_owner_uid: int | None = 0) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
metadata = path.lstat()
|
|
||||||
except OSError as exc:
|
|
||||||
raise MigrationPlanError(f"secure_json_unavailable:{path}") from exc
|
|
||||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise MigrationPlanError("secure_json_regular_file_required")
|
|
||||||
if expected_owner_uid is not None and os.name != "nt" and metadata.st_uid != expected_owner_uid:
|
|
||||||
raise MigrationPlanError("secure_json_owner_invalid")
|
|
||||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
raise MigrationPlanError("secure_json_mode_invalid")
|
|
||||||
try:
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise MigrationPlanError("secure_json_invalid") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise MigrationPlanError("secure_json_object_required")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _durable_json(path: Path, value: Mapping[str, Any], *, mode: int = 0o640) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
encoded = _canonical_json(dict(value))
|
|
||||||
if path.exists():
|
|
||||||
current = path.read_bytes()
|
|
||||||
if current != encoded:
|
|
||||||
raise MigrationPlanError("migration_record_conflict")
|
|
||||||
return
|
|
||||||
temporary = path.with_name(path.name + ".tmp")
|
|
||||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "wb") as handle:
|
|
||||||
handle.write(encoded)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(temporary, path)
|
|
||||||
if os.name != "nt":
|
|
||||||
os.chmod(path, mode)
|
|
||||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
finally:
|
|
||||||
os.close(directory_fd)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
temporary.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_files(root: Path, files: Sequence[str]) -> str:
|
|
||||||
payload: dict[str, str] = {}
|
|
||||||
resolved_root = root.resolve(strict=True)
|
|
||||||
for relative in files:
|
|
||||||
rel = Path(relative)
|
|
||||||
if rel.is_absolute() or ".." in rel.parts:
|
|
||||||
raise MigrationPlanError("interface_path_invalid")
|
|
||||||
path = (resolved_root / rel).resolve(strict=True)
|
|
||||||
try:
|
|
||||||
path.relative_to(resolved_root)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise MigrationPlanError("interface_path_escape") from exc
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise MigrationPlanError("interface_file_invalid")
|
|
||||||
payload[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _git_blob(git_dir: Path, source_oid: str, relative: str) -> bytes:
|
|
||||||
rel = Path(relative)
|
|
||||||
if rel.is_absolute() or ".." in rel.parts:
|
|
||||||
raise MigrationPlanError("target_interface_path_invalid")
|
|
||||||
completed = subprocess.run(
|
|
||||||
["git", f"--git-dir={git_dir}", "show", f"{source_oid}:{relative}"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=30,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise MigrationPlanError("target_interface_unavailable")
|
|
||||||
return bytes(completed.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_git_files(git_dir: Path, source_oid: str, files: Sequence[str]) -> str:
|
|
||||||
payload = {
|
|
||||||
relative: hashlib.sha256(_git_blob(git_dir, source_oid, relative)).hexdigest()
|
|
||||||
for relative in files
|
|
||||||
}
|
|
||||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _systemd_properties(service: str) -> dict[str, Any]:
|
|
||||||
_ident(service, "service")
|
|
||||||
completed = subprocess.run(
|
|
||||||
[
|
|
||||||
"systemctl",
|
|
||||||
"show",
|
|
||||||
service,
|
|
||||||
"--property=WorkingDirectory",
|
|
||||||
"--property=Environment",
|
|
||||||
"--property=FragmentPath",
|
|
||||||
"--property=ActiveState",
|
|
||||||
],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=20,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise MigrationPlanError("systemd_observation_unavailable")
|
|
||||||
result: dict[str, Any] = {}
|
|
||||||
for line in completed.stdout.splitlines():
|
|
||||||
if "=" in line:
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
result[key] = value
|
|
||||||
if result.get("ActiveState") != "active":
|
|
||||||
raise MigrationPlanError("component_not_active")
|
|
||||||
environment: dict[str, str] = {}
|
|
||||||
for token in str(result.get("Environment") or "").split():
|
|
||||||
if "=" in token:
|
|
||||||
key, value = token.split("=", 1)
|
|
||||||
environment[key] = value.strip('"')
|
|
||||||
result["environment"] = environment
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_schema(connection: sqlite3.Connection) -> dict[str, Any]:
|
|
||||||
tables = [
|
|
||||||
row[0]
|
|
||||||
for row in connection.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
result: dict[str, Any] = {}
|
|
||||||
for table in tables:
|
|
||||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
|
|
||||||
raise MigrationPlanError("sqlite_table_name_invalid")
|
|
||||||
columns = []
|
|
||||||
for row in connection.execute(f"PRAGMA table_info({table})"):
|
|
||||||
columns.append(
|
|
||||||
{
|
|
||||||
"cid": int(row[0]),
|
|
||||||
"name": str(row[1]),
|
|
||||||
"type": str(row[2]),
|
|
||||||
"notnull": int(row[3]),
|
|
||||||
"default": row[4],
|
|
||||||
"pk": int(row[5]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result[table] = {
|
|
||||||
"columns": columns,
|
|
||||||
"row_count": int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]),
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _schema_digest(schema: Mapping[str, Any]) -> str:
|
|
||||||
shape = {
|
|
||||||
table: {"columns": value["columns"]}
|
|
||||||
for table, value in sorted(schema.items())
|
|
||||||
}
|
|
||||||
return hashlib.sha256(_canonical_json(shape)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_additive_sqlite(before: Mapping[str, Any], after: Mapping[str, Any]) -> None:
|
|
||||||
for table, old in before.items():
|
|
||||||
if table not in after:
|
|
||||||
raise MigrationPlanError("sqlite_table_removed")
|
|
||||||
old_columns = {column["name"]: column for column in old["columns"]}
|
|
||||||
new_columns = {column["name"]: column for column in after[table]["columns"]}
|
|
||||||
for name, column in old_columns.items():
|
|
||||||
if new_columns.get(name) != column:
|
|
||||||
raise MigrationPlanError("sqlite_column_changed")
|
|
||||||
for name, column in new_columns.items():
|
|
||||||
if name in old_columns:
|
|
||||||
continue
|
|
||||||
# A new NOT NULL column is backward compatible only with a default.
|
|
||||||
if column["notnull"] and column["default"] is None:
|
|
||||||
raise MigrationPlanError("sqlite_new_column_unsafe")
|
|
||||||
if int(after[table]["row_count"]) != int(old["row_count"]):
|
|
||||||
raise MigrationPlanError("sqlite_row_count_changed_during_probe")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeComponentBinding:
|
|
||||||
component_id: str
|
|
||||||
service: str
|
|
||||||
code_line_id: str
|
|
||||||
source_oid: str
|
|
||||||
artifact_sha256: str | None
|
|
||||||
interface_sha256: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_ident(self.component_id, "component_id")
|
|
||||||
_ident(self.service, "service")
|
|
||||||
_ident(self.code_line_id, "code_line_id")
|
|
||||||
_oid(self.source_oid, "source_oid")
|
|
||||||
if self.artifact_sha256 is not None:
|
|
||||||
_sha(self.artifact_sha256, "artifact_sha256")
|
|
||||||
_sha(self.interface_sha256, "interface_sha256")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: Mapping[str, Any]) -> "RuntimeComponentBinding":
|
|
||||||
return cls(**dict(value))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StateMigrationContract:
|
|
||||||
state_id: str
|
|
||||||
state_ref: str
|
|
||||||
mode: str
|
|
||||||
before_schema_sha256: str
|
|
||||||
after_schema_sha256: str
|
|
||||||
snapshot_name: str
|
|
||||||
snapshot_sha256: str
|
|
||||||
snapshot_required: bool
|
|
||||||
rollback_compatible: bool
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_ident(self.state_id, "state_id")
|
|
||||||
_ident(self.state_ref, "state_ref")
|
|
||||||
if self.mode not in ALLOWED_STATE_MODES:
|
|
||||||
raise MigrationPlanError("state_mode_invalid")
|
|
||||||
_sha(self.before_schema_sha256, "before_schema_sha256")
|
|
||||||
_sha(self.after_schema_sha256, "after_schema_sha256")
|
|
||||||
_sha(self.snapshot_sha256, "snapshot_sha256")
|
|
||||||
snapshot = Path(self.snapshot_name)
|
|
||||||
if (
|
|
||||||
snapshot.is_absolute()
|
|
||||||
or ".." in snapshot.parts
|
|
||||||
or not snapshot.parts
|
|
||||||
or str(snapshot).startswith(".")
|
|
||||||
):
|
|
||||||
raise MigrationPlanError("snapshot_name_invalid")
|
|
||||||
if self.snapshot_required is not True or self.rollback_compatible is not True:
|
|
||||||
raise MigrationPlanError("state_recovery_contract_invalid")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: Mapping[str, Any]) -> "StateMigrationContract":
|
|
||||||
return cls(**dict(value))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MigrationPlan:
|
|
||||||
migration_id: str
|
|
||||||
version: str
|
|
||||||
candidate_id: str
|
|
||||||
repository_identity: str
|
|
||||||
source_admission_id: str
|
|
||||||
source_commit: str
|
|
||||||
tree_oid: str
|
|
||||||
profile_id: str
|
|
||||||
profile_sha256: str
|
|
||||||
from_components: tuple[RuntimeComponentBinding, ...]
|
|
||||||
to_components: tuple[RuntimeComponentBinding, ...]
|
|
||||||
state_contracts: tuple[StateMigrationContract, ...]
|
|
||||||
activation_schema: str
|
|
||||||
activation_sha256: str
|
|
||||||
rollback_window_seconds: int
|
|
||||||
preconditions: tuple[str, ...] = REQUIRED_PRECONDITIONS
|
|
||||||
verification_contracts: tuple[str, ...] = REQUIRED_VERIFICATION_CONTRACTS
|
|
||||||
runtime_switch_capability: str = "runtime_switch"
|
|
||||||
rollback_contract: str = "restore_previous_runtime_and_state_snapshot.v1"
|
|
||||||
forward_recovery_contract: str = "forward_recovery_after_verified_rollback_block.v1"
|
|
||||||
writer_expected: bool = False
|
|
||||||
direct_db_allowed: bool = False
|
|
||||||
direct_runtime_overwrite_allowed: bool = False
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_ident(self.migration_id, "migration_id")
|
|
||||||
_ident(self.version, "version")
|
|
||||||
_ident(self.candidate_id, "candidate_id")
|
|
||||||
if self.repository_identity != "aios/aios":
|
|
||||||
raise MigrationPlanError("repository_identity_invalid")
|
|
||||||
_ident(self.source_admission_id, "source_admission_id")
|
|
||||||
_oid(self.source_commit, "source_commit")
|
|
||||||
_oid(self.tree_oid, "tree_oid")
|
|
||||||
_ident(self.profile_id, "profile_id")
|
|
||||||
_sha(self.profile_sha256, "profile_sha256")
|
|
||||||
_ident(self.activation_schema, "activation_schema")
|
|
||||||
_sha(self.activation_sha256, "activation_sha256")
|
|
||||||
if not 60 <= int(self.rollback_window_seconds) <= 86400:
|
|
||||||
raise MigrationPlanError("rollback_window_invalid")
|
|
||||||
if tuple(self.preconditions) != REQUIRED_PRECONDITIONS:
|
|
||||||
raise MigrationPlanError("migration_preconditions_invalid")
|
|
||||||
if tuple(self.verification_contracts) != REQUIRED_VERIFICATION_CONTRACTS:
|
|
||||||
raise MigrationPlanError("migration_verification_contracts_invalid")
|
|
||||||
if self.runtime_switch_capability != "runtime_switch":
|
|
||||||
raise MigrationPlanError("migration_capability_invalid")
|
|
||||||
if self.writer_expected or self.direct_db_allowed or self.direct_runtime_overwrite_allowed:
|
|
||||||
raise MigrationPlanError("migration_unsafe_authority")
|
|
||||||
if not self.from_components or not self.to_components or not self.state_contracts:
|
|
||||||
raise MigrationPlanError("migration_contract_incomplete")
|
|
||||||
from_ids = {item.component_id for item in self.from_components}
|
|
||||||
to_ids = {item.component_id for item in self.to_components}
|
|
||||||
if from_ids != to_ids or len(from_ids) != len(self.from_components) or len(to_ids) != len(self.to_components):
|
|
||||||
raise MigrationPlanError("migration_component_binding_invalid")
|
|
||||||
if all(
|
|
||||||
next(item.code_line_id for item in self.from_components if item.component_id == target.component_id)
|
|
||||||
== target.code_line_id
|
|
||||||
for target in self.to_components
|
|
||||||
):
|
|
||||||
raise MigrationPlanError("migration_not_required")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": PLAN_SCHEMA,
|
|
||||||
"migration_id": self.migration_id,
|
|
||||||
"version": self.version,
|
|
||||||
"candidate_id": self.candidate_id,
|
|
||||||
"repository_identity": self.repository_identity,
|
|
||||||
"source_admission_id": self.source_admission_id,
|
|
||||||
"source_commit": self.source_commit,
|
|
||||||
"tree_oid": self.tree_oid,
|
|
||||||
"profile_id": self.profile_id,
|
|
||||||
"profile_sha256": self.profile_sha256,
|
|
||||||
"from_components": [item.to_dict() for item in self.from_components],
|
|
||||||
"to_components": [item.to_dict() for item in self.to_components],
|
|
||||||
"state_contracts": [item.to_dict() for item in self.state_contracts],
|
|
||||||
"activation_schema": self.activation_schema,
|
|
||||||
"activation_sha256": self.activation_sha256,
|
|
||||||
"rollback_window_seconds": self.rollback_window_seconds,
|
|
||||||
"preconditions": list(self.preconditions),
|
|
||||||
"verification_contracts": list(self.verification_contracts),
|
|
||||||
"runtime_switch_capability": self.runtime_switch_capability,
|
|
||||||
"rollback_contract": self.rollback_contract,
|
|
||||||
"forward_recovery_contract": self.forward_recovery_contract,
|
|
||||||
"writer_expected": self.writer_expected,
|
|
||||||
"direct_db_allowed": self.direct_db_allowed,
|
|
||||||
"direct_runtime_overwrite_allowed": self.direct_runtime_overwrite_allowed,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sha256(self) -> str:
|
|
||||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {**self.canonical_payload, "sha256": self.sha256}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationPlan":
|
|
||||||
raw = dict(value)
|
|
||||||
if raw.pop("schema", None) != PLAN_SCHEMA:
|
|
||||||
raise MigrationPlanError("migration_plan_schema_invalid")
|
|
||||||
claimed = raw.pop("sha256", None)
|
|
||||||
raw["from_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["from_components"])
|
|
||||||
raw["to_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["to_components"])
|
|
||||||
raw["state_contracts"] = tuple(StateMigrationContract.from_dict(item) for item in raw["state_contracts"])
|
|
||||||
raw["preconditions"] = tuple(raw.get("preconditions") or ())
|
|
||||||
raw["verification_contracts"] = tuple(raw.get("verification_contracts") or ())
|
|
||||||
plan = cls(**raw)
|
|
||||||
if claimed is not None and claimed != plan.sha256:
|
|
||||||
raise MigrationPlanError("migration_plan_sha256_mismatch")
|
|
||||||
return plan
|
|
||||||
|
|
||||||
def assert_release_binding(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
version: str,
|
|
||||||
candidate_id: str,
|
|
||||||
source_commit: str,
|
|
||||||
tree_oid: str,
|
|
||||||
repository_identity: str,
|
|
||||||
) -> None:
|
|
||||||
expected = (
|
|
||||||
self.version,
|
|
||||||
self.candidate_id,
|
|
||||||
self.source_commit,
|
|
||||||
self.tree_oid,
|
|
||||||
self.repository_identity,
|
|
||||||
)
|
|
||||||
actual = (version, candidate_id, source_commit, tree_oid, repository_identity)
|
|
||||||
if actual != expected:
|
|
||||||
raise MigrationPlanError("migration_release_binding_mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MigrationRequirement:
|
|
||||||
version: str
|
|
||||||
candidate_id: str
|
|
||||||
source_commit: str
|
|
||||||
repository_identity: str
|
|
||||||
required: bool
|
|
||||||
reason_code: str
|
|
||||||
profile_id: str
|
|
||||||
profile_sha256: str
|
|
||||||
migration_plan_sha256: str | None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": REQUIREMENT_SCHEMA,
|
|
||||||
**asdict(self),
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sha256(self) -> str:
|
|
||||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {**self.canonical_payload, "sha256": self.sha256}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationRequirement":
|
|
||||||
raw = dict(value)
|
|
||||||
if raw.pop("schema", None) != REQUIREMENT_SCHEMA:
|
|
||||||
raise MigrationPlanError("migration_requirement_schema_invalid")
|
|
||||||
claimed = raw.pop("sha256", None)
|
|
||||||
requirement = cls(**raw)
|
|
||||||
_ident(requirement.version, "version")
|
|
||||||
_ident(requirement.candidate_id, "candidate_id")
|
|
||||||
_oid(requirement.source_commit, "source_commit")
|
|
||||||
if requirement.repository_identity != "aios/aios":
|
|
||||||
raise MigrationPlanError("migration_requirement_repository_invalid")
|
|
||||||
_ident(requirement.reason_code, "reason_code")
|
|
||||||
_ident(requirement.profile_id, "profile_id")
|
|
||||||
_sha(requirement.profile_sha256, "profile_sha256")
|
|
||||||
if requirement.required:
|
|
||||||
_sha(requirement.migration_plan_sha256, "migration_plan_sha256")
|
|
||||||
elif requirement.migration_plan_sha256 is not None:
|
|
||||||
raise MigrationPlanError("unexpected_migration_plan")
|
|
||||||
if claimed is not None and claimed != requirement.sha256:
|
|
||||||
raise MigrationPlanError("migration_requirement_sha256_mismatch")
|
|
||||||
return requirement
|
|
||||||
|
|
||||||
|
|
||||||
class FileMigrationPlanResolver:
|
|
||||||
"""Read-only resolver used by the trusted builder.
|
|
||||||
|
|
||||||
A profile declares that a release version is cross-code-line. Once a
|
|
||||||
version is declared, absence of requirement/plan state is fail-closed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root: Path,
|
|
||||||
*,
|
|
||||||
profile_root: Path,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
):
|
|
||||||
self.root = Path(root)
|
|
||||||
self.profile_root = Path(profile_root)
|
|
||||||
self.expected_owner_uid = expected_owner_uid
|
|
||||||
|
|
||||||
def _profile_for_version(self, version: str) -> dict[str, Any] | None:
|
|
||||||
matches = []
|
|
||||||
if not self.profile_root.is_dir():
|
|
||||||
return None
|
|
||||||
for path in sorted(self.profile_root.glob("*.json")):
|
|
||||||
profile = _secure_json(path, expected_owner_uid=self.expected_owner_uid)
|
|
||||||
if profile.get("schema") != PROFILE_SCHEMA:
|
|
||||||
raise MigrationPlanError("migration_profile_schema_invalid")
|
|
||||||
if profile.get("version") == version:
|
|
||||||
matches.append(profile)
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise MigrationPlanError("migration_profile_ambiguous")
|
|
||||||
return matches[0] if matches else None
|
|
||||||
|
|
||||||
def resolve(self, version: str, candidate_id: str, source_commit: str) -> MigrationPlan | None:
|
|
||||||
profile = self._profile_for_version(version)
|
|
||||||
if profile is None:
|
|
||||||
return None
|
|
||||||
base = self.root / source_commit
|
|
||||||
requirement_path = base / "requirement.json"
|
|
||||||
plan_path = base / "plan.json"
|
|
||||||
if not requirement_path.is_file():
|
|
||||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
|
||||||
requirement = MigrationRequirement.from_dict(
|
|
||||||
_secure_json(requirement_path, expected_owner_uid=self.expected_owner_uid)
|
|
||||||
)
|
|
||||||
expected = (version, candidate_id, source_commit, "aios/aios")
|
|
||||||
actual = (
|
|
||||||
requirement.version,
|
|
||||||
requirement.candidate_id,
|
|
||||||
requirement.source_commit,
|
|
||||||
requirement.repository_identity,
|
|
||||||
)
|
|
||||||
if actual != expected:
|
|
||||||
raise MigrationPlanError("migration_requirement_binding_mismatch")
|
|
||||||
if requirement.profile_id != profile.get("profile_id"):
|
|
||||||
raise MigrationPlanError("migration_profile_binding_mismatch")
|
|
||||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
|
||||||
if requirement.profile_sha256 != profile_sha:
|
|
||||||
raise MigrationPlanError("migration_profile_digest_mismatch")
|
|
||||||
if not requirement.required:
|
|
||||||
return None
|
|
||||||
if not plan_path.is_file():
|
|
||||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
|
||||||
plan = MigrationPlan.from_dict(
|
|
||||||
_secure_json(plan_path, expected_owner_uid=self.expected_owner_uid)
|
|
||||||
)
|
|
||||||
plan.assert_release_binding(
|
|
||||||
version=version,
|
|
||||||
candidate_id=candidate_id,
|
|
||||||
source_commit=source_commit,
|
|
||||||
tree_oid=plan.tree_oid,
|
|
||||||
repository_identity="aios/aios",
|
|
||||||
)
|
|
||||||
if requirement.migration_plan_sha256 != plan.sha256:
|
|
||||||
raise MigrationPlanError("migration_requirement_plan_mismatch")
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationPreSwitchValidator:
|
|
||||||
"""Validates a root-owned live proof against the signed migration plan."""
|
|
||||||
|
|
||||||
def __init__(self, proof_resolver: Callable[[MigrationPlan], Mapping[str, Any]]):
|
|
||||||
self.proof_resolver = proof_resolver
|
|
||||||
|
|
||||||
def validate(self, migration_plan: MigrationPlan | None) -> dict[str, Any]:
|
|
||||||
if migration_plan is None:
|
|
||||||
return {"required": False, "verified": True}
|
|
||||||
proof = dict(self.proof_resolver(migration_plan))
|
|
||||||
snapshot_digests = sorted(
|
|
||||||
contract.snapshot_sha256 for contract in migration_plan.state_contracts
|
|
||||||
)
|
|
||||||
required = {
|
|
||||||
"migration_plan_sha256": migration_plan.sha256,
|
|
||||||
"migration_id": migration_plan.migration_id,
|
|
||||||
"source_commit": migration_plan.source_commit,
|
|
||||||
"tree_oid": migration_plan.tree_oid,
|
|
||||||
"activation_sha256": migration_plan.activation_sha256,
|
|
||||||
"state_snapshot_sha256s": snapshot_digests,
|
|
||||||
"writer_disabled": True,
|
|
||||||
"task_quiescent": True,
|
|
||||||
"state_snapshot_verified": True,
|
|
||||||
"credentials_ready": True,
|
|
||||||
"rollback_ready": True,
|
|
||||||
}
|
|
||||||
failed = [key for key, expected in required.items() if proof.get(key) != expected]
|
|
||||||
if failed:
|
|
||||||
raise MigrationPlanError(
|
|
||||||
"MIGRATION_PRE_SWITCH_PROOF_INVALID:" + ",".join(sorted(failed))
|
|
||||||
)
|
|
||||||
return {"required": True, "verified": True, **proof}
|
|
||||||
|
|
||||||
|
|
||||||
class HostMigrationCompiler:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
authority_git_dir: Path,
|
|
||||||
profile_path: Path,
|
|
||||||
output_root: Path,
|
|
||||||
expected_owner_uid: int | None = 0,
|
|
||||||
):
|
|
||||||
self.authority_git_dir = Path(authority_git_dir)
|
|
||||||
self.profile_path = Path(profile_path)
|
|
||||||
self.output_root = Path(output_root)
|
|
||||||
self.expected_owner_uid = expected_owner_uid
|
|
||||||
|
|
||||||
def _profile(self) -> dict[str, Any]:
|
|
||||||
profile = _secure_json(self.profile_path, expected_owner_uid=self.expected_owner_uid)
|
|
||||||
required = {
|
|
||||||
"schema",
|
|
||||||
"profile_id",
|
|
||||||
"version",
|
|
||||||
"repository",
|
|
||||||
"current_components",
|
|
||||||
"target_components",
|
|
||||||
"activation_file",
|
|
||||||
"state_ref",
|
|
||||||
"state_db",
|
|
||||||
"state_mode",
|
|
||||||
"rollback_window_seconds",
|
|
||||||
}
|
|
||||||
if set(profile) != required or profile.get("schema") != PROFILE_SCHEMA:
|
|
||||||
raise MigrationPlanError("migration_profile_fields_invalid")
|
|
||||||
if profile.get("repository") != "aios/aios":
|
|
||||||
raise MigrationPlanError("migration_profile_repository_invalid")
|
|
||||||
_ident(profile.get("profile_id"), "profile_id")
|
|
||||||
_ident(profile.get("version"), "version")
|
|
||||||
if profile.get("state_mode") not in ALLOWED_STATE_MODES:
|
|
||||||
raise MigrationPlanError("migration_profile_state_mode_invalid")
|
|
||||||
return profile
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _component_from_live(spec: Mapping[str, Any]) -> RuntimeComponentBinding:
|
|
||||||
required = {
|
|
||||||
"component_id",
|
|
||||||
"service",
|
|
||||||
"code_line_id",
|
|
||||||
"expected_source_oid",
|
|
||||||
"source_oid_env",
|
|
||||||
"artifact_sha_env",
|
|
||||||
"interface_files",
|
|
||||||
"marker_files",
|
|
||||||
}
|
|
||||||
if set(spec) != required:
|
|
||||||
raise MigrationPlanError("current_component_profile_invalid")
|
|
||||||
properties = _systemd_properties(str(spec["service"]))
|
|
||||||
environment = properties["environment"]
|
|
||||||
source_oid = _oid(environment.get(str(spec["source_oid_env"])), "current_source_oid")
|
|
||||||
expected_oid = _oid(spec["expected_source_oid"], "expected_source_oid")
|
|
||||||
if source_oid != expected_oid:
|
|
||||||
raise MigrationPlanError("CURRENT_RUNTIME_DRIFT")
|
|
||||||
working = Path(str(properties.get("WorkingDirectory") or ""))
|
|
||||||
if not working.is_absolute() or not working.is_dir():
|
|
||||||
raise MigrationPlanError("current_working_directory_invalid")
|
|
||||||
for marker in spec["marker_files"]:
|
|
||||||
if not (working / str(marker)).is_file():
|
|
||||||
raise MigrationPlanError("current_code_line_marker_missing")
|
|
||||||
interface = _hash_files(working, tuple(str(item) for item in spec["interface_files"]))
|
|
||||||
raw_artifact = environment.get(str(spec["artifact_sha_env"]))
|
|
||||||
artifact = str(raw_artifact or "").lower()
|
|
||||||
artifact_value = artifact if SHA_RE.fullmatch(artifact) else None
|
|
||||||
return RuntimeComponentBinding(
|
|
||||||
component_id=str(spec["component_id"]),
|
|
||||||
service=str(spec["service"]),
|
|
||||||
code_line_id=str(spec["code_line_id"]),
|
|
||||||
source_oid=source_oid,
|
|
||||||
artifact_sha256=artifact_value,
|
|
||||||
interface_sha256=interface,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _component_from_target(
|
|
||||||
self, spec: Mapping[str, Any], *, source_commit: str
|
|
||||||
) -> RuntimeComponentBinding:
|
|
||||||
required = {"component_id", "service", "code_line_id", "interface_files"}
|
|
||||||
if set(spec) != required:
|
|
||||||
raise MigrationPlanError("target_component_profile_invalid")
|
|
||||||
interface_files = tuple(str(item) for item in spec["interface_files"])
|
|
||||||
interface = _hash_git_files(self.authority_git_dir, source_commit, interface_files)
|
|
||||||
return RuntimeComponentBinding(
|
|
||||||
component_id=str(spec["component_id"]),
|
|
||||||
service=str(spec["service"]),
|
|
||||||
code_line_id=str(spec["code_line_id"]),
|
|
||||||
source_oid=source_commit,
|
|
||||||
artifact_sha256=None,
|
|
||||||
interface_sha256=interface,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _state_compatibility(
|
|
||||||
state_db: Path,
|
|
||||||
state_ref: str,
|
|
||||||
*,
|
|
||||||
snapshot_path: Path,
|
|
||||||
snapshot_name: str,
|
|
||||||
) -> StateMigrationContract:
|
|
||||||
from aios_task_admission_bootstrap import TaskAdmissionStore
|
|
||||||
|
|
||||||
if state_db.is_symlink() or not state_db.is_file():
|
|
||||||
raise MigrationPlanError("state_db_invalid")
|
|
||||||
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with tempfile.TemporaryDirectory(prefix="aios-migration-probe-") as temp_dir:
|
|
||||||
temp_root = Path(temp_dir)
|
|
||||||
consistent_snapshot = temp_root / "pre-migration.sqlite3"
|
|
||||||
probe_path = temp_root / "schema-probe.sqlite3"
|
|
||||||
with sqlite3.connect(
|
|
||||||
f"file:{state_db}?mode=ro", uri=True
|
|
||||||
) as live, sqlite3.connect(consistent_snapshot) as copy:
|
|
||||||
live.backup(copy)
|
|
||||||
with sqlite3.connect(consistent_snapshot) as frozen:
|
|
||||||
before = _sqlite_schema(frozen)
|
|
||||||
# Probe target initialization only on a second private copy. The
|
|
||||||
# rollback snapshot remains byte-for-byte pre-migration state.
|
|
||||||
with sqlite3.connect(consistent_snapshot) as source, sqlite3.connect(
|
|
||||||
probe_path
|
|
||||||
) as probe:
|
|
||||||
source.backup(probe)
|
|
||||||
TaskAdmissionStore(probe_path)
|
|
||||||
with sqlite3.connect(probe_path) as migrated:
|
|
||||||
after = _sqlite_schema(migrated)
|
|
||||||
required_tables = {
|
|
||||||
"task_admission_envelopes",
|
|
||||||
"task_admission_grants",
|
|
||||||
"task_admission_receipts",
|
|
||||||
}
|
|
||||||
if not required_tables.issubset(after):
|
|
||||||
raise MigrationPlanError("INCOMPATIBLE_STATE_SCHEMA")
|
|
||||||
_assert_additive_sqlite(before, after)
|
|
||||||
snapshot_bytes = consistent_snapshot.read_bytes()
|
|
||||||
snapshot_sha = hashlib.sha256(snapshot_bytes).hexdigest()
|
|
||||||
if snapshot_path.exists():
|
|
||||||
if snapshot_path.is_symlink() or not snapshot_path.is_file():
|
|
||||||
raise MigrationPlanError("migration_snapshot_invalid")
|
|
||||||
if hashlib.sha256(snapshot_path.read_bytes()).hexdigest() != snapshot_sha:
|
|
||||||
raise MigrationPlanError("migration_snapshot_conflict")
|
|
||||||
else:
|
|
||||||
temporary = snapshot_path.with_name(snapshot_path.name + ".tmp")
|
|
||||||
descriptor = os.open(
|
|
||||||
temporary,
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
||||||
0o640,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "wb") as handle:
|
|
||||||
handle.write(snapshot_bytes)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(temporary, snapshot_path)
|
|
||||||
if os.name != "nt":
|
|
||||||
os.chmod(snapshot_path, 0o640)
|
|
||||||
directory_fd = os.open(snapshot_path.parent, os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
finally:
|
|
||||||
os.close(directory_fd)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
temporary.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return StateMigrationContract(
|
|
||||||
state_id="task-admission",
|
|
||||||
state_ref=state_ref,
|
|
||||||
mode="REUSE_IN_PLACE_WITH_SNAPSHOT",
|
|
||||||
before_schema_sha256=_schema_digest(before),
|
|
||||||
after_schema_sha256=_schema_digest(after),
|
|
||||||
snapshot_name=snapshot_name,
|
|
||||||
snapshot_sha256=snapshot_sha,
|
|
||||||
snapshot_required=True,
|
|
||||||
rollback_compatible=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def compile(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source_admission: Mapping[str, Any],
|
|
||||||
candidate_id: str,
|
|
||||||
) -> tuple[MigrationRequirement, MigrationPlan]:
|
|
||||||
profile = self._profile()
|
|
||||||
version = str(source_admission.get("version") or "")
|
|
||||||
source_commit = _oid(source_admission.get("source_commit"), "source_commit")
|
|
||||||
tree_oid = _oid(source_admission.get("tree_oid"), "tree_oid")
|
|
||||||
admission_id = _ident(source_admission.get("admission_id"), "source_admission_id")
|
|
||||||
if (
|
|
||||||
version != profile["version"]
|
|
||||||
or source_admission.get("repository") != "aios/aios"
|
|
||||||
):
|
|
||||||
raise MigrationPlanError("source_admission_profile_mismatch")
|
|
||||||
_ident(candidate_id, "candidate_id")
|
|
||||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
|
||||||
from_components = tuple(
|
|
||||||
self._component_from_live(item) for item in profile["current_components"]
|
|
||||||
)
|
|
||||||
to_components = tuple(
|
|
||||||
self._component_from_target(item, source_commit=source_commit)
|
|
||||||
for item in profile["target_components"]
|
|
||||||
)
|
|
||||||
if {item.component_id for item in from_components} != {item.component_id for item in to_components}:
|
|
||||||
raise MigrationPlanError("migration_component_profile_mismatch")
|
|
||||||
required = any(
|
|
||||||
next(old.code_line_id for old in from_components if old.component_id == new.component_id)
|
|
||||||
!= new.code_line_id
|
|
||||||
for new in to_components
|
|
||||||
)
|
|
||||||
if not required:
|
|
||||||
raise MigrationPlanError("migration_profile_does_not_require_transition")
|
|
||||||
activation_path = Path(str(profile["activation_file"]))
|
|
||||||
activation = _secure_json(activation_path, expected_owner_uid=self.expected_owner_uid)
|
|
||||||
if activation.get("schema") != "aios.task-admission-host-activation.v1":
|
|
||||||
raise MigrationPlanError("activation_schema_invalid")
|
|
||||||
state_path = Path(str(profile["state_db"]))
|
|
||||||
if str(activation.get("state_db") or "") != str(state_path):
|
|
||||||
raise MigrationPlanError("activation_state_db_mismatch")
|
|
||||||
root = self.output_root / source_commit
|
|
||||||
snapshot_name = "snapshots/task-admission.sqlite3"
|
|
||||||
state_contract = self._state_compatibility(
|
|
||||||
state_path,
|
|
||||||
str(profile["state_ref"]),
|
|
||||||
snapshot_path=root / snapshot_name,
|
|
||||||
snapshot_name=snapshot_name,
|
|
||||||
)
|
|
||||||
migration_id = (
|
|
||||||
"migration-"
|
|
||||||
+ hashlib.sha256(
|
|
||||||
f"{version}\0{candidate_id}\0{source_commit}\0{profile_sha}".encode("utf-8")
|
|
||||||
).hexdigest()[:24]
|
|
||||||
)
|
|
||||||
plan = MigrationPlan(
|
|
||||||
migration_id=migration_id,
|
|
||||||
version=version,
|
|
||||||
candidate_id=candidate_id,
|
|
||||||
repository_identity="aios/aios",
|
|
||||||
source_admission_id=admission_id,
|
|
||||||
source_commit=source_commit,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
profile_id=str(profile["profile_id"]),
|
|
||||||
profile_sha256=profile_sha,
|
|
||||||
from_components=from_components,
|
|
||||||
to_components=to_components,
|
|
||||||
state_contracts=(state_contract,),
|
|
||||||
activation_schema=str(activation["schema"]),
|
|
||||||
activation_sha256=hashlib.sha256(_canonical_json(activation)).hexdigest(),
|
|
||||||
rollback_window_seconds=int(profile["rollback_window_seconds"]),
|
|
||||||
)
|
|
||||||
requirement = MigrationRequirement(
|
|
||||||
version=version,
|
|
||||||
candidate_id=candidate_id,
|
|
||||||
source_commit=source_commit,
|
|
||||||
repository_identity="aios/aios",
|
|
||||||
required=True,
|
|
||||||
reason_code="CROSS_CODE_LINE",
|
|
||||||
profile_id=str(profile["profile_id"]),
|
|
||||||
profile_sha256=profile_sha,
|
|
||||||
migration_plan_sha256=plan.sha256,
|
|
||||||
)
|
|
||||||
_durable_json(root / "requirement.json", requirement.to_dict())
|
|
||||||
_durable_json(root / "plan.json", plan.to_dict())
|
|
||||||
return requirement, plan
|
|
||||||
|
|
||||||
|
|
||||||
def _verified_source_admission(
|
|
||||||
*,
|
|
||||||
source_admission_root: Path,
|
|
||||||
source_commit: str,
|
|
||||||
builder_policy: Path,
|
|
||||||
credential_name: str = "source_admission_hmac",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Resolve one source admission through its existing HMAC verifier.
|
|
||||||
|
|
||||||
The migration planner never trusts a caller-selected admission JSON path.
|
|
||||||
It consumes only a root-owned admission store and the independent
|
|
||||||
``source_admission_hmac`` credential projected by systemd.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from release_source_admission import open_source_admission_store
|
|
||||||
|
|
||||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
|
||||||
if not credentials:
|
|
||||||
raise MigrationPlanError("source_admission_credentials_unavailable")
|
|
||||||
credential_path = Path(credentials) / credential_name
|
|
||||||
try:
|
|
||||||
hmac_key = credential_path.read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise MigrationPlanError("source_admission_hmac_unavailable") from exc
|
|
||||||
try:
|
|
||||||
store = open_source_admission_store(
|
|
||||||
source_admission_root,
|
|
||||||
repository="aios/aios",
|
|
||||||
policy=builder_policy,
|
|
||||||
hmac_key=hmac_key,
|
|
||||||
expected_owner_uid=0 if os.name != "nt" else None,
|
|
||||||
writable=False,
|
|
||||||
)
|
|
||||||
record = store.lookup_source(source_commit)
|
|
||||||
except Exception as exc:
|
|
||||||
raise MigrationPlanError("source_admission_verification_failed") from exc
|
|
||||||
if record is None:
|
|
||||||
raise MigrationPlanRequired("SOURCE_ADMISSION_REQUIRED")
|
|
||||||
return dict(record)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
|
||||||
parser.add_argument("--profile", required=True, type=Path)
|
|
||||||
parser.add_argument("--output-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
|
||||||
parser.add_argument("--source-commit", required=True)
|
|
||||||
parser.add_argument("--candidate-id", required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
source_commit = _oid(args.source_commit, "source_commit")
|
|
||||||
compiler = HostMigrationCompiler(
|
|
||||||
authority_git_dir=args.authority_git_dir,
|
|
||||||
profile_path=args.profile,
|
|
||||||
output_root=args.output_root,
|
|
||||||
expected_owner_uid=0 if os.name != "nt" else None,
|
|
||||||
)
|
|
||||||
requirement, plan = compiler.compile(
|
|
||||||
source_admission=_verified_source_admission(
|
|
||||||
source_admission_root=args.source_admission_root,
|
|
||||||
source_commit=source_commit,
|
|
||||||
builder_policy=args.builder_policy,
|
|
||||||
),
|
|
||||||
candidate_id=args.candidate_id,
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"status": "MIGRATION_PLAN_READY",
|
|
||||||
"migration_plan_sha256": plan.sha256,
|
|
||||||
"requirement_sha256": requirement.sha256,
|
|
||||||
"source_commit": plan.source_commit,
|
|
||||||
"version": plan.version,
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
raise SystemExit(main())
|
|
||||||
except Exception as exc:
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"status": "FAIL_CLOSED",
|
|
||||||
"error": type(exc).__name__,
|
|
||||||
"reason": str(exc),
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise SystemExit(2)
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Governed admission of an exact remote Git commit into the release source closure.
|
|
||||||
|
|
||||||
The admitter is intentionally separate from the trusted candidate builder:
|
|
||||||
it may use a narrowly-scoped Gitea read/fetch credential, while the builder
|
|
||||||
remains PrivateNetwork=true and reads only the authoritative Git object store
|
|
||||||
plus immutable admission records.
|
|
||||||
|
|
||||||
An admission never moves ``main`` and never treats an arbitrary caller-supplied
|
|
||||||
OID as trusted. It binds:
|
|
||||||
remote branch ref -> exact full commit OID -> authoritative Git object/tree
|
|
||||||
-> current authoritative main ancestry -> immutable release recipe metadata.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping, Protocol
|
|
||||||
|
|
||||||
from authoritative_git import (
|
|
||||||
AuthoritativeGitError,
|
|
||||||
authoritative_head_oid,
|
|
||||||
authoritative_lineage,
|
|
||||||
run_authoritative_git,
|
|
||||||
)
|
|
||||||
from aios_gitea_machine_transport import GiteaMachineTransport, GiteaTransportError
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = "aios.release-source-admission.v1"
|
|
||||||
RECIPE_MODE = "governed-source-admission"
|
|
||||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$")
|
|
||||||
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
|
||||||
_REF_PREFIX = "refs/heads/"
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseSourceAdmissionError(RuntimeError):
|
|
||||||
"""A stable fail-closed source-admission error code."""
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
dict(value),
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
ensure_ascii=True,
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: object, field: str) -> str:
|
|
||||||
text = str(value or "").strip().lower()
|
|
||||||
if not _OID_RE.fullmatch(text):
|
|
||||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(value: object, field: str) -> str:
|
|
||||||
text = str(value or "").strip().lower()
|
|
||||||
if not _DIGEST_RE.fullmatch(text):
|
|
||||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: object, field: str) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not _IDENTIFIER_RE.fullmatch(text) or ".." in text or "\x00" in text:
|
|
||||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _source_ref(value: object) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if not text.startswith(_REF_PREFIX):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
|
||||||
branch = text[len(_REF_PREFIX):]
|
|
||||||
if (
|
|
||||||
not _BRANCH_RE.fullmatch(branch)
|
|
||||||
or ".." in branch
|
|
||||||
or "//" in branch
|
|
||||||
or "@{" in branch
|
|
||||||
or branch.endswith(("/", ".", ".lock"))
|
|
||||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
|
||||||
):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _version(value: object) -> str:
|
|
||||||
try:
|
|
||||||
return validate_release_version(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("VERSION_INVALID") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _version_slug(version: str) -> str:
|
|
||||||
value = re.sub(r"[^A-Za-z0-9]+", "-", version.lower()).strip("-")
|
|
||||||
if not value:
|
|
||||||
raise ReleaseSourceAdmissionError("VERSION_INVALID")
|
|
||||||
return value[:80]
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _secure_stat(path: Path, *, expected_owner_uid: int | None, directory: bool) -> None:
|
|
||||||
try:
|
|
||||||
info = path.lstat()
|
|
||||||
except OSError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
|
||||||
if path.is_symlink():
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
|
||||||
if directory and not path.is_dir():
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
|
||||||
if not directory and not path.is_file():
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
|
||||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_OWNER_MISMATCH")
|
|
||||||
# Group/other write would allow an untrusted process to forge release input.
|
|
||||||
if info.st_mode & 0o022:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_PERMISSIONS_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_record(
|
|
||||||
value: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
repository: str,
|
|
||||||
policy_sha256: str,
|
|
||||||
hmac_key: bytes,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
|
||||||
required = {
|
|
||||||
"schema",
|
|
||||||
"admission_id",
|
|
||||||
"repository",
|
|
||||||
"task_id",
|
|
||||||
"version",
|
|
||||||
"candidate_id",
|
|
||||||
"base_oid",
|
|
||||||
"source_ref",
|
|
||||||
"source_commit",
|
|
||||||
"remote_ref_oid",
|
|
||||||
"tree_oid",
|
|
||||||
"purpose",
|
|
||||||
"policy_sha256",
|
|
||||||
"admitted_at",
|
|
||||||
"mac",
|
|
||||||
}
|
|
||||||
if set(value) != required or value.get("schema") != SCHEMA:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
|
||||||
record = dict(value)
|
|
||||||
if _identifier(record["repository"], "repository") != repository:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REPOSITORY_MISMATCH")
|
|
||||||
_identifier(record["task_id"], "task_id")
|
|
||||||
record["version"] = _version(record["version"])
|
|
||||||
_identifier(record["candidate_id"], "candidate_id")
|
|
||||||
_identifier(record["purpose"], "purpose")
|
|
||||||
_identifier(record["admission_id"], "admission_id")
|
|
||||||
record["base_oid"] = _oid(record["base_oid"], "base_oid")
|
|
||||||
record["source_commit"] = _oid(record["source_commit"], "source_commit")
|
|
||||||
record["remote_ref_oid"] = _oid(record["remote_ref_oid"], "remote_ref_oid")
|
|
||||||
record["tree_oid"] = _oid(record["tree_oid"], "tree_oid")
|
|
||||||
record["source_ref"] = _source_ref(record["source_ref"])
|
|
||||||
record["policy_sha256"] = _digest(record["policy_sha256"], "policy_sha256")
|
|
||||||
if record["policy_sha256"] != policy_sha256:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_POLICY_MISMATCH")
|
|
||||||
if record["remote_ref_oid"] != record["source_commit"]:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REMOTE_MISMATCH")
|
|
||||||
admitted_at = record.get("admitted_at")
|
|
||||||
if not isinstance(admitted_at, str) or not admitted_at.endswith("Z") or len(admitted_at) < 20:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_TIME_INVALID")
|
|
||||||
supplied_mac = record.get("mac")
|
|
||||||
if (
|
|
||||||
not isinstance(supplied_mac, str)
|
|
||||||
or not re.fullmatch(r"[0-9a-f]{64}", supplied_mac)
|
|
||||||
):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
|
||||||
unsigned = {key: value for key, value in record.items() if key != "mac"}
|
|
||||||
expected_mac = hmac.new(hmac_key, _canonical(unsigned), hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(supplied_mac, expected_mac):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseSourceBackend(Protocol):
|
|
||||||
def authoritative_main_oid(self) -> str: ...
|
|
||||||
def remote_ref_oid(self, source_ref: str) -> str: ...
|
|
||||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str: ...
|
|
||||||
def tree_oid(self, source_commit: str) -> str: ...
|
|
||||||
def require_lineage(self, base_oid: str, source_commit: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseSourceAdmissionStore:
|
|
||||||
"""Durable immutable admission records keyed by exact source commit."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root: str | os.PathLike[str],
|
|
||||||
*,
|
|
||||||
repository: str,
|
|
||||||
policy_sha256: str,
|
|
||||||
hmac_key: bytes,
|
|
||||||
expected_owner_uid: int | None = None,
|
|
||||||
writable: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.root = Path(root)
|
|
||||||
self.repository = _identifier(repository, "repository")
|
|
||||||
self.policy_sha256 = _digest(policy_sha256, "policy_sha256")
|
|
||||||
if not isinstance(hmac_key, (bytes, bytearray)) or len(hmac_key) < 32:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_HMAC_INVALID")
|
|
||||||
self.hmac_key = bytes(hmac_key)
|
|
||||||
self.expected_owner_uid = expected_owner_uid
|
|
||||||
self.writable = bool(writable)
|
|
||||||
if self.writable and not self.root.exists():
|
|
||||||
self.root.mkdir(parents=True, mode=0o750)
|
|
||||||
if self.root.exists():
|
|
||||||
_secure_stat(
|
|
||||||
self.root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _path(self, source_commit: str) -> Path:
|
|
||||||
return self.root / f"{_oid(source_commit, 'source_commit')}.json"
|
|
||||||
|
|
||||||
def register(self, record: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if not self.writable:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
|
||||||
supplied = dict(record)
|
|
||||||
if "mac" not in supplied:
|
|
||||||
supplied["mac"] = hmac.new(
|
|
||||||
self.hmac_key,
|
|
||||||
_canonical(supplied),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
checked = _validate_record(
|
|
||||||
supplied,
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=self.policy_sha256,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
path = self._path(checked["source_commit"])
|
|
||||||
encoded = _canonical(checked) + b"\n"
|
|
||||||
if path.exists():
|
|
||||||
_secure_stat(
|
|
||||||
path,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=False,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
existing = path.read_bytes()
|
|
||||||
except OSError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
|
||||||
if existing == encoded:
|
|
||||||
return dict(checked)
|
|
||||||
try:
|
|
||||||
previous = _validate_record(
|
|
||||||
json.loads(existing),
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=self.policy_sha256,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
|
||||||
comparable_previous = {
|
|
||||||
key: value
|
|
||||||
for key, value in previous.items()
|
|
||||||
if key not in {"admitted_at", "mac"}
|
|
||||||
}
|
|
||||||
comparable_checked = {
|
|
||||||
key: value
|
|
||||||
for key, value in checked.items()
|
|
||||||
if key not in {"admitted_at", "mac"}
|
|
||||||
}
|
|
||||||
if comparable_previous == comparable_checked:
|
|
||||||
return dict(previous)
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CONFLICT")
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
||||||
_secure_stat(
|
|
||||||
self.root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=True,
|
|
||||||
)
|
|
||||||
fd, raw_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{checked['source_commit']}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
dir=self.root,
|
|
||||||
)
|
|
||||||
temp_path = Path(raw_name)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd, 0o640)
|
|
||||||
with os.fdopen(fd, "wb", closefd=True) as handle:
|
|
||||||
handle.write(encoded)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(temp_path, path)
|
|
||||||
os.chmod(path, 0o640)
|
|
||||||
directory_fd = os.open(self.root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
||||||
try:
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
finally:
|
|
||||||
os.close(directory_fd)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.close(fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
temp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
_secure_stat(
|
|
||||||
path,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=False,
|
|
||||||
)
|
|
||||||
return dict(checked)
|
|
||||||
|
|
||||||
def rebind_policy(
|
|
||||||
self,
|
|
||||||
source_commit: str,
|
|
||||||
*,
|
|
||||||
previous_policy_sha256: str,
|
|
||||||
reason: str,
|
|
||||||
expected_admission_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Rebind one immutable source identity to an explicitly authorized policy.
|
|
||||||
|
|
||||||
Only ``policy_sha256`` and ``mac`` change in the admission record.
|
|
||||||
``admission_id`` and every source/candidate/tree binding remain immutable.
|
|
||||||
A separate HMAC-authenticated history record makes the temporary §8.1
|
|
||||||
trust expansion and its restoration auditable and idempotent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self.writable:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
|
||||||
previous_policy = _digest(
|
|
||||||
previous_policy_sha256,
|
|
||||||
"previous_policy_sha256",
|
|
||||||
)
|
|
||||||
reason_value = _identifier(reason, "reason")
|
|
||||||
path = self._path(source_commit)
|
|
||||||
if not path.exists():
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REQUIRED")
|
|
||||||
_secure_stat(
|
|
||||||
path,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=False,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
decoded = json.loads(raw)
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ReleaseSourceAdmissionError(
|
|
||||||
"SOURCE_ADMISSION_RECORD_INVALID"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
previous = _validate_record(
|
|
||||||
decoded,
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=previous_policy,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
except ReleaseSourceAdmissionError:
|
|
||||||
# Idempotent replay after a successful rebind validates under the
|
|
||||||
# current policy instead of silently weakening the old-policy gate.
|
|
||||||
current = _validate_record(
|
|
||||||
decoded,
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=self.policy_sha256,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
if expected_admission_id and current["admission_id"] != expected_admission_id:
|
|
||||||
raise ReleaseSourceAdmissionError(
|
|
||||||
"SOURCE_ADMISSION_ID_MISMATCH"
|
|
||||||
)
|
|
||||||
return dict(current)
|
|
||||||
|
|
||||||
if expected_admission_id and previous["admission_id"] != expected_admission_id:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_ID_MISMATCH")
|
|
||||||
rebound = {
|
|
||||||
key: value
|
|
||||||
for key, value in previous.items()
|
|
||||||
if key != "mac"
|
|
||||||
}
|
|
||||||
rebound["policy_sha256"] = self.policy_sha256
|
|
||||||
rebound["mac"] = hmac.new(
|
|
||||||
self.hmac_key,
|
|
||||||
_canonical(rebound),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
checked = _validate_record(
|
|
||||||
rebound,
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=self.policy_sha256,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
if checked["admission_id"] != previous["admission_id"]:
|
|
||||||
raise ReleaseSourceAdmissionError(
|
|
||||||
"SOURCE_ADMISSION_ID_CHANGED"
|
|
||||||
)
|
|
||||||
for field in (
|
|
||||||
"repository",
|
|
||||||
"version",
|
|
||||||
"candidate_id",
|
|
||||||
"base_oid",
|
|
||||||
"source_ref",
|
|
||||||
"source_commit",
|
|
||||||
"remote_ref_oid",
|
|
||||||
"tree_oid",
|
|
||||||
"purpose",
|
|
||||||
"task_id",
|
|
||||||
):
|
|
||||||
if checked[field] != previous[field]:
|
|
||||||
raise ReleaseSourceAdmissionError(
|
|
||||||
"SOURCE_ADMISSION_REBIND_SCOPE_CHANGED"
|
|
||||||
)
|
|
||||||
|
|
||||||
encoded = _canonical(checked) + b"\n"
|
|
||||||
history_payload = {
|
|
||||||
"schema": "aios.release-source-admission-policy-rebind.v1",
|
|
||||||
"admission_id": checked["admission_id"],
|
|
||||||
"source_commit": checked["source_commit"],
|
|
||||||
"previous_policy_sha256": previous_policy,
|
|
||||||
"policy_sha256": self.policy_sha256,
|
|
||||||
"reason": reason_value,
|
|
||||||
"record_sha256": hashlib.sha256(encoded).hexdigest(),
|
|
||||||
"rebound_at": _now(),
|
|
||||||
}
|
|
||||||
history_payload["mac"] = hmac.new(
|
|
||||||
self.hmac_key,
|
|
||||||
_canonical(history_payload),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
history_root = self.root / "rebind-history"
|
|
||||||
if not history_root.exists():
|
|
||||||
history_root.mkdir(mode=0o750)
|
|
||||||
_secure_stat(
|
|
||||||
history_root,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=True,
|
|
||||||
)
|
|
||||||
history_name = (
|
|
||||||
f"{checked['source_commit']}."
|
|
||||||
f"{previous_policy[:12]}-{self.policy_sha256[:12]}."
|
|
||||||
f"{hashlib.sha256(reason_value.encode()).hexdigest()[:12]}.json"
|
|
||||||
)
|
|
||||||
history_path = history_root / history_name
|
|
||||||
history_bytes = _canonical(history_payload) + b"\n"
|
|
||||||
|
|
||||||
fd, raw_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{checked['source_commit']}.rebind.",
|
|
||||||
suffix=".tmp",
|
|
||||||
dir=self.root,
|
|
||||||
)
|
|
||||||
temp_path = Path(raw_name)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd, 0o640)
|
|
||||||
with os.fdopen(fd, "wb", closefd=True) as handle:
|
|
||||||
handle.write(encoded)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
if history_path.exists():
|
|
||||||
_secure_stat(
|
|
||||||
history_path,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=False,
|
|
||||||
)
|
|
||||||
if history_path.read_bytes() != history_bytes:
|
|
||||||
raise ReleaseSourceAdmissionError(
|
|
||||||
"SOURCE_ADMISSION_REBIND_HISTORY_CONFLICT"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
history_fd = os.open(
|
|
||||||
history_path,
|
|
||||||
os.O_WRONLY
|
|
||||||
| os.O_CREAT
|
|
||||||
| os.O_EXCL
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0),
|
|
||||||
0o640,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
os.write(history_fd, history_bytes)
|
|
||||||
os.fsync(history_fd)
|
|
||||||
finally:
|
|
||||||
os.close(history_fd)
|
|
||||||
os.replace(temp_path, path)
|
|
||||||
os.chmod(path, 0o640)
|
|
||||||
directory_fd = os.open(
|
|
||||||
self.root,
|
|
||||||
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
finally:
|
|
||||||
os.close(directory_fd)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.close(fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
temp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
return dict(checked)
|
|
||||||
|
|
||||||
def lookup_source(self, source_commit: str) -> dict[str, Any] | None:
|
|
||||||
path = self._path(source_commit)
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
_secure_stat(
|
|
||||||
path,
|
|
||||||
expected_owner_uid=self.expected_owner_uid,
|
|
||||||
directory=False,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
if len(raw) > 64 * 1024:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
|
||||||
decoded = json.loads(raw)
|
|
||||||
except ReleaseSourceAdmissionError:
|
|
||||||
raise
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
|
||||||
return _validate_record(
|
|
||||||
decoded,
|
|
||||||
repository=self.repository,
|
|
||||||
policy_sha256=self.policy_sha256,
|
|
||||||
hmac_key=self.hmac_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_recipe(
|
|
||||||
self,
|
|
||||||
version: str,
|
|
||||||
candidate_id: str | None,
|
|
||||||
source_commit: str,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
record = self.lookup_source(source_commit)
|
|
||||||
if record is None:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_NOT_FOUND")
|
|
||||||
if record["version"] != version:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_VERSION_MISMATCH")
|
|
||||||
supplied_candidate = str(candidate_id or "")
|
|
||||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
|
||||||
return {
|
|
||||||
"mode": RECIPE_MODE,
|
|
||||||
"task_id": record["task_id"],
|
|
||||||
"candidate_id": record["candidate_id"],
|
|
||||||
"base_oid": record["base_oid"],
|
|
||||||
"source_commit": record["source_commit"],
|
|
||||||
"version": record["version"],
|
|
||||||
"purpose": record["purpose"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class GiteaReleaseSourceBackend:
|
|
||||||
transport: GiteaMachineTransport
|
|
||||||
git_dir: Path
|
|
||||||
|
|
||||||
def authoritative_main_oid(self) -> str:
|
|
||||||
return authoritative_head_oid(git_dir=self.git_dir)
|
|
||||||
|
|
||||||
def remote_ref_oid(self, source_ref: str) -> str:
|
|
||||||
ref = _source_ref(source_ref)
|
|
||||||
branch = ref[len(_REF_PREFIX):]
|
|
||||||
value = self.transport.read_branch(branch)
|
|
||||||
if value is None:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_REF_NOT_FOUND")
|
|
||||||
return _oid(value, "remote_ref_oid")
|
|
||||||
|
|
||||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str:
|
|
||||||
ref = _source_ref(source_ref)
|
|
||||||
branch = ref[len(_REF_PREFIX):]
|
|
||||||
try:
|
|
||||||
value = self.transport.fetch_branch_exact(
|
|
||||||
branch,
|
|
||||||
_oid(source_commit, "source_commit"),
|
|
||||||
)
|
|
||||||
except GiteaTransportError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_FAILED") from exc
|
|
||||||
return _oid(value, "fetched_source_commit")
|
|
||||||
|
|
||||||
def tree_oid(self, source_commit: str) -> str:
|
|
||||||
source = _oid(source_commit, "source_commit")
|
|
||||||
try:
|
|
||||||
result = run_authoritative_git(
|
|
||||||
["rev-parse", "--verify", f"{source}^{{tree}}"],
|
|
||||||
git_dir=self.git_dir,
|
|
||||||
)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_COMMIT_NOT_FOUND") from exc
|
|
||||||
return _oid(result.stdout.strip().lower(), "tree_oid")
|
|
||||||
|
|
||||||
def require_lineage(self, base_oid: str, source_commit: str) -> None:
|
|
||||||
try:
|
|
||||||
authoritative_lineage(
|
|
||||||
_oid(base_oid, "base_oid"),
|
|
||||||
_oid(source_commit, "source_commit"),
|
|
||||||
git_dir=self.git_dir,
|
|
||||||
)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_LINEAGE_MISMATCH") from exc
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseSourceAdmitter:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
store: ReleaseSourceAdmissionStore,
|
|
||||||
backend: ReleaseSourceBackend,
|
|
||||||
*,
|
|
||||||
purpose: str = "AIOS_TRUSTED_RELEASE_BUILDER",
|
|
||||||
) -> None:
|
|
||||||
if not store.writable:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
|
||||||
self.store = store
|
|
||||||
self.backend = backend
|
|
||||||
self.purpose = _identifier(purpose, "purpose")
|
|
||||||
|
|
||||||
def admit(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
version: str,
|
|
||||||
task_id: str,
|
|
||||||
source_ref: str,
|
|
||||||
source_commit: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
version_value = _version(version)
|
|
||||||
task_value = _identifier(task_id, "task_id")
|
|
||||||
ref_value = _source_ref(source_ref)
|
|
||||||
source_value = _oid(source_commit, "source_commit")
|
|
||||||
|
|
||||||
remote_before = self.backend.remote_ref_oid(ref_value)
|
|
||||||
if remote_before != source_value:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
|
||||||
base_oid = _oid(self.backend.authoritative_main_oid(), "base_oid")
|
|
||||||
fetched = _oid(
|
|
||||||
self.backend.fetch_exact_ref(ref_value, source_value),
|
|
||||||
"fetched_source_commit",
|
|
||||||
)
|
|
||||||
if fetched != source_value:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_MISMATCH")
|
|
||||||
remote_after = self.backend.remote_ref_oid(ref_value)
|
|
||||||
if remote_after != source_value:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
|
||||||
self.backend.require_lineage(base_oid, source_value)
|
|
||||||
tree_oid = _oid(self.backend.tree_oid(source_value), "tree_oid")
|
|
||||||
|
|
||||||
candidate_id = f"rc-{_version_slug(version_value)}-{source_value[:12]}"
|
|
||||||
admission_id = "source-admission-" + hashlib.sha256(
|
|
||||||
f"{self.store.repository}\0{version_value}\0{source_value}\0{ref_value}".encode("utf-8")
|
|
||||||
).hexdigest()[:24]
|
|
||||||
record = {
|
|
||||||
"schema": SCHEMA,
|
|
||||||
"admission_id": admission_id,
|
|
||||||
"repository": self.store.repository,
|
|
||||||
"task_id": task_value,
|
|
||||||
"version": version_value,
|
|
||||||
"candidate_id": candidate_id,
|
|
||||||
"base_oid": base_oid,
|
|
||||||
"source_ref": ref_value,
|
|
||||||
"source_commit": source_value,
|
|
||||||
"remote_ref_oid": remote_after,
|
|
||||||
"tree_oid": tree_oid,
|
|
||||||
"purpose": self.purpose,
|
|
||||||
"policy_sha256": self.store.policy_sha256,
|
|
||||||
"admitted_at": _now(),
|
|
||||||
}
|
|
||||||
return self.store.register(record)
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256_file(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def source_admission_policy_sha256(path: Path) -> str:
|
|
||||||
"""Canonical Source Admission policy binding: exact policy file bytes.
|
|
||||||
|
|
||||||
Builder/controller readiness may use a canonical JSON digest internally,
|
|
||||||
but Source Admission records are intentionally bound to the immutable policy
|
|
||||||
file bytes. Every admission producer/consumer must call this helper.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
return _sha256_file(Path(path))
|
|
||||||
except OSError as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def open_source_admission_store(
|
|
||||||
root: str | os.PathLike[str],
|
|
||||||
*,
|
|
||||||
repository: str,
|
|
||||||
policy: Path,
|
|
||||||
hmac_key: bytes,
|
|
||||||
expected_owner_uid: int | None,
|
|
||||||
writable: bool,
|
|
||||||
) -> ReleaseSourceAdmissionStore:
|
|
||||||
"""The only supported constructor for policy-bound Source Admission stores."""
|
|
||||||
|
|
||||||
return ReleaseSourceAdmissionStore(
|
|
||||||
root,
|
|
||||||
repository=repository,
|
|
||||||
policy_sha256=source_admission_policy_sha256(policy),
|
|
||||||
hmac_key=hmac_key,
|
|
||||||
expected_owner_uid=expected_owner_uid,
|
|
||||||
writable=writable,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_isolated_peer_policy_delta(
|
|
||||||
previous_policy: Path,
|
|
||||||
policy: Path,
|
|
||||||
isolated_peer_cgroup: str,
|
|
||||||
) -> tuple[str, str, str]:
|
|
||||||
"""Allow exactly one temporary isolated peer add/remove and no other drift."""
|
|
||||||
|
|
||||||
peer = str(isolated_peer_cgroup or "").strip()
|
|
||||||
if (
|
|
||||||
not peer.startswith("/system.slice/")
|
|
||||||
or not peer.endswith(".service")
|
|
||||||
or "*" in peer
|
|
||||||
or ".." in peer
|
|
||||||
):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
|
||||||
try:
|
|
||||||
from aios_trusted_candidate_builder import load_builder_policy
|
|
||||||
|
|
||||||
before = dict(load_builder_policy(Path(previous_policy)))
|
|
||||||
after = dict(load_builder_policy(Path(policy)))
|
|
||||||
except Exception as exc:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_INVALID") from exc
|
|
||||||
|
|
||||||
before_cgroups = tuple(before.get("allowed_peer_cgroups") or ())
|
|
||||||
after_cgroups = tuple(after.get("allowed_peer_cgroups") or ())
|
|
||||||
if len(set(before_cgroups)) != len(before_cgroups) or len(set(after_cgroups)) != len(after_cgroups):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
|
||||||
if any("*" in value or ".." in value for value in (*before_cgroups, *after_cgroups)):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
|
||||||
|
|
||||||
before_without = dict(before)
|
|
||||||
after_without = dict(after)
|
|
||||||
before_without["allowed_peer_cgroups"] = []
|
|
||||||
after_without["allowed_peer_cgroups"] = []
|
|
||||||
if _canonical(before_without) != _canonical(after_without):
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_REBIND_SCOPE_CHANGED")
|
|
||||||
|
|
||||||
before_set, after_set = set(before_cgroups), set(after_cgroups)
|
|
||||||
added = after_set - before_set
|
|
||||||
removed = before_set - after_set
|
|
||||||
if added == {peer} and not removed:
|
|
||||||
direction = "ADD_ISOLATED_PEER"
|
|
||||||
elif removed == {peer} and not added:
|
|
||||||
direction = "REMOVE_ISOLATED_PEER"
|
|
||||||
else:
|
|
||||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_DELTA_INVALID")
|
|
||||||
return (
|
|
||||||
source_admission_policy_sha256(previous_policy),
|
|
||||||
source_admission_policy_sha256(policy),
|
|
||||||
direction,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
admit = subparsers.add_parser("admit")
|
|
||||||
admit.add_argument("--version", required=True)
|
|
||||||
admit.add_argument("--task-id", required=True)
|
|
||||||
admit.add_argument("--source-ref", required=True)
|
|
||||||
admit.add_argument("--source-commit", required=True)
|
|
||||||
admit.add_argument("--policy", required=True, type=Path)
|
|
||||||
admit.add_argument("--store-root", required=True, type=Path)
|
|
||||||
|
|
||||||
rebind = subparsers.add_parser("rebind-policy")
|
|
||||||
rebind.add_argument("--source-commit", required=True)
|
|
||||||
rebind.add_argument("--previous-policy", required=True, type=Path)
|
|
||||||
rebind.add_argument("--policy", required=True, type=Path)
|
|
||||||
rebind.add_argument("--isolated-peer-cgroup", required=True)
|
|
||||||
rebind.add_argument("--expected-admission-id", required=True)
|
|
||||||
rebind.add_argument("--reason", required=True)
|
|
||||||
rebind.add_argument("--store-root", required=True, type=Path)
|
|
||||||
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
repository = os.environ.get("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
|
||||||
if not repository:
|
|
||||||
raise SystemExit("AIOS_GITOPS_REPOSITORY_IDENTITY is required")
|
|
||||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
|
||||||
if not credentials:
|
|
||||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
|
||||||
try:
|
|
||||||
admission_hmac = (
|
|
||||||
Path(credentials) / "source_admission_hmac"
|
|
||||||
).read_bytes().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise SystemExit(
|
|
||||||
"source_admission_hmac credential is required"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if arguments.command == "rebind-policy":
|
|
||||||
try:
|
|
||||||
previous_sha, policy_sha, direction = (
|
|
||||||
validate_isolated_peer_policy_delta(
|
|
||||||
arguments.previous_policy,
|
|
||||||
arguments.policy,
|
|
||||||
arguments.isolated_peer_cgroup,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
store = open_source_admission_store(
|
|
||||||
arguments.store_root,
|
|
||||||
repository=repository,
|
|
||||||
policy=arguments.policy,
|
|
||||||
hmac_key=admission_hmac,
|
|
||||||
expected_owner_uid=(
|
|
||||||
os.geteuid() if hasattr(os, "geteuid") else None
|
|
||||||
),
|
|
||||||
writable=True,
|
|
||||||
)
|
|
||||||
result = store.rebind_policy(
|
|
||||||
arguments.source_commit,
|
|
||||||
previous_policy_sha256=previous_sha,
|
|
||||||
reason=f"{arguments.reason}:{direction}",
|
|
||||||
expected_admission_id=arguments.expected_admission_id,
|
|
||||||
)
|
|
||||||
except ReleaseSourceAdmissionError as exc:
|
|
||||||
raise SystemExit(str(exc)) from exc
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
**result,
|
|
||||||
"rebind_direction": direction,
|
|
||||||
"previous_policy_sha256": previous_sha,
|
|
||||||
"policy_sha256": policy_sha,
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
api_base = os.environ.get("AIOS_GITEA_API_BASE", "")
|
|
||||||
remote_url = os.environ.get("AIOS_GITEA_REMOTE_URL", "")
|
|
||||||
raw_git_dir = os.environ.get("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")
|
|
||||||
if not api_base or not remote_url or not raw_git_dir:
|
|
||||||
raise SystemExit("release source admission environment is incomplete")
|
|
||||||
git_dir = Path(raw_git_dir)
|
|
||||||
transport = GiteaMachineTransport(
|
|
||||||
api_base=api_base,
|
|
||||||
repository_id=repository,
|
|
||||||
remote_url=remote_url,
|
|
||||||
authority_git_dir=git_dir,
|
|
||||||
credential_name=os.environ.get(
|
|
||||||
"AIOS_GITEA_MACHINE_CREDENTIAL_NAME",
|
|
||||||
"gitea-machine-token",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
store = open_source_admission_store(
|
|
||||||
arguments.store_root,
|
|
||||||
repository=repository,
|
|
||||||
policy=arguments.policy,
|
|
||||||
hmac_key=admission_hmac,
|
|
||||||
expected_owner_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
||||||
writable=True,
|
|
||||||
)
|
|
||||||
result = ReleaseSourceAdmitter(
|
|
||||||
store,
|
|
||||||
GiteaReleaseSourceBackend(transport=transport, git_dir=git_dir),
|
|
||||||
).admit(
|
|
||||||
version=arguments.version,
|
|
||||||
task_id=arguments.task_id,
|
|
||||||
source_ref=arguments.source_ref,
|
|
||||||
source_commit=arguments.source_commit,
|
|
||||||
)
|
|
||||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
@@ -25,12 +25,6 @@ from typing import Any, Callable, Iterable, Mapping, Protocol
|
|||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
from release_migration_plan import (
|
|
||||||
MigrationPlan,
|
|
||||||
MigrationPlanError,
|
|
||||||
MigrationPreSwitchValidator,
|
|
||||||
)
|
|
||||||
|
|
||||||
from silent_release_controller import (
|
from silent_release_controller import (
|
||||||
CANDIDATE_ID_RE,
|
CANDIDATE_ID_RE,
|
||||||
OID_RE,
|
OID_RE,
|
||||||
@@ -568,7 +562,6 @@ class ReleasePlan:
|
|||||||
tag: TagPlan
|
tag: TagPlan
|
||||||
finalization: FinalizationPlan
|
finalization: FinalizationPlan
|
||||||
policy_sha256: str
|
policy_sha256: str
|
||||||
migration: MigrationPlan | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_validate_sha("policy_sha256", self.policy_sha256)
|
_validate_sha("policy_sha256", self.policy_sha256)
|
||||||
@@ -595,32 +588,16 @@ class ReleasePlan:
|
|||||||
!= self.finalization.expected_previous_baseline_sha256
|
!= self.finalization.expected_previous_baseline_sha256
|
||||||
):
|
):
|
||||||
raise ValueError("previous baseline is inconsistent")
|
raise ValueError("previous baseline is inconsistent")
|
||||||
if self.migration is not None:
|
|
||||||
migration = self.migration
|
|
||||||
if (
|
|
||||||
migration.version != self.tag.version
|
|
||||||
or migration.source_commit != self.deployment.source_commit
|
|
||||||
or migration.tree_oid != self.deployment.tree_oid
|
|
||||||
or migration.repository_identity
|
|
||||||
!= self.deployment.repository_identity
|
|
||||||
):
|
|
||||||
raise ValueError("migration plan release binding is inconsistent")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def canonical_payload(self) -> dict[str, Any]:
|
def canonical_payload(self) -> dict[str, Any]:
|
||||||
# Legacy release-plan bytes/hashes remain unchanged when migration is
|
return {
|
||||||
# not required. A cross-code-line migration is an optional signed
|
|
||||||
# sub-contract and is therefore present only when required.
|
|
||||||
payload = {
|
|
||||||
"deployment": self.deployment.canonical_payload,
|
"deployment": self.deployment.canonical_payload,
|
||||||
"finalization": self.finalization.canonical_payload,
|
"finalization": self.finalization.canonical_payload,
|
||||||
"policy_sha256": self.policy_sha256,
|
"policy_sha256": self.policy_sha256,
|
||||||
"tag": self.tag.canonical_payload,
|
"tag": self.tag.canonical_payload,
|
||||||
}
|
}
|
||||||
if self.migration is not None:
|
|
||||||
payload["migration"] = self.migration.to_dict()
|
|
||||||
return payload
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sha256(self) -> str:
|
def sha256(self) -> str:
|
||||||
@@ -635,7 +612,6 @@ class ReleasePlan:
|
|||||||
def from_dict(cls, payload: Mapping[str, Any]) -> "ReleasePlan":
|
def from_dict(cls, payload: Mapping[str, Any]) -> "ReleasePlan":
|
||||||
values = dict(payload)
|
values = dict(payload)
|
||||||
values.pop("sha256", None)
|
values.pop("sha256", None)
|
||||||
migration_value = values.get("migration")
|
|
||||||
return cls(
|
return cls(
|
||||||
deployment=DeploymentPlan.from_dict(values["deployment"]),
|
deployment=DeploymentPlan.from_dict(values["deployment"]),
|
||||||
tag=TagPlan.from_dict(values["tag"]),
|
tag=TagPlan.from_dict(values["tag"]),
|
||||||
@@ -643,11 +619,6 @@ class ReleasePlan:
|
|||||||
values["finalization"]
|
values["finalization"]
|
||||||
),
|
),
|
||||||
policy_sha256=values["policy_sha256"],
|
policy_sha256=values["policy_sha256"],
|
||||||
migration=(
|
|
||||||
MigrationPlan.from_dict(migration_value)
|
|
||||||
if isinstance(migration_value, Mapping)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1244,19 +1215,6 @@ class TrustedCandidateBundle:
|
|||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
"trusted candidate and release plan are inconsistent"
|
"trusted candidate and release plan are inconsistent"
|
||||||
)
|
)
|
||||||
if plan.migration is not None:
|
|
||||||
try:
|
|
||||||
plan.migration.assert_release_binding(
|
|
||||||
version=plan.tag.version,
|
|
||||||
candidate_id=candidate.candidate_id,
|
|
||||||
source_commit=candidate.source_commit,
|
|
||||||
tree_oid=candidate.tree_oid,
|
|
||||||
repository_identity=plan.deployment.repository_identity,
|
|
||||||
)
|
|
||||||
except MigrationPlanError as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"trusted candidate migration binding is invalid"
|
|
||||||
) from exc
|
|
||||||
if attestation_verifier is not None:
|
if attestation_verifier is not None:
|
||||||
if self.attestation is None:
|
if self.attestation is None:
|
||||||
raise CapabilityAuthorizationError(
|
raise CapabilityAuthorizationError(
|
||||||
@@ -3132,7 +3090,6 @@ class ReleaseOrchestrator:
|
|||||||
runtime_rollback: Callable[..., Mapping[str, Any]],
|
runtime_rollback: Callable[..., Mapping[str, Any]],
|
||||||
tag_publish: HardenedTagController,
|
tag_publish: HardenedTagController,
|
||||||
finalizer: GovernedFinalizationCoordinator,
|
finalizer: GovernedFinalizationCoordinator,
|
||||||
migration_pre_switch_validator: MigrationPreSwitchValidator | None = None,
|
|
||||||
):
|
):
|
||||||
self.candidates = candidates
|
self.candidates = candidates
|
||||||
self.plans = plans
|
self.plans = plans
|
||||||
@@ -3147,7 +3104,6 @@ class ReleaseOrchestrator:
|
|||||||
self.runtime_rollback = runtime_rollback
|
self.runtime_rollback = runtime_rollback
|
||||||
self.tag_publish = tag_publish
|
self.tag_publish = tag_publish
|
||||||
self.finalizer = finalizer
|
self.finalizer = finalizer
|
||||||
self.migration_pre_switch_validator = migration_pre_switch_validator
|
|
||||||
|
|
||||||
def _transition(
|
def _transition(
|
||||||
self,
|
self,
|
||||||
@@ -3348,14 +3304,6 @@ class ReleaseOrchestrator:
|
|||||||
plan.deployment.repository_identity
|
plan.deployment.repository_identity
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if plan.migration is not None:
|
|
||||||
validator = self.migration_pre_switch_validator
|
|
||||||
if validator is None:
|
|
||||||
raise StaleApprovalError(
|
|
||||||
"MIGRATION_PLAN_REQUIRED: "
|
|
||||||
"migration pre-switch validator is unavailable"
|
|
||||||
)
|
|
||||||
validator.validate(plan.migration)
|
|
||||||
saga = self._transition(
|
saga = self._transition(
|
||||||
saga,
|
saga,
|
||||||
SagaState.SWITCH_PENDING,
|
SagaState.SWITCH_PENDING,
|
||||||
@@ -3583,7 +3531,6 @@ class SilentReleaseActionService:
|
|||||||
trusted_policy: TrustedReleasePolicy | None = None,
|
trusted_policy: TrustedReleasePolicy | None = None,
|
||||||
orchestrator: ReleaseOrchestrator | None = None,
|
orchestrator: ReleaseOrchestrator | None = None,
|
||||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
||||||
source_admission_resolver: Callable[[str], Mapping[str, Any] | None] | None = None,
|
|
||||||
allow_legacy_candidate_input: bool = False,
|
allow_legacy_candidate_input: bool = False,
|
||||||
expected_repository: str = "",
|
expected_repository: str = "",
|
||||||
policy_sha256: str = "",
|
policy_sha256: str = "",
|
||||||
@@ -3600,7 +3547,6 @@ class SilentReleaseActionService:
|
|||||||
self.trusted_policy = trusted_policy
|
self.trusted_policy = trusted_policy
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
self.builder_readiness_probe = builder_readiness_probe
|
self.builder_readiness_probe = builder_readiness_probe
|
||||||
self.source_admission_resolver = source_admission_resolver
|
|
||||||
self.allow_legacy_candidate_input = bool(
|
self.allow_legacy_candidate_input = bool(
|
||||||
allow_legacy_candidate_input
|
allow_legacy_candidate_input
|
||||||
)
|
)
|
||||||
@@ -3692,40 +3638,6 @@ class SilentReleaseActionService:
|
|||||||
version = str(payload.get("version") or "")
|
version = str(payload.get("version") or "")
|
||||||
if version:
|
if version:
|
||||||
_validate_identifier("version", version)
|
_validate_identifier("version", version)
|
||||||
|
|
||||||
source_admission: dict[str, Any] | None = None
|
|
||||||
resolver = self.source_admission_resolver
|
|
||||||
if resolver is not None:
|
|
||||||
try:
|
|
||||||
raw_admission = resolver(source_commit)
|
|
||||||
except Exception as exc:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"release source admission is invalid"
|
|
||||||
) from exc
|
|
||||||
if raw_admission is not None:
|
|
||||||
source_admission = dict(raw_admission)
|
|
||||||
admitted_source = str(
|
|
||||||
source_admission.get("source_commit") or ""
|
|
||||||
)
|
|
||||||
admitted_version = str(source_admission.get("version") or "")
|
|
||||||
admission_id = str(
|
|
||||||
source_admission.get("admission_id") or ""
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
admitted_source != source_commit
|
|
||||||
or not admitted_version
|
|
||||||
or not admission_id
|
|
||||||
):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"release source admission is invalid"
|
|
||||||
)
|
|
||||||
_validate_identifier("version", admitted_version)
|
|
||||||
_validate_identifier("admission_id", admission_id)
|
|
||||||
if version and version != admitted_version:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"release source admission version mismatch"
|
|
||||||
)
|
|
||||||
version = admitted_version
|
|
||||||
intent_id = "intent-" + hashlib.sha256(
|
intent_id = "intent-" + hashlib.sha256(
|
||||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||||
).hexdigest()[:24]
|
).hexdigest()[:24]
|
||||||
@@ -3745,83 +3657,47 @@ class SilentReleaseActionService:
|
|||||||
}
|
}
|
||||||
if version:
|
if version:
|
||||||
record["version"] = version
|
record["version"] = version
|
||||||
if source_admission is not None:
|
|
||||||
record["source_admission_id"] = source_admission["admission_id"]
|
|
||||||
if was_existing:
|
if was_existing:
|
||||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
prerequisite_states = {
|
|
||||||
"SOURCE_ADMISSION_REQUIRED",
|
|
||||||
"MIGRATION_PLAN_REQUIRED",
|
|
||||||
}
|
|
||||||
existing_status = str(existing.get("status") or "")
|
|
||||||
existing_version = str(existing.get("version") or "")
|
|
||||||
existing_admission = str(existing.get("source_admission_id") or "")
|
|
||||||
incoming_admission = str(record.get("source_admission_id") or "")
|
|
||||||
if (
|
if (
|
||||||
existing.get("event_id") != event_id
|
existing.get("event_id") != event_id
|
||||||
or existing.get("source_commit") != source_commit
|
or existing.get("source_commit") != source_commit
|
||||||
or (existing_version and existing_version != version)
|
or str(existing.get("version") or "") != version
|
||||||
or (existing_admission and existing_admission != incoming_admission)
|
|
||||||
):
|
):
|
||||||
raise SagaConflict(
|
raise SagaConflict(
|
||||||
"release intent id is bound to different evidence"
|
"release intent id is bound to different evidence"
|
||||||
)
|
)
|
||||||
# A source-admission prerequisite may initially know only the
|
if existing.get("status") == "BUILDING":
|
||||||
# event/source identity. Once the verified admission becomes
|
try:
|
||||||
# available, bind the previously missing version/admission ID to
|
started_at = _parse_utc(
|
||||||
# the same durable intent. Existing non-empty evidence can never
|
str(existing.get("attempt_started_at") or "")
|
||||||
# be replaced.
|
|
||||||
if (
|
|
||||||
(not existing_version and version)
|
|
||||||
or (not existing_admission and incoming_admission)
|
|
||||||
):
|
|
||||||
if existing_status != "SOURCE_ADMISSION_REQUIRED":
|
|
||||||
raise SagaConflict(
|
|
||||||
"release intent prerequisite binding changed"
|
|
||||||
)
|
)
|
||||||
if version:
|
elapsed = (
|
||||||
existing["version"] = version
|
datetime.now(timezone.utc) - started_at
|
||||||
if incoming_admission:
|
).total_seconds()
|
||||||
existing["source_admission_id"] = incoming_admission
|
except (TypeError, ValueError):
|
||||||
existing["updated_at"] = utc_now()
|
elapsed = self.builder_attempt_timeout_seconds + 1
|
||||||
_durable_json(path, existing)
|
if elapsed <= self.builder_attempt_timeout_seconds:
|
||||||
if existing_status in prerequisite_states:
|
|
||||||
# Prerequisite blockers are not builder attempts. Replaying
|
|
||||||
# the same intent after Source Admission or Migration Plan
|
|
||||||
# material appears is the expected autonomous recovery path.
|
|
||||||
record = dict(existing)
|
|
||||||
else:
|
|
||||||
if existing.get("status") == "BUILDING":
|
|
||||||
try:
|
|
||||||
started_at = _parse_utc(
|
|
||||||
str(existing.get("attempt_started_at") or "")
|
|
||||||
)
|
|
||||||
elapsed = (
|
|
||||||
datetime.now(timezone.utc) - started_at
|
|
||||||
).total_seconds()
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
|
||||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
|
||||||
return self._intent_response(existing, idempotent=True)
|
|
||||||
existing.update(
|
|
||||||
{
|
|
||||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
|
||||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
|
||||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
|
||||||
"retryable": int(existing.get("attempt_count") or 0)
|
|
||||||
< self.max_builder_attempts,
|
|
||||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
|
||||||
"updated_at": utc_now(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, existing)
|
|
||||||
if (
|
|
||||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
|
||||||
or int(existing.get("attempt_count") or 0)
|
|
||||||
>= self.max_builder_attempts
|
|
||||||
):
|
|
||||||
return self._intent_response(existing, idempotent=True)
|
return self._intent_response(existing, idempotent=True)
|
||||||
record = dict(existing)
|
existing.update(
|
||||||
|
{
|
||||||
|
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||||
|
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||||
|
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
||||||
|
"retryable": int(existing.get("attempt_count") or 0)
|
||||||
|
< self.max_builder_attempts,
|
||||||
|
"status": "BUILDER_RETRYABLE_FAILURE",
|
||||||
|
"updated_at": utc_now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_durable_json(path, existing)
|
||||||
|
if (
|
||||||
|
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
||||||
|
or int(existing.get("attempt_count") or 0)
|
||||||
|
>= self.max_builder_attempts
|
||||||
|
):
|
||||||
|
return self._intent_response(existing, idempotent=True)
|
||||||
|
record = dict(existing)
|
||||||
if self.candidate_builder is None:
|
if self.candidate_builder is None:
|
||||||
record.update(
|
record.update(
|
||||||
{
|
{
|
||||||
@@ -3866,36 +3742,14 @@ class SilentReleaseActionService:
|
|||||||
)
|
)
|
||||||
_durable_json(path, record)
|
_durable_json(path, record)
|
||||||
return self._intent_response(record, idempotent=False)
|
return self._intent_response(record, idempotent=False)
|
||||||
except CapabilityAuthorizationError as exc:
|
except CapabilityAuthorizationError:
|
||||||
prerequisite_code = {
|
record.update(
|
||||||
"release source admission required": "SOURCE_ADMISSION_REQUIRED",
|
{
|
||||||
"migration plan required": "MIGRATION_PLAN_REQUIRED",
|
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||||
}.get(str(exc))
|
"retryable": False,
|
||||||
if prerequisite_code is not None:
|
"status": "BUILDER_TERMINAL_FAILURE",
|
||||||
record.update(
|
}
|
||||||
{
|
)
|
||||||
# Prerequisite discovery is a control-plane retry, not
|
|
||||||
# a business/builder execution attempt.
|
|
||||||
"attempt_count": max(
|
|
||||||
0, int(record.get("attempt_count") or 0) - 1
|
|
||||||
),
|
|
||||||
"failure_code": prerequisite_code,
|
|
||||||
"retryable": True,
|
|
||||||
"status": prerequisite_code,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"failure_code": (
|
|
||||||
"MIGRATION_PLAN_INVALID"
|
|
||||||
if str(exc) == "migration plan invalid"
|
|
||||||
else "TRUSTED_BUILDER_REJECTED"
|
|
||||||
),
|
|
||||||
"retryable": False,
|
|
||||||
"status": "BUILDER_TERMINAL_FAILURE",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_durable_json(path, record)
|
_durable_json(path, record)
|
||||||
return self._intent_response(record, idempotent=False)
|
return self._intent_response(record, idempotent=False)
|
||||||
except (ConnectionError, OSError, TimeoutError):
|
except (ConnectionError, OSError, TimeoutError):
|
||||||
|
|||||||
@@ -105,28 +105,6 @@ pre { white-space: pre-wrap; word-break: break-word; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Security: all dynamic values (API/git/agent/owner-gate) are rendered with
|
|
||||||
// textContent / DOM APIs. innerHTML is never used to inject untrusted data.
|
|
||||||
function clearNode(node) {
|
|
||||||
if (!node) return;
|
|
||||||
while (node.firstChild) node.removeChild(node.firstChild);
|
|
||||||
}
|
|
||||||
function setText(id, text) {
|
|
||||||
const n = document.getElementById(id);
|
|
||||||
if (n) n.textContent = text;
|
|
||||||
}
|
|
||||||
function addClass(node, cls) { if (node) node.classList.add(cls); }
|
|
||||||
function div(cls) { const d = document.createElement('div'); if (cls) addClass(d, cls); return d; }
|
|
||||||
function span(cls, text) { const s = document.createElement('span'); if (cls) addClass(s, cls); if (text != null) s.textContent = text; return s; }
|
|
||||||
|
|
||||||
// Centralized CSRF token source: server-issued meta tag, or double-submit cookie.
|
|
||||||
function csrfToken() {
|
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (meta && meta.content) return meta.content;
|
|
||||||
const m = document.cookie.match(/(?:^|;\s*)aios_csrf=([^;]+)/);
|
|
||||||
return m ? decodeURIComponent(m[1]) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const r = await fetch('/admin/ai-evolution/api/data');
|
const r = await fetch('/admin/ai-evolution/api/data');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
@@ -146,204 +124,130 @@ async function fetchData() {
|
|||||||
|
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
const el = document.getElementById('versions-list');
|
const el = document.getElementById('versions-list');
|
||||||
clearNode(el);
|
el.innerHTML = '<div class="loading">Loading versions...</div>';
|
||||||
const loading = div('loading'); loading.textContent = 'Loading versions...'; el.appendChild(loading);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/admin/ai-evolution/api/versions');
|
const r = await fetch('/admin/ai-evolution/api/versions');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
clearNode(el);
|
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
let html = '<div class="grid">';
|
||||||
const grid = div('grid');
|
|
||||||
for (const v of data.versions || []) {
|
for (const v of data.versions || []) {
|
||||||
const card = div('card');
|
html += '<div class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
|
||||||
const t = div('card-title'); t.textContent = v.tag || '';
|
|
||||||
const val = div('card-value'); val.textContent = v.version || v.tag || '';
|
|
||||||
card.appendChild(t); card.appendChild(val); grid.appendChild(card);
|
|
||||||
}
|
}
|
||||||
el.appendChild(grid);
|
html += '</div>';
|
||||||
} catch (e) {
|
el.innerHTML = html;
|
||||||
const err = div('error'); err.textContent = 'Failed to load versions: ' + e.message; el.appendChild(err);
|
} catch(e) {
|
||||||
|
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCommands() {
|
async function loadCommands() {
|
||||||
const el = document.getElementById('commands-list');
|
const el = document.getElementById('commands-list');
|
||||||
clearNode(el);
|
el.innerHTML = '<div class="loading">Loading commands...</div>';
|
||||||
const loading = div('loading'); loading.textContent = 'Loading commands...'; el.appendChild(loading);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/admin/ai-evolution/api/data');
|
const r = await fetch('/admin/ai-evolution/api/data');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
const cmds = data.commands || [];
|
let cmds = data.commands || [];
|
||||||
clearNode(el);
|
let html = '<div style="display:flex;gap:24px;margin-bottom:16px;">';
|
||||||
const wrapper = div('');
|
html += '<div><div class="card"><div class="card-title">Total</div><div class="card-value">' + cmds.length + '</div></div></div>';
|
||||||
wrapper.style.display = 'flex'; wrapper.style.gap = '24px'; wrapper.style.marginBottom = '16px';
|
html += '<div><div class="card"><div class="card-title">By Category</div>';
|
||||||
const totalCard = div('card');
|
|
||||||
const tcTitle = div('card-title'); tcTitle.textContent = 'Total';
|
|
||||||
const tcVal = div('card-value'); tcVal.textContent = String(cmds.length);
|
|
||||||
totalCard.appendChild(tcTitle); totalCard.appendChild(tcVal); wrapper.appendChild(totalCard);
|
|
||||||
const catCard = div('card');
|
|
||||||
const ccTitle = div('card-title'); ccTitle.textContent = 'By Category';
|
|
||||||
catCard.appendChild(ccTitle);
|
|
||||||
const cats = {};
|
const cats = {};
|
||||||
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
|
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
|
||||||
for (const [k, v] of Object.entries(cats)) {
|
for (const [k, v] of Object.entries(cats)) html += '<div style="font-size:14px;color:#8b949e;margin:4px 0;">' + k + ': ' + v + '</div>';
|
||||||
const line = div(''); line.style.fontSize = '14px'; line.style.color = '#8b949e'; line.style.margin = '4px 0';
|
html += '</div></div></div>';
|
||||||
line.textContent = k + ': ' + v; catCard.appendChild(line);
|
|
||||||
}
|
|
||||||
wrapper.appendChild(catCard); el.appendChild(wrapper);
|
|
||||||
for (const cmd of cmds.slice().reverse()) {
|
for (const cmd of cmds.slice().reverse()) {
|
||||||
const item = div('command-item');
|
html += '<div class="command-item"><div class="meta">' + cmd.category + ' | ' + cmd.filename + '</div><div class="name">' + cmd.title + '</div><div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + cmd.summary + '</div></div>';
|
||||||
const meta = div('meta'); meta.textContent = (cmd.category || '') + ' | ' + (cmd.filename || '');
|
|
||||||
const name = div('name'); name.textContent = cmd.title || '';
|
|
||||||
const summary = div(''); summary.style.fontSize = '13px'; summary.style.color = '#c9d1d9'; summary.style.marginTop = '4px';
|
|
||||||
summary.textContent = cmd.summary || '';
|
|
||||||
item.appendChild(meta); item.appendChild(name); item.appendChild(summary);
|
|
||||||
el.appendChild(item);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
el.innerHTML = html;
|
||||||
const err = div('error'); err.textContent = 'Failed to load commands: ' + e.message; el.appendChild(err);
|
} catch(e) {
|
||||||
|
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
const el = document.getElementById('agents-list');
|
const el = document.getElementById('agents-list');
|
||||||
clearNode(el);
|
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
|
||||||
const loading = div('loading'); loading.textContent = 'Loading agent runs...'; el.appendChild(loading);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/admin/ai-evolution/api/agents');
|
const r = await fetch('/admin/ai-evolution/api/agents');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
clearNode(el);
|
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
let html = '';
|
||||||
const agents = data.agents || [];
|
for (const agent of data.agents || []) {
|
||||||
if (!agents.length) {
|
html += '<div class="agent-item"><div class="agent-title">' + agent.name + ' <span class="state state-' + (agent.status === 'running' ? 'running' : (agent.status === 'ok' ? 'completed' : 'failed')) + '">' + agent.status + '</span></div><div class="agent-meta">' + (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-') + '</div></div>';
|
||||||
const none = div(''); none.style.padding = '24px'; none.style.color = '#8b949e';
|
|
||||||
none.textContent = 'No agent runs recorded yet.'; el.appendChild(none); return;
|
|
||||||
}
|
}
|
||||||
for (const agent of agents) {
|
el.innerHTML = html || '<div style="padding:24px;color:#8b949e;">No agent runs recorded yet.</div>';
|
||||||
const item = div('agent-item');
|
} catch(e) {
|
||||||
const title = div('agent-title');
|
el.innerHTML = '<div class="error">Failed to load agents: ' + e.message + '</div>';
|
||||||
title.appendChild(document.createTextNode(agent.name || ''));
|
|
||||||
const st = (agent.status === 'running') ? 'running' : ((agent.status === 'ok') ? 'completed' : 'failed');
|
|
||||||
const badge = span('state state-' + st, agent.status || '');
|
|
||||||
title.appendChild(badge);
|
|
||||||
const meta = div('agent-meta');
|
|
||||||
meta.textContent = (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-');
|
|
||||||
item.appendChild(title); item.appendChild(meta); el.appendChild(item);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const err = div('error'); err.textContent = 'Failed to load agents: ' + e.message; el.appendChild(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showPanel(name) {
|
||||||
|
|
||||||
async function loadOwnerGate() {
|
async function loadOwnerGate() {
|
||||||
const el = document.getElementById('arch-owner-content');
|
const el = document.getElementById('arch-owner-content');
|
||||||
clearNode(el);
|
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
|
||||||
const loading = div('loading'); loading.textContent = 'Loading owner gate data...'; el.appendChild(loading);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
clearNode(el);
|
let html = '<div class="row">';
|
||||||
const row = div('row');
|
html += '<div class="col"><div class="card"><div class="card-title">Pending Requests</div><div class="card-value">' + data.pending_count + '</div></div></div>';
|
||||||
const cols = [['Pending Requests', data.pending_count], ['Approved', data.approved_count], ['Rejected', data.rejected_count]];
|
html += '<div class="col"><div class="card"><div class="card-title">Approved</div><div class="card-value">' + data.approved_count + '</div></div></div>';
|
||||||
for (const [label, value] of cols) {
|
html += '<div class="col"><div class="card"><div class="card-title">Rejected</div><div class="card-value">' + data.rejected_count + '</div></div></div>';
|
||||||
const col = div('col'); const card = div('card');
|
html += '</div>';
|
||||||
const t = div('card-title'); t.textContent = label;
|
html += '<div class="card"><div class="card-title">Pending Approval Requests</div>';
|
||||||
const val = div('card-value'); val.textContent = value;
|
if (data.pending_requests && data.pending_requests.length) {
|
||||||
card.appendChild(t); card.appendChild(val); col.appendChild(card); row.appendChild(col);
|
for (const req of data.pending_requests) {
|
||||||
}
|
html += '<div class="command-item">';
|
||||||
el.appendChild(row);
|
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||||
const listCard = div('card');
|
html += '<div class="meta">ID: ' + req.change_id + ' | Risk: ' + req.risk + '</div>';
|
||||||
const lt = div('card-title'); lt.textContent = 'Pending Approval Requests'; listCard.appendChild(lt);
|
html += '<div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + (req.reason || '') + '</div>';
|
||||||
const reqs = data.pending_requests || [];
|
html += '</div>';
|
||||||
if (reqs.length) {
|
|
||||||
for (const req of reqs) {
|
|
||||||
const item = div('command-item');
|
|
||||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
|
||||||
const meta = div('meta'); meta.textContent = 'ID: ' + (req.change_id || '') + ' | Risk: ' + (req.risk || '');
|
|
||||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.marginTop = '4px';
|
|
||||||
reason.textContent = req.reason || '';
|
|
||||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason);
|
|
||||||
listCard.appendChild(item);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const none = div(''); none.style.color = '#8b949e'; none.style.padding = '12px';
|
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</div>';
|
||||||
none.textContent = 'No pending requests.'; listCard.appendChild(none);
|
|
||||||
}
|
}
|
||||||
el.appendChild(listCard);
|
html += '</div>';
|
||||||
} catch (e) {
|
el.innerHTML = html;
|
||||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
} catch(e) {
|
||||||
}
|
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||||
}
|
|
||||||
|
|
||||||
// Real governed approve: posts the decision to the server-side governed endpoint.
|
|
||||||
async function approveDecision(changeId, decision, btn) {
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/admin/ai-evolution/api/owner-gate/decision', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
|
|
||||||
body: JSON.stringify({ change_id: changeId, decision: decision })
|
|
||||||
});
|
|
||||||
const payload = await res.json();
|
|
||||||
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status));
|
|
||||||
await loadApprove();
|
|
||||||
} catch (e) {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
const out = document.getElementById('arch-approve-content');
|
|
||||||
const err = div('error'); err.textContent = 'Decision failed: ' + e.message;
|
|
||||||
if (out) out.appendChild(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadApprove() {
|
async function loadApprove() {
|
||||||
const el = document.getElementById('arch-approve-content');
|
const el = document.getElementById('arch-approve-content');
|
||||||
clearNode(el);
|
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
|
||||||
const loading = div('loading'); loading.textContent = 'Loading pending approvals...'; el.appendChild(loading);
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
clearNode(el);
|
let html = '';
|
||||||
const reqs = data.pending_requests || [];
|
if (data.pending_requests && data.pending_requests.length) {
|
||||||
if (reqs.length) {
|
html += '<div class="card"><div class="card-title">Pending changes requiring your approval</div>';
|
||||||
const card = div('card');
|
for (const req of data.pending_requests) {
|
||||||
const t = div('card-title'); t.textContent = 'Pending changes requiring your approval'; card.appendChild(t);
|
html += '<div class="command-item">';
|
||||||
for (const req of reqs) {
|
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||||
const item = div('command-item');
|
html += '<div class="meta">Risk: <strong>' + req.risk + '</strong></div>';
|
||||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
html += '<div style="font-size:13px;color:#c9d1d9;margin:8px 0;">' + (req.reason || '') + '</div>';
|
||||||
const meta = div('meta');
|
html += '<div style="display:flex;gap:8px;">';
|
||||||
const strongRisk = document.createElement('strong'); strongRisk.textContent = req.risk || '';
|
html += '<button onclick="ownerAction.classList.add('\'approved\');this.disabled=true" style="padding:6px 16px;background:#238636;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">✓ Approve</button>';
|
||||||
meta.appendChild(document.createTextNode('Risk: ')); meta.appendChild(strongRisk);
|
html += '</div></div>';
|
||||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.margin = '8px 0';
|
|
||||||
reason.textContent = req.reason || '';
|
|
||||||
const actions = div(''); actions.style.display = 'flex'; actions.style.gap = '8px';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.textContent = '✓ Approve';
|
|
||||||
btn.style.padding = '6px 16px'; btn.style.background = '#238636'; btn.style.color = '#fff';
|
|
||||||
btn.style.border = 'none'; btn.style.borderRadius = '6px'; btn.style.cursor = 'pointer'; btn.style.fontSize = '13px';
|
|
||||||
const changeId = req.change_id;
|
|
||||||
btn.addEventListener('click', function () { approveDecision(changeId, 'approved', btn); });
|
|
||||||
actions.appendChild(btn);
|
|
||||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason); item.appendChild(actions);
|
|
||||||
card.appendChild(item);
|
|
||||||
}
|
}
|
||||||
el.appendChild(card);
|
html += '</div>';
|
||||||
} else {
|
} else {
|
||||||
const done = div('card');
|
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
|
||||||
const inner = div(''); inner.style.padding = '24px'; inner.style.textAlign = 'center'; inner.style.color = '#8b949e';
|
|
||||||
inner.textContent = '✅ All changes processed. No pending approval requests.';
|
|
||||||
done.appendChild(inner); el.appendChild(done);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
el.innerHTML = html;
|
||||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
} catch(e) {
|
||||||
|
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPanel(name) {
|
|
||||||
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
||||||
document.getElementById('panel-' + name).style.display = '';
|
document.getElementById('panel-' + name).style.display = '';
|
||||||
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
|
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
|
||||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4,['arch-owner']:5,['arch-approve']:6}[name] || 1) + ')').classList.add('active');
|
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4}[name] || 1) + ')').classList.add('active');
|
||||||
if (name === 'overview') fetchData();
|
if (name === 'overview') {
|
||||||
|
document.getElementById('panel-overview').innerHTML = '<div class="main-header"><h2>System Overview</h2><div id="header-status" class="status-badge">Loading...</div></div><div id="loading" class="loading">Loading...</div><div id="error" class="error" style="display:none;"></div><div id="content"><div class="row"><div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div></div><div class="card" style="margin-top:16px"><div class="card-title">Event Bus Status</div><pre id="bus-status">-</pre></div></div>';
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
const loaders = {
|
const loaders = {
|
||||||
'arch-owner': loadOwnerGate,
|
'arch-owner': loadOwnerGate,
|
||||||
'arch-approve': loadApprove,
|
'arch-approve': loadApprove,
|
||||||
|
|||||||
@@ -12,29 +12,6 @@ from .task_lifecycle import TaskLifecycle
|
|||||||
|
|
||||||
lifecycle = TaskLifecycle()
|
lifecycle = TaskLifecycle()
|
||||||
|
|
||||||
# Role must come from an authenticated principal, never from the client body.
|
|
||||||
_ROLE_WHITELIST = {"user", "agent", "executor", "dispatcher", "notifier", "group_admin", "owner"}
|
|
||||||
_authenticator = None # callable(payload) -> {"role": ...} | None, injected by host
|
|
||||||
|
|
||||||
|
|
||||||
def set_authenticator(fn):
|
|
||||||
"""Install a server-side authenticator used to derive the actor role.
|
|
||||||
|
|
||||||
`fn` receives the raw request payload and must return either None (or a
|
|
||||||
dict without a whitelisted "role") for anonymous, or a dict containing a
|
|
||||||
server-verified "role". The client-supplied `payload["role"]` is ignored.
|
|
||||||
"""
|
|
||||||
global _authenticator
|
|
||||||
_authenticator = fn
|
|
||||||
|
|
||||||
|
|
||||||
def effective_role(payload: dict[str, Any]) -> str:
|
|
||||||
if _authenticator is not None:
|
|
||||||
principal = _authenticator(payload)
|
|
||||||
if isinstance(principal, dict) and principal.get("role") in _ROLE_WHITELIST:
|
|
||||||
return principal["role"]
|
|
||||||
return "user"
|
|
||||||
|
|
||||||
|
|
||||||
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
|
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -54,8 +31,7 @@ def build_router():
|
|||||||
|
|
||||||
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
||||||
def action(task_id: str, action: str, payload: dict[str, Any]):
|
def action(task_id: str, action: str, payload: dict[str, Any]):
|
||||||
# Role is derived from the authenticated principal; body role is ignored.
|
return execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
|
||||||
return execute_task_action(payload.get("task", {}), action, effective_role(payload), payload.get("reason"))
|
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .delegation import DelegationEngine
|
from .delegation import DelegationEngine
|
||||||
@@ -24,8 +23,6 @@ from .errors import (
|
|||||||
from .evidence import EvidenceService
|
from .evidence import EvidenceService
|
||||||
from .journal import TaskJournal
|
from .journal import TaskJournal
|
||||||
from .lease import LeaseManager
|
from .lease import LeaseManager
|
||||||
from .lineage_store import TaskLineagePolicy, TaskPlaneLineageCoordinator
|
|
||||||
from .mission import MissionAuthority
|
|
||||||
from .models import (
|
from .models import (
|
||||||
EvidenceEnvelope,
|
EvidenceEnvelope,
|
||||||
ResultEnvelope,
|
ResultEnvelope,
|
||||||
@@ -36,11 +33,6 @@ from .models import (
|
|||||||
validate_identifier,
|
validate_identifier,
|
||||||
)
|
)
|
||||||
from .policy import PolicyEngine
|
from .policy import PolicyEngine
|
||||||
from .root_action_consumer import (
|
|
||||||
RootActionConsumer,
|
|
||||||
RootActionExecutor,
|
|
||||||
RootActionRunResult,
|
|
||||||
)
|
|
||||||
from .store import TaskPlaneStore
|
from .store import TaskPlaneStore
|
||||||
from .worker_registry import WorkerRegistry
|
from .worker_registry import WorkerRegistry
|
||||||
|
|
||||||
@@ -61,15 +53,7 @@ def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class TaskPlaneService:
|
class TaskPlaneService:
|
||||||
def __init__(
|
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
||||||
self,
|
|
||||||
store: TaskPlaneStore,
|
|
||||||
policy: PolicyEngine | None = None,
|
|
||||||
*,
|
|
||||||
lineage_policy: TaskLineagePolicy | None = None,
|
|
||||||
mission_authority: MissionAuthority | None = None,
|
|
||||||
root_action_executor: RootActionExecutor | None = None,
|
|
||||||
):
|
|
||||||
self.store = store
|
self.store = store
|
||||||
self.policy = policy or PolicyEngine(
|
self.policy = policy or PolicyEngine(
|
||||||
execution_enabled=False,
|
execution_enabled=False,
|
||||||
@@ -82,257 +66,30 @@ class TaskPlaneService:
|
|||||||
self.workers = WorkerRegistry(store)
|
self.workers = WorkerRegistry(store)
|
||||||
self.evidence = EvidenceService(store)
|
self.evidence = EvidenceService(store)
|
||||||
self.delegation = DelegationEngine(store)
|
self.delegation = DelegationEngine(store)
|
||||||
if mission_authority is not None and mission_authority.store is not store:
|
|
||||||
raise ValueError("mission authority must use the TaskPlane store")
|
|
||||||
self.missions = mission_authority
|
|
||||||
effective_lineage_policy = lineage_policy
|
|
||||||
if mission_authority is not None:
|
|
||||||
effective_lineage_policy = (
|
|
||||||
lineage_policy
|
|
||||||
or TaskLineagePolicy(
|
|
||||||
principal_bindings=(), acceptance_evaluators={}
|
|
||||||
)
|
|
||||||
).with_acceptance_evaluator_resolver(
|
|
||||||
mission_authority.resolve_acceptance_evaluator
|
|
||||||
)
|
|
||||||
self.lineage = (
|
|
||||||
TaskPlaneLineageCoordinator(store, effective_lineage_policy)
|
|
||||||
if effective_lineage_policy is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.root_actions = (
|
|
||||||
RootActionConsumer(
|
|
||||||
store,
|
|
||||||
plan_builder=lambda review: mission_authority.build_root_action_plan(
|
|
||||||
review=review, lineage=self.lineage
|
|
||||||
),
|
|
||||||
rereviewer=lambda plan, receipt: mission_authority.rereview_root_action(
|
|
||||||
plan=plan,
|
|
||||||
execution_receipt=receipt,
|
|
||||||
lineage=self.lineage,
|
|
||||||
),
|
|
||||||
executor=root_action_executor,
|
|
||||||
)
|
|
||||||
if mission_authority is not None and self.lineage is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||||
|
|
||||||
def consume_one_root_action(
|
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
consumer_id: str,
|
|
||||||
now: datetime | None = None,
|
|
||||||
lease_seconds: int = 60,
|
|
||||||
) -> RootActionRunResult:
|
|
||||||
if self.root_actions is None:
|
|
||||||
raise ForbiddenError("root action consumer is not configured")
|
|
||||||
return self.root_actions.run_once(
|
|
||||||
consumer_id=consumer_id,
|
|
||||||
now=now or datetime.now(tz=timezone.utc),
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_mission(
|
|
||||||
self,
|
|
||||||
body: dict[str, Any],
|
|
||||||
idempotency_key: str | None,
|
|
||||||
*,
|
|
||||||
actor_identity: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if self.missions is None or self.lineage is None:
|
|
||||||
raise ForbiddenError("mission authority is not configured")
|
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("mission creation requires authenticated identity")
|
|
||||||
if set(body) != {"goal_slug", "original_acceptance"}:
|
|
||||||
raise ValidationError(
|
|
||||||
"mission body requires only goal_slug and original_acceptance"
|
|
||||||
)
|
|
||||||
self.policy.assert_allowed("create", actor_identity)
|
|
||||||
mission = self.missions.create_mission(
|
|
||||||
goal_slug=body["goal_slug"],
|
|
||||||
original_acceptance=body["original_acceptance"],
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
authenticated_identity=actor_identity,
|
|
||||||
lineage=self.lineage,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
**mission.to_dict(),
|
|
||||||
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_mission(
|
|
||||||
self, mission_id: str, *, actor_identity: str | None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if self.missions is None:
|
|
||||||
raise ForbiddenError("mission authority is not configured")
|
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("mission read requires authenticated identity")
|
|
||||||
self.policy.assert_allowed("read", actor_identity)
|
|
||||||
mission = self.missions.get_mission(
|
|
||||||
mission_id, authenticated_identity=actor_identity
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
**mission.to_dict(),
|
|
||||||
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def delegate_mission(
|
|
||||||
self,
|
|
||||||
parent_task_id: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
idempotency_key: str | None,
|
|
||||||
*,
|
|
||||||
actor_identity: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Admit one server-identified child under an owned canonical Mission."""
|
|
||||||
|
|
||||||
if self.missions is None or self.lineage is None:
|
|
||||||
raise ForbiddenError("mission authority is not configured")
|
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("mission delegation requires authenticated identity")
|
|
||||||
# Mission delegation is an admission operation. It deliberately does
|
|
||||||
# not enable the worker execution actions guarded by execution_enabled.
|
|
||||||
self.policy.assert_allowed("create", actor_identity)
|
|
||||||
granted = set(
|
|
||||||
self.policy.identity_capabilities.get(
|
|
||||||
actor_identity,
|
|
||||||
self.policy.identity_capabilities.get("*", set()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if "task.delegate" not in granted:
|
|
||||||
raise ForbiddenError("action delegate not permitted")
|
|
||||||
child = self.missions.delegate_child(
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
body=body,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
authenticated_identity=actor_identity,
|
|
||||||
lineage=self.lineage,
|
|
||||||
delegation=self.delegation,
|
|
||||||
)
|
|
||||||
return child.to_dict()
|
|
||||||
|
|
||||||
def attach_legacy_attachment(
|
|
||||||
self,
|
|
||||||
mission_id: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
idempotency_key: str | None,
|
|
||||||
*,
|
|
||||||
actor_identity: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if self.missions is None:
|
|
||||||
raise ForbiddenError("mission authority is not configured")
|
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("legacy attachment requires authenticated identity")
|
|
||||||
self.policy.assert_allowed("create", actor_identity)
|
|
||||||
granted = set(
|
|
||||||
self.policy.identity_capabilities.get(
|
|
||||||
actor_identity, self.policy.identity_capabilities.get("*", set())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if "task.delegate" not in granted:
|
|
||||||
raise ForbiddenError("action delegate not permitted")
|
|
||||||
return self.missions.attach_legacy(
|
|
||||||
mission_id=mission_id,
|
|
||||||
body=body,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
authenticated_identity=actor_identity,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_legacy_attachment(
|
|
||||||
self,
|
|
||||||
mission_id: str,
|
|
||||||
attachment_id: str,
|
|
||||||
*,
|
|
||||||
actor_identity: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if self.missions is None:
|
|
||||||
raise ForbiddenError("mission authority is not configured")
|
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("legacy attachment read requires authenticated identity")
|
|
||||||
self.policy.assert_allowed("read", actor_identity)
|
|
||||||
return self.missions.get_legacy_attachment(
|
|
||||||
mission_id=mission_id,
|
|
||||||
attachment_id=attachment_id,
|
|
||||||
authenticated_identity=actor_identity,
|
|
||||||
)
|
|
||||||
|
|
||||||
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", {})
|
||||||
if actor_identity is not None:
|
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||||
if issuer and (
|
|
||||||
not isinstance(issuer, dict) or issuer.get("id") != actor_identity
|
|
||||||
):
|
|
||||||
raise ForbiddenError("issuer identity mismatch")
|
|
||||||
body = dict(body)
|
|
||||||
body["issuer"] = {"type": "authenticated", "id": actor_identity}
|
|
||||||
actor = actor_identity or (
|
|
||||||
issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
|
||||||
)
|
|
||||||
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 self.lineage is not None:
|
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||||
if actor_identity is None:
|
|
||||||
raise ForbiddenError("lineage admission requires authenticated identity")
|
|
||||||
task = self.lineage.create_root(
|
|
||||||
plan=plan,
|
|
||||||
authenticated_identity=actor_identity,
|
|
||||||
request_digest=self.lineage._request_digest(body),
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
task = self.store.create_task_with_event(
|
|
||||||
plan, idempotency_key=idempotency_key
|
|
||||||
)
|
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
def _can_read_mission_task(self, task, actor: str | None) -> bool:
|
|
||||||
# Every service sharing the store must honor persisted Mission ownership,
|
|
||||||
# including a legacy launcher that has no Mission authority configured.
|
|
||||||
if self.missions is not None:
|
|
||||||
return self.missions.can_read_task(
|
|
||||||
task, authenticated_identity=actor or ""
|
|
||||||
)
|
|
||||||
with self.store._lock:
|
|
||||||
return self.store.conn.execute(
|
|
||||||
"SELECT 1 FROM task_missions WHERE root_task_id=?",
|
|
||||||
(task.root_task_id,),
|
|
||||||
).fetchone() is None
|
|
||||||
|
|
||||||
def _assert_mission_read(self, task, actor: str | None) -> None:
|
|
||||||
if not self._can_read_mission_task(task, actor):
|
|
||||||
raise ForbiddenError("mission task requires its configured owner authority")
|
|
||||||
|
|
||||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor)
|
self.policy.assert_allowed("read", actor)
|
||||||
tasks = self.store.list_tasks()
|
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||||
tasks = [task for task in tasks if self._can_read_mission_task(task, actor)]
|
|
||||||
return [task.to_dict(include_task=False) for task in tasks]
|
|
||||||
|
|
||||||
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]:
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self._assert_mission_read(task, actor)
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=task)
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
if self.missions is not None and self.missions.manages_root(
|
return self.store.get_task(task_id).to_dict()
|
||||||
task.root_task_id
|
|
||||||
):
|
|
||||||
self.missions.assert_task_read(
|
|
||||||
task, authenticated_identity=actor
|
|
||||||
)
|
|
||||||
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:
|
||||||
@@ -550,51 +307,17 @@ class TaskPlaneService:
|
|||||||
)
|
)
|
||||||
evidence_entries.append(envelope)
|
evidence_entries.append(envelope)
|
||||||
previous = envelope.evidence_digest
|
previous = envelope.evidence_digest
|
||||||
final_status = (
|
self.store.complete_claim_with_evidence_and_event(
|
||||||
|
task_id,
|
||||||
|
claim_id,
|
||||||
status
|
status
|
||||||
if status
|
if status
|
||||||
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
||||||
else "COMPLETED"
|
else "COMPLETED",
|
||||||
|
result.to_dict(),
|
||||||
|
evidence_entries,
|
||||||
)
|
)
|
||||||
lineage_metadata = task.task_plan.metadata.get("aios_task_lineage")
|
return result.to_dict()
|
||||||
is_lineage_root = bool(
|
|
||||||
self.lineage is not None
|
|
||||||
and isinstance(lineage_metadata, dict)
|
|
||||||
and lineage_metadata.get("relation_type") == "ROOT"
|
|
||||||
)
|
|
||||||
if is_lineage_root and final_status == "COMPLETED":
|
|
||||||
raise ForbiddenError("lineage root completion requires root acceptance review")
|
|
||||||
review_outcome = None
|
|
||||||
with self.store._transaction():
|
|
||||||
self.store.complete_claim_with_evidence_and_event(
|
|
||||||
task_id,
|
|
||||||
claim_id,
|
|
||||||
final_status,
|
|
||||||
result.to_dict(),
|
|
||||||
evidence_entries,
|
|
||||||
)
|
|
||||||
is_lineage_child = bool(
|
|
||||||
self.lineage is not None
|
|
||||||
and isinstance(lineage_metadata, dict)
|
|
||||||
and lineage_metadata.get("relation_type") != "ROOT"
|
|
||||||
)
|
|
||||||
if is_lineage_child and final_status in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
|
|
||||||
review_outcome = self.lineage.review_proven_terminal_child(
|
|
||||||
child_task_id=task_id,
|
|
||||||
claim_id=str(claim_id),
|
|
||||||
result_digest=str(result.result_digest),
|
|
||||||
evidence_refs=[entry.evidence_id for entry in evidence_entries],
|
|
||||||
)
|
|
||||||
response = result.to_dict()
|
|
||||||
if review_outcome is not None:
|
|
||||||
response["root_review"] = {
|
|
||||||
"review_id": review_outcome.review.review_id,
|
|
||||||
"root_task_id": review_outcome.review.root_task_id,
|
|
||||||
"root_complete": review_outcome.review.root_complete,
|
|
||||||
"next_legal_action": review_outcome.review.next_legal_action.value,
|
|
||||||
"dispatch_status": review_outcome.dispatch.status,
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
|
|
||||||
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
claim_id = body.get("claim_id")
|
claim_id = body.get("claim_id")
|
||||||
@@ -623,38 +346,14 @@ class TaskPlaneService:
|
|||||||
)
|
)
|
||||||
return updated.to_dict()
|
return updated.to_dict()
|
||||||
|
|
||||||
def delegate(
|
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
actor = body.get("actor_identity") or "owner"
|
actor = body.get("actor_identity") or "owner"
|
||||||
parent = self.store.get_task(task_id)
|
|
||||||
mission_managed = bool(
|
|
||||||
self.lineage is not None
|
|
||||||
and self.missions is not None
|
|
||||||
and self.missions.manages_root(parent.root_task_id)
|
|
||||||
)
|
|
||||||
if mission_managed:
|
|
||||||
plan_body = body.get("task") if "task" in body else {
|
|
||||||
key: value
|
|
||||||
for key, value in body.items()
|
|
||||||
if key != "actor_identity"
|
|
||||||
}
|
|
||||||
return self.delegate_mission(
|
|
||||||
task_id,
|
|
||||||
plan_body,
|
|
||||||
idempotency_key,
|
|
||||||
actor_identity=str(actor),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.policy.assert_allowed("delegate", actor)
|
self.policy.assert_allowed("delegate", actor)
|
||||||
|
parent = self.store.get_task(task_id)
|
||||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||||
if "task" in body:
|
if "task" in body:
|
||||||
plan_body = body["task"]
|
|
||||||
try:
|
try:
|
||||||
child_plan = TaskPlan.from_dict(plan_body)
|
child_plan = TaskPlan.from_dict(body["task"])
|
||||||
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
|
||||||
else:
|
else:
|
||||||
@@ -665,67 +364,25 @@ 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
|
||||||
if self.lineage is not None:
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||||
if not isinstance(plan_body, dict):
|
|
||||||
raise ValidationError("invalid child task plan")
|
|
||||||
if "task_id" not in plan_body:
|
|
||||||
raise ValidationError("lineage delegation requires a stable task_id")
|
|
||||||
plan_body = dict(plan_body)
|
|
||||||
issuer = plan_body.get("issuer")
|
|
||||||
if issuer and (
|
|
||||||
not isinstance(issuer, dict) or issuer.get("id") != actor
|
|
||||||
):
|
|
||||||
raise ForbiddenError("child issuer identity mismatch")
|
|
||||||
plan_body["issuer"] = {"type": "authenticated", "id": actor}
|
|
||||||
child_plan.issuer = dict(plan_body["issuer"])
|
|
||||||
child = self.lineage.delegate_child(
|
|
||||||
parent_task_id=task_id,
|
|
||||||
plan=child_plan,
|
|
||||||
authenticated_identity=str(actor),
|
|
||||||
request_digest=self.lineage._request_digest(plan_body),
|
|
||||||
delegation=self.delegation,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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]]:
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self._assert_mission_read(task, actor)
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=task)
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
if self.missions is not None and self.missions.manages_root(
|
self.store.get_task(task_id)
|
||||||
task.root_task_id
|
|
||||||
):
|
|
||||||
self.missions.assert_task_read(
|
|
||||||
task, authenticated_identity=actor
|
|
||||||
)
|
|
||||||
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]]:
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self._assert_mission_read(task, actor)
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("audit", actor, task=task)
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
if self.missions is not None and self.missions.manages_root(
|
self.store.get_task(task_id)
|
||||||
task.root_task_id
|
|
||||||
):
|
|
||||||
self.missions.assert_task_read(
|
|
||||||
task, authenticated_identity=actor
|
|
||||||
)
|
|
||||||
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]:
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self._assert_mission_read(task, actor)
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor, task=task)
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
if self.missions is not None and self.missions.manages_root(
|
self.store.get_task(task_id)
|
||||||
task.root_task_id
|
|
||||||
):
|
|
||||||
self.missions.assert_task_read(
|
|
||||||
task, authenticated_identity=actor
|
|
||||||
)
|
|
||||||
return self.store.list_children(task_id)
|
return self.store.list_children(task_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -817,10 +474,6 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if len(parts) == 1 and parts[0] == "tasks":
|
if len(parts) == 1 and parts[0] == "tasks":
|
||||||
return 200, self.service.list_tasks(identity)
|
return 200, self.service.list_tasks(identity)
|
||||||
if len(parts) == 2 and parts[0] == "missions":
|
|
||||||
return 200, self.service.get_mission(
|
|
||||||
parts[1], actor_identity=identity
|
|
||||||
)
|
|
||||||
if len(parts) == 2 and parts[0] == "tasks":
|
if len(parts) == 2 and parts[0] == "tasks":
|
||||||
task_id = parts[1]
|
task_id = parts[1]
|
||||||
return 200, self.service.get_task(task_id, identity)
|
return 200, self.service.get_task(task_id, identity)
|
||||||
@@ -846,13 +499,6 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
parts = [p for p in path.split("/") if p]
|
parts = [p for p in path.split("/") if p]
|
||||||
headers = {k.lower(): v for k, v in self.headers.items()}
|
headers = {k.lower(): v for k, v in self.headers.items()}
|
||||||
|
|
||||||
if len(parts) == 1 and parts[0] == "missions":
|
|
||||||
return 201, self.service.create_mission(
|
|
||||||
body or {},
|
|
||||||
headers.get("idempotency-key"),
|
|
||||||
actor_identity=identity,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(parts) == 1 and parts[0] == "tasks":
|
if len(parts) == 1 and parts[0] == "tasks":
|
||||||
issuer = body.get("issuer")
|
issuer = body.get("issuer")
|
||||||
if issuer and (
|
if issuer and (
|
||||||
@@ -860,11 +506,7 @@ 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(
|
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||||
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
|
||||||
@@ -881,9 +523,7 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
||||||
return 200, self.service.cancel(parts[1], body or {})
|
return 200, self.service.cancel(parts[1], body or {})
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
||||||
return 200, self.service.delegate(
|
return 200, self.service.delegate(parts[1], body or {})
|
||||||
parts[1], body or {}, headers.get("idempotency-key")
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Agent discovery/claim coordinator over diagnostic schedulability evidence."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Mapping
|
|
||||||
from .schedulability import (
|
|
||||||
DiscoveryDiagnostics,
|
|
||||||
SchedulabilityProof,
|
|
||||||
SchedulabilityService,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DiscoveryResult:
|
|
||||||
status: str
|
|
||||||
proof: SchedulabilityProof | None
|
|
||||||
blocker_codes: tuple[str, ...] = ()
|
|
||||||
candidate_count: int = 0
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
value: dict[str, Any] = {
|
|
||||||
"status": self.status,
|
|
||||||
"blocker_codes": list(self.blocker_codes),
|
|
||||||
"candidate_count": self.candidate_count,
|
|
||||||
"proof": None,
|
|
||||||
}
|
|
||||||
if self.proof is not None:
|
|
||||||
value["proof"] = {
|
|
||||||
"schema": self.proof.schema,
|
|
||||||
"proof_id": self.proof.proof_id,
|
|
||||||
"task_id": self.proof.task_id,
|
|
||||||
"worker_id": self.proof.worker_id,
|
|
||||||
"created_at": self.proof.created_at,
|
|
||||||
"expires_at": self.proof.expires_at,
|
|
||||||
"blockers": [b.to_dict() for b in self.proof.blockers],
|
|
||||||
"proof_digest": self.proof.proof_digest,
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _waiting_status(diag: DiscoveryDiagnostics) -> str:
|
|
||||||
codes = set(diag.blocker_codes)
|
|
||||||
if diag.candidate_count == 0:
|
|
||||||
return "NO_CANDIDATES"
|
|
||||||
if not codes:
|
|
||||||
return "NO_CLAIMABLE_TASK"
|
|
||||||
if codes <= {"CAPACITY_EXHAUSTED"}:
|
|
||||||
return "WAITING_CAPACITY"
|
|
||||||
if "AUTHORIZATION_BLOCKED" in codes or "EXACT_SCOPE_MISMATCH" in codes:
|
|
||||||
return "WAITING_AUTHORIZATION"
|
|
||||||
if "DEPENDENCY_BLOCKED" in codes:
|
|
||||||
return "WAITING_DEPENDENCY"
|
|
||||||
if "FREEZE_BLOCKED" in codes:
|
|
||||||
return "WAITING_FREEZE"
|
|
||||||
if "CONFLICTING_LEASE" in codes:
|
|
||||||
return "WAITING_LEASE"
|
|
||||||
if {"STALE_OR_INACTIVE_WORKER", "MISSING_CAPABILITY", "INVALID_AUTHORITY_VALUE"} & codes:
|
|
||||||
return "WORKER_NOT_ELIGIBLE"
|
|
||||||
if "TASK_STATE_NOT_CLAIMABLE" in codes:
|
|
||||||
return "NO_CLAIMABLE_TASK"
|
|
||||||
return "BLOCKED"
|
|
||||||
|
|
||||||
|
|
||||||
class AutoClaimCoordinator:
|
|
||||||
"""No implicit credential derivation; authenticated identity is supplied by host."""
|
|
||||||
|
|
||||||
def __init__(self, schedulability: SchedulabilityService) -> None:
|
|
||||||
self.schedulability = schedulability
|
|
||||||
|
|
||||||
def discover(self, *, worker_id: str, actor_identity: str) -> DiscoveryResult:
|
|
||||||
proof, diag = self.schedulability.discover_with_diagnostics(
|
|
||||||
worker_id=worker_id, actor_identity=actor_identity
|
|
||||||
)
|
|
||||||
if proof is not None:
|
|
||||||
return DiscoveryResult(
|
|
||||||
"CLAIMABLE", proof, diag.blocker_codes, diag.candidate_count
|
|
||||||
)
|
|
||||||
return DiscoveryResult(
|
|
||||||
_waiting_status(diag), None, diag.blocker_codes, diag.candidate_count
|
|
||||||
)
|
|
||||||
|
|
||||||
def claim(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
proof_id: str,
|
|
||||||
worker_id: str,
|
|
||||||
actor_identity: str,
|
|
||||||
lease_seconds: int = 30,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
return self.schedulability.claim_with_recheck(
|
|
||||||
proof_id=proof_id,
|
|
||||||
worker_id=worker_id,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -31,16 +31,7 @@ class DelegationEngine:
|
|||||||
current = self.store.parent_of(current)
|
current = self.store.parent_of(current)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delegate(
|
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> TaskRecord:
|
||||||
self,
|
|
||||||
parent_id: str,
|
|
||||||
child_plan: TaskPlan,
|
|
||||||
*,
|
|
||||||
caller: str,
|
|
||||||
owner_principal_id: str = "",
|
|
||||||
mission_id: str = "",
|
|
||||||
created_at: float | None = None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
self.can_delegate(parent_id, child_plan)
|
self.can_delegate(parent_id, child_plan)
|
||||||
parent = self.store.get_task(parent_id)
|
parent = self.store.get_task(parent_id)
|
||||||
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
|
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
|
||||||
@@ -89,12 +80,7 @@ class DelegationEngine:
|
|||||||
raise ConflictError("delegation task_id conflict")
|
raise ConflictError("delegation task_id conflict")
|
||||||
|
|
||||||
return self.store.delegate_task_with_budget_and_events(
|
return self.store.delegate_task_with_budget_and_events(
|
||||||
parent_id,
|
parent_id, child_plan, caller=caller
|
||||||
child_plan,
|
|
||||||
caller=caller,
|
|
||||||
owner_principal_id=owner_principal_id,
|
|
||||||
mission_id=mission_id,
|
|
||||||
created_at=created_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _depth(self, task_id: str) -> int:
|
def _depth(self, task_id: str) -> int:
|
||||||
|
|||||||
@@ -1,536 +0,0 @@
|
|||||||
"""Mission-owned, append-only bindings for verified external legacy executions."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Mapping, Protocol, Sequence
|
|
||||||
|
|
||||||
from .errors import ConflictError, NotFoundError, ValidationError
|
|
||||||
from .models import compute_sha256_hex
|
|
||||||
|
|
||||||
|
|
||||||
ATTACH_REQUEST_SCHEMA = "aios.mission-legacy-attach-request.v1"
|
|
||||||
ATTACH_EVIDENCE_SCHEMA = "aios.verified-legacy-attachment-evidence.v1"
|
|
||||||
ATTACH_RECEIPT_SCHEMA = "aios.mission-legacy-attachment-receipt.v1"
|
|
||||||
ATTACHMENT_KIND = "LEGACY_EXECUTION_SUBJECT"
|
|
||||||
RELATION_TYPE = "RECOVERY"
|
|
||||||
SOURCE_KIND = "agent-task-bridge-job"
|
|
||||||
REQUIRED_READBACK_ROLES = frozenset(
|
|
||||||
{"agent_task", "admission", "owner_registry", "bridge_job"}
|
|
||||||
)
|
|
||||||
REQUIRED_C04_EVIDENCE = frozenset(
|
|
||||||
{
|
|
||||||
"verified legacy ownership and acceptance relevance",
|
|
||||||
"unchanged task/job/idempotency/reconciliation identity",
|
|
||||||
"authoritative attach readback",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> str:
|
|
||||||
try:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
allow_nan=False,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValidationError("legacy attachment value must be JSON data") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, fields: frozenset[str], name: str) -> dict[str, object]:
|
|
||||||
if not isinstance(value, Mapping) or set(value) != fields:
|
|
||||||
raise ValidationError(f"invalid {name} fields")
|
|
||||||
return json.loads(_canonical(dict(value)))
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: object, name: str, *, maximum: int = 256) -> str:
|
|
||||||
if (
|
|
||||||
not isinstance(value, str)
|
|
||||||
or not value
|
|
||||||
or len(value) > maximum
|
|
||||||
or any(ord(character) < 33 for character in value)
|
|
||||||
):
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, name: str) -> int:
|
|
||||||
if type(value) is not int or value < 0:
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _utc(value: object, name: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(f"invalid {name}") from exc
|
|
||||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthorityReadbackRef:
|
|
||||||
"""A non-secret pointer and digest issued by one independent authority."""
|
|
||||||
|
|
||||||
role: str
|
|
||||||
authority: str
|
|
||||||
readback_ref: str
|
|
||||||
canonical_sha256: str
|
|
||||||
observed_at: str
|
|
||||||
revision: int | None = None
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
if self.role not in REQUIRED_READBACK_ROLES:
|
|
||||||
raise ValidationError("invalid legacy authority readback role")
|
|
||||||
authority = _identifier(self.authority, "readback authority")
|
|
||||||
reference = _identifier(self.readback_ref, "readback ref", maximum=1024)
|
|
||||||
if not _SHA256_RE.fullmatch(self.canonical_sha256):
|
|
||||||
raise ValidationError("invalid readback canonical SHA-256")
|
|
||||||
observed = _utc(self.observed_at, "readback observed_at")
|
|
||||||
revision = self.revision
|
|
||||||
if revision is not None:
|
|
||||||
revision = _integer(revision, "readback revision")
|
|
||||||
return {
|
|
||||||
"role": self.role,
|
|
||||||
"authority": authority,
|
|
||||||
"readback_ref": reference,
|
|
||||||
"canonical_sha256": self.canonical_sha256,
|
|
||||||
"observed_at": observed,
|
|
||||||
"revision": revision,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VerifiedLegacyAttachmentEvidence:
|
|
||||||
"""Typed output of a configured authority reader, never a caller payload."""
|
|
||||||
|
|
||||||
schema: str
|
|
||||||
source_kind: str
|
|
||||||
source_task_id: str
|
|
||||||
source_task_created_at: str
|
|
||||||
source_task_status: str
|
|
||||||
source_task_revision: int
|
|
||||||
source_task_attempt: int
|
|
||||||
source_task_max_attempts: int
|
|
||||||
external_job_id: str
|
|
||||||
internal_job_id: str
|
|
||||||
internal_idempotency_key: str
|
|
||||||
job_branch: str
|
|
||||||
job_state: str
|
|
||||||
job_revision: int
|
|
||||||
side_effect_state: str
|
|
||||||
grant_id: str
|
|
||||||
grant_digest: str
|
|
||||||
execution_digest: str
|
|
||||||
epoch_id: str
|
|
||||||
epoch_digest: str
|
|
||||||
historical_signer_id: str
|
|
||||||
historical_signer_fingerprint: str
|
|
||||||
registry_generation: int
|
|
||||||
signature_verified: bool
|
|
||||||
epoch_currently_valid: bool
|
|
||||||
readbacks: Sequence[AuthorityReadbackRef]
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
if self.schema != ATTACH_EVIDENCE_SCHEMA or self.source_kind != SOURCE_KIND:
|
|
||||||
raise ValidationError("invalid legacy attachment evidence schema")
|
|
||||||
if self.signature_verified is not True or self.epoch_currently_valid is not False:
|
|
||||||
raise ConflictError("legacy historical Owner evidence is not verified-expired")
|
|
||||||
values = {
|
|
||||||
"source_task_id": self.source_task_id,
|
|
||||||
"source_task_status": self.source_task_status,
|
|
||||||
"external_job_id": self.external_job_id,
|
|
||||||
"internal_job_id": self.internal_job_id,
|
|
||||||
"internal_idempotency_key": self.internal_idempotency_key,
|
|
||||||
"job_branch": self.job_branch,
|
|
||||||
"job_state": self.job_state,
|
|
||||||
"side_effect_state": self.side_effect_state,
|
|
||||||
"grant_id": self.grant_id,
|
|
||||||
"historical_signer_id": self.historical_signer_id,
|
|
||||||
"historical_signer_fingerprint": self.historical_signer_fingerprint,
|
|
||||||
"epoch_id": self.epoch_id,
|
|
||||||
}
|
|
||||||
normalized = {
|
|
||||||
key: _identifier(value, key, maximum=1024)
|
|
||||||
for key, value in values.items()
|
|
||||||
}
|
|
||||||
for name, value in {
|
|
||||||
"grant_digest": self.grant_digest,
|
|
||||||
"execution_digest": self.execution_digest,
|
|
||||||
"epoch_digest": self.epoch_digest,
|
|
||||||
}.items():
|
|
||||||
if not _SHA256_RE.fullmatch(value):
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
normalized[name] = value
|
|
||||||
task_revision = _integer(self.source_task_revision, "source_task_revision")
|
|
||||||
task_attempt = _integer(self.source_task_attempt, "source_task_attempt")
|
|
||||||
task_max_attempts = _integer(
|
|
||||||
self.source_task_max_attempts, "source_task_max_attempts"
|
|
||||||
)
|
|
||||||
if task_attempt > task_max_attempts:
|
|
||||||
raise ConflictError("legacy task attempt exceeds max attempts")
|
|
||||||
job_revision = _integer(self.job_revision, "job_revision")
|
|
||||||
registry_generation = _integer(
|
|
||||||
self.registry_generation, "registry_generation"
|
|
||||||
)
|
|
||||||
readbacks = [value.to_dict() for value in self.readbacks]
|
|
||||||
roles = [str(value["role"]) for value in readbacks]
|
|
||||||
if len(roles) != len(set(roles)) or set(roles) != REQUIRED_READBACK_ROLES:
|
|
||||||
raise ValidationError("legacy evidence requires four authority readbacks")
|
|
||||||
readbacks.sort(key=lambda value: str(value["role"]))
|
|
||||||
return {
|
|
||||||
"schema": ATTACH_EVIDENCE_SCHEMA,
|
|
||||||
"source_kind": SOURCE_KIND,
|
|
||||||
**normalized,
|
|
||||||
"source_task_created_at": _utc(
|
|
||||||
self.source_task_created_at, "source_task_created_at"
|
|
||||||
),
|
|
||||||
"source_task_revision": task_revision,
|
|
||||||
"source_task_attempt": task_attempt,
|
|
||||||
"source_task_max_attempts": task_max_attempts,
|
|
||||||
"job_revision": job_revision,
|
|
||||||
"registry_generation": registry_generation,
|
|
||||||
"signature_verified": True,
|
|
||||||
"epoch_currently_valid": False,
|
|
||||||
"readbacks": readbacks,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyAttachmentEvidenceProvider(Protocol):
|
|
||||||
"""Configured server-side reader for the four independent authority sources."""
|
|
||||||
|
|
||||||
def read_verified(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mission_id: str,
|
|
||||||
root_task_id: str,
|
|
||||||
anchor_task_id: str,
|
|
||||||
source_kind: str,
|
|
||||||
source_task_id: str,
|
|
||||||
external_job_id: str,
|
|
||||||
original_acceptance_digest: str,
|
|
||||||
) -> VerifiedLegacyAttachmentEvidence: ...
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_attach_request(value: object) -> dict[str, object]:
|
|
||||||
body = _object(
|
|
||||||
value,
|
|
||||||
frozenset({"schema", "anchor_task_id", "source", "expected"}),
|
|
||||||
"legacy attachment request",
|
|
||||||
)
|
|
||||||
if body["schema"] != ATTACH_REQUEST_SCHEMA:
|
|
||||||
raise ValidationError("invalid legacy attachment request schema")
|
|
||||||
source = _object(
|
|
||||||
body["source"],
|
|
||||||
frozenset({"kind", "task_id", "external_job_id"}),
|
|
||||||
"legacy attachment source",
|
|
||||||
)
|
|
||||||
if source["kind"] != SOURCE_KIND:
|
|
||||||
raise ValidationError("invalid legacy attachment source kind")
|
|
||||||
expected = _object(
|
|
||||||
body["expected"],
|
|
||||||
frozenset(
|
|
||||||
{
|
|
||||||
"root_revision",
|
|
||||||
"anchor_revision",
|
|
||||||
"agent_task_revision",
|
|
||||||
"agent_task_attempt",
|
|
||||||
"internal_job_id",
|
|
||||||
"job_revision",
|
|
||||||
"internal_idempotency_key",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"legacy attachment expected",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"schema": ATTACH_REQUEST_SCHEMA,
|
|
||||||
"anchor_task_id": _identifier(body["anchor_task_id"], "anchor_task_id"),
|
|
||||||
"source": {
|
|
||||||
"kind": SOURCE_KIND,
|
|
||||||
"task_id": _identifier(source["task_id"], "source task_id"),
|
|
||||||
"external_job_id": _identifier(
|
|
||||||
source["external_job_id"], "external_job_id"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"expected": {
|
|
||||||
"root_revision": _integer(expected["root_revision"], "root_revision"),
|
|
||||||
"anchor_revision": _integer(
|
|
||||||
expected["anchor_revision"], "anchor_revision"
|
|
||||||
),
|
|
||||||
"agent_task_revision": _integer(
|
|
||||||
expected["agent_task_revision"], "agent_task_revision"
|
|
||||||
),
|
|
||||||
"agent_task_attempt": _integer(
|
|
||||||
expected["agent_task_attempt"], "agent_task_attempt"
|
|
||||||
),
|
|
||||||
"internal_job_id": _identifier(
|
|
||||||
expected["internal_job_id"], "internal_job_id"
|
|
||||||
),
|
|
||||||
"job_revision": _integer(expected["job_revision"], "job_revision"),
|
|
||||||
"internal_idempotency_key": _identifier(
|
|
||||||
expected["internal_idempotency_key"], "internal_idempotency_key"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_c04_acceptance(
|
|
||||||
acceptance: Mapping[str, object], *, source_task_id: str, external_job_id: str
|
|
||||||
) -> None:
|
|
||||||
candidate = acceptance.get("legacy_candidate")
|
|
||||||
if (
|
|
||||||
not isinstance(candidate, Mapping)
|
|
||||||
or candidate.get("task_id") != source_task_id
|
|
||||||
or candidate.get("job_id") != external_job_id
|
|
||||||
):
|
|
||||||
raise ConflictError("mission legacy candidate authority mismatch")
|
|
||||||
conditions = acceptance.get("conditions")
|
|
||||||
if not isinstance(conditions, list):
|
|
||||||
raise ConflictError("mission C04 acceptance condition is unavailable")
|
|
||||||
matches = [
|
|
||||||
condition
|
|
||||||
for condition in conditions
|
|
||||||
if isinstance(condition, Mapping) and condition.get("key") == "C04"
|
|
||||||
]
|
|
||||||
if len(matches) != 1:
|
|
||||||
raise ConflictError("mission C04 acceptance condition is unavailable")
|
|
||||||
condition = matches[0]
|
|
||||||
evidence = condition.get("required_evidence")
|
|
||||||
if (
|
|
||||||
condition.get("purpose") != "LEGACY-LINEAGE-MIGRATION"
|
|
||||||
or not isinstance(evidence, list)
|
|
||||||
or not REQUIRED_C04_EVIDENCE.issubset(
|
|
||||||
value for value in evidence if isinstance(value, str)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ConflictError("mission C04 acceptance contract mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_evidence_matches_request(
|
|
||||||
evidence: Mapping[str, object], request: Mapping[str, object]
|
|
||||||
) -> None:
|
|
||||||
source = request["source"]
|
|
||||||
expected = request["expected"]
|
|
||||||
assert isinstance(source, Mapping) and isinstance(expected, Mapping)
|
|
||||||
pairs = {
|
|
||||||
"source_kind": source["kind"],
|
|
||||||
"source_task_id": source["task_id"],
|
|
||||||
"external_job_id": source["external_job_id"],
|
|
||||||
"source_task_revision": expected["agent_task_revision"],
|
|
||||||
"source_task_attempt": expected["agent_task_attempt"],
|
|
||||||
"internal_job_id": expected["internal_job_id"],
|
|
||||||
"job_revision": expected["job_revision"],
|
|
||||||
"internal_idempotency_key": expected["internal_idempotency_key"],
|
|
||||||
}
|
|
||||||
mismatches = [name for name, value in pairs.items() if evidence.get(name) != value]
|
|
||||||
if mismatches:
|
|
||||||
raise ConflictError(
|
|
||||||
"legacy provider evidence mismatch: " + ",".join(sorted(mismatches))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyAttachmentLedger:
|
|
||||||
"""Append-only persistence owned by one Mission authority store."""
|
|
||||||
|
|
||||||
def __init__(self, store) -> None:
|
|
||||||
self.store = store
|
|
||||||
with store._transaction():
|
|
||||||
store.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_mission_legacy_attachments (
|
|
||||||
attachment_id TEXT PRIMARY KEY,
|
|
||||||
mission_id TEXT NOT NULL,
|
|
||||||
root_task_id TEXT NOT NULL,
|
|
||||||
anchor_task_id TEXT NOT NULL,
|
|
||||||
authenticated_identity TEXT NOT NULL,
|
|
||||||
owner_principal_id TEXT NOT NULL,
|
|
||||||
attachment_kind TEXT NOT NULL,
|
|
||||||
relation_type TEXT NOT NULL,
|
|
||||||
attachment_sequence INTEGER NOT NULL,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
source_kind TEXT NOT NULL,
|
|
||||||
source_task_id TEXT NOT NULL,
|
|
||||||
external_job_id TEXT NOT NULL,
|
|
||||||
internal_job_id TEXT NOT NULL,
|
|
||||||
internal_idempotency_key TEXT NOT NULL,
|
|
||||||
request_digest TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
evidence_digest TEXT NOT NULL,
|
|
||||||
receipt_digest TEXT NOT NULL,
|
|
||||||
receipt_json TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
UNIQUE (authenticated_identity, idempotency_key),
|
|
||||||
UNIQUE (source_kind, source_task_id),
|
|
||||||
UNIQUE (source_kind, external_job_id),
|
|
||||||
UNIQUE (mission_id, anchor_task_id, attachment_sequence),
|
|
||||||
UNIQUE (mission_id, anchor_task_id, source_kind, source_task_id, external_job_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
store.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_mission_legacy_attachments "
|
|
||||||
"ON task_mission_legacy_attachments(mission_id, anchor_task_id)"
|
|
||||||
)
|
|
||||||
store.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_mission_acceptance_child_bindings (
|
|
||||||
mission_id TEXT NOT NULL,
|
|
||||||
condition_key TEXT NOT NULL,
|
|
||||||
child_task_id TEXT NOT NULL UNIQUE,
|
|
||||||
original_acceptance_digest TEXT NOT NULL,
|
|
||||||
binding_digest TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY (mission_id, condition_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def acceptance_child_binding(self, mission_id: str, condition_key: str):
|
|
||||||
return self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_mission_acceptance_child_bindings "
|
|
||||||
"WHERE mission_id=? AND condition_key=?",
|
|
||||||
(mission_id, condition_key),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
def bind_acceptance_child(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
mission_id: str,
|
|
||||||
condition_key: str,
|
|
||||||
child_task_id: str,
|
|
||||||
original_acceptance_digest: str,
|
|
||||||
binding_digest: str,
|
|
||||||
created_at: float,
|
|
||||||
) -> None:
|
|
||||||
existing = self.acceptance_child_binding(mission_id, condition_key)
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing["child_task_id"] != child_task_id
|
|
||||||
or existing["original_acceptance_digest"]
|
|
||||||
!= original_acceptance_digest
|
|
||||||
or existing["binding_digest"] != binding_digest
|
|
||||||
):
|
|
||||||
raise ConflictError(
|
|
||||||
"mission acceptance condition already has a canonical child"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
child_binding = self.store.conn.execute(
|
|
||||||
"SELECT mission_id,condition_key FROM "
|
|
||||||
"task_mission_acceptance_child_bindings WHERE child_task_id=?",
|
|
||||||
(child_task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if child_binding is not None:
|
|
||||||
raise ConflictError("mission child already belongs to an acceptance condition")
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_mission_acceptance_child_bindings "
|
|
||||||
"(mission_id,condition_key,child_task_id,original_acceptance_digest,"
|
|
||||||
"binding_digest,created_at) VALUES(?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
mission_id,
|
|
||||||
condition_key,
|
|
||||||
child_task_id,
|
|
||||||
original_acceptance_digest,
|
|
||||||
binding_digest,
|
|
||||||
created_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def by_idempotency(self, identity: str, key: str):
|
|
||||||
return self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_mission_legacy_attachments "
|
|
||||||
"WHERE authenticated_identity=? AND idempotency_key=?",
|
|
||||||
(identity, key),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
def by_source(self, source_kind: str, task_id: str, external_job_id: str):
|
|
||||||
return self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_mission_legacy_attachments "
|
|
||||||
"WHERE source_kind=? AND (source_task_id=? OR external_job_id=?)",
|
|
||||||
(source_kind, task_id, external_job_id),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
def by_id(self, mission_id: str, attachment_id: str):
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_mission_legacy_attachments "
|
|
||||||
"WHERE mission_id=? AND attachment_id=?",
|
|
||||||
(mission_id, attachment_id),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise NotFoundError("legacy attachment not found")
|
|
||||||
return row
|
|
||||||
|
|
||||||
def next_sequence(self, mission_id: str, anchor_task_id: str) -> int:
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT COALESCE(MAX(attachment_sequence),0)+1 "
|
|
||||||
"FROM task_mission_legacy_attachments "
|
|
||||||
"WHERE mission_id=? AND anchor_task_id=?",
|
|
||||||
(mission_id, anchor_task_id),
|
|
||||||
).fetchone()
|
|
||||||
sequence = int(row[0])
|
|
||||||
if not 1 <= sequence <= 99:
|
|
||||||
raise ConflictError("legacy attachment sequence exhausted")
|
|
||||||
return sequence
|
|
||||||
|
|
||||||
def insert(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
receipt: Mapping[str, object],
|
|
||||||
authenticated_identity: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
request_digest: str,
|
|
||||||
evidence_digest: str,
|
|
||||||
created_at: float,
|
|
||||||
) -> None:
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_mission_legacy_attachments "
|
|
||||||
"(attachment_id,mission_id,root_task_id,anchor_task_id,"
|
|
||||||
"authenticated_identity,owner_principal_id,attachment_kind,"
|
|
||||||
"relation_type,attachment_sequence,display_name,source_kind,"
|
|
||||||
"source_task_id,external_job_id,internal_job_id,"
|
|
||||||
"internal_idempotency_key,request_digest,idempotency_key,"
|
|
||||||
"evidence_digest,receipt_digest,receipt_json,created_at) "
|
|
||||||
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
receipt["attachment_id"], receipt["mission_id"],
|
|
||||||
receipt["root_task_id"], receipt["anchor_task_id"],
|
|
||||||
authenticated_identity, receipt["owner_principal_id"],
|
|
||||||
receipt["attachment_kind"], receipt["relation_type"],
|
|
||||||
receipt["attachment_sequence"], receipt["display_name"],
|
|
||||||
receipt["source"]["source_kind"],
|
|
||||||
receipt["source"]["source_task_id"],
|
|
||||||
receipt["source"]["external_job_id"],
|
|
||||||
receipt["source"]["internal_job_id"],
|
|
||||||
receipt["source"]["internal_idempotency_key"],
|
|
||||||
request_digest, idempotency_key, evidence_digest,
|
|
||||||
receipt["receipt_digest"], _canonical(receipt), created_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def receipt(row) -> dict[str, object]:
|
|
||||||
try:
|
|
||||||
value = json.loads(row["receipt_json"])
|
|
||||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
raise ConflictError("persisted legacy attachment receipt is invalid") from exc
|
|
||||||
if (
|
|
||||||
not isinstance(value, dict)
|
|
||||||
or value.get("schema") != ATTACH_RECEIPT_SCHEMA
|
|
||||||
or value.get("receipt_digest") != row["receipt_digest"]
|
|
||||||
):
|
|
||||||
raise ConflictError("persisted legacy attachment receipt is invalid")
|
|
||||||
digest_payload = dict(value)
|
|
||||||
digest_payload.pop("receipt_digest", None)
|
|
||||||
digest_payload.pop("idempotent", None)
|
|
||||||
if compute_sha256_hex(digest_payload) != row["receipt_digest"]:
|
|
||||||
raise ConflictError("persisted legacy attachment receipt digest mismatch")
|
|
||||||
return value
|
|
||||||
@@ -1,846 +0,0 @@
|
|||||||
"""Actual TaskPlane binding for task identity, lineage and root review.
|
|
||||||
|
|
||||||
All writes use the existing :class:`TaskPlaneStore` connection and transaction
|
|
||||||
fence. Request metadata is never used as an authorization source.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Callable, Mapping, Sequence
|
|
||||||
|
|
||||||
if __package__ and "." in __package__: # app.task_plane package import
|
|
||||||
from ..aios_task_lineage import (
|
|
||||||
AcceptanceCondition,
|
|
||||||
AcceptanceEvaluator,
|
|
||||||
NextLegalAction,
|
|
||||||
RelationType,
|
|
||||||
RootAcceptanceSnapshot,
|
|
||||||
RootReviewRecord,
|
|
||||||
TaskRecord as LineageTaskRecord,
|
|
||||||
lineage_from_metadata,
|
|
||||||
normalize_goal_slug,
|
|
||||||
root_review_from_record,
|
|
||||||
)
|
|
||||||
from ..aios_task_lineage_integration import (
|
|
||||||
DISPLAY_NAME_METADATA_KEY,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
LINEAGE_REQUEST_DIGEST_KEY,
|
|
||||||
NewTaskIdentityRequest,
|
|
||||||
PERSISTED_DISPATCH_STATUSES,
|
|
||||||
RootActionDispatchReceipt,
|
|
||||||
TaskAuthoritySnapshot,
|
|
||||||
TaskIdentityLineageService,
|
|
||||||
TerminalReviewOutcome,
|
|
||||||
_as_task_record,
|
|
||||||
)
|
|
||||||
from ..aios_task_lineage_taskplane_adapter import (
|
|
||||||
AuthenticatedTaskContext,
|
|
||||||
InProcessTaskPlaneLineageAdapter,
|
|
||||||
TaskPlaneServiceBindings,
|
|
||||||
)
|
|
||||||
from ..aios_task_lineage_hooks import TaskLineageLifecycleHooks
|
|
||||||
else: # top-level task_plane import used by direct-module test harnesses
|
|
||||||
from aios_task_lineage import (
|
|
||||||
AcceptanceCondition,
|
|
||||||
AcceptanceEvaluator,
|
|
||||||
NextLegalAction,
|
|
||||||
RelationType,
|
|
||||||
RootAcceptanceSnapshot,
|
|
||||||
RootReviewRecord,
|
|
||||||
TaskRecord as LineageTaskRecord,
|
|
||||||
lineage_from_metadata,
|
|
||||||
normalize_goal_slug,
|
|
||||||
root_review_from_record,
|
|
||||||
)
|
|
||||||
from aios_task_lineage_integration import (
|
|
||||||
DISPLAY_NAME_METADATA_KEY,
|
|
||||||
LINEAGE_METADATA_KEY,
|
|
||||||
LINEAGE_REQUEST_DIGEST_KEY,
|
|
||||||
NewTaskIdentityRequest,
|
|
||||||
PERSISTED_DISPATCH_STATUSES,
|
|
||||||
RootActionDispatchReceipt,
|
|
||||||
TaskAuthoritySnapshot,
|
|
||||||
TaskIdentityLineageService,
|
|
||||||
TerminalReviewOutcome,
|
|
||||||
_as_task_record,
|
|
||||||
)
|
|
||||||
from aios_task_lineage_taskplane_adapter import (
|
|
||||||
AuthenticatedTaskContext,
|
|
||||||
InProcessTaskPlaneLineageAdapter,
|
|
||||||
TaskPlaneServiceBindings,
|
|
||||||
)
|
|
||||||
from aios_task_lineage_hooks import TaskLineageLifecycleHooks
|
|
||||||
|
|
||||||
from .delegation import DelegationEngine
|
|
||||||
from .errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
|
||||||
from .models import TaskPlan, TaskRecord, canonical_json, compute_sha256_hex
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
RESERVED_LINEAGE_METADATA_KEYS = frozenset(
|
|
||||||
{LINEAGE_METADATA_KEY, DISPLAY_NAME_METADATA_KEY, LINEAGE_REQUEST_DIGEST_KEY}
|
|
||||||
)
|
|
||||||
PROVEN_TERMINAL_STATES = frozenset({"COMPLETED", "FAILED_FINAL", "CANCELLED"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PrincipalLineageBinding:
|
|
||||||
"""Server-owned projection of one authenticated service identity."""
|
|
||||||
|
|
||||||
authenticated_identity: str
|
|
||||||
owner_principal_id: str
|
|
||||||
mission_id: str
|
|
||||||
preferred_owner_alias: str | None
|
|
||||||
root_acceptance_ref: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in (
|
|
||||||
"authenticated_identity",
|
|
||||||
"owner_principal_id",
|
|
||||||
"mission_id",
|
|
||||||
"root_acceptance_ref",
|
|
||||||
):
|
|
||||||
if not str(getattr(self, name)).strip():
|
|
||||||
raise ValueError(f"{name} is required")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskLineagePolicy:
|
|
||||||
"""Immutable service policy for authenticated identity and acceptance."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
principal_bindings: Sequence[PrincipalLineageBinding],
|
|
||||||
acceptance_evaluators: Mapping[str, AcceptanceEvaluator],
|
|
||||||
acceptance_evaluator_resolver: Callable[
|
|
||||||
[str], AcceptanceEvaluator | None
|
|
||||||
]
|
|
||||||
| None = None,
|
|
||||||
) -> None:
|
|
||||||
self._bindings: dict[str, PrincipalLineageBinding] = {}
|
|
||||||
for binding in principal_bindings:
|
|
||||||
if binding.authenticated_identity in self._bindings:
|
|
||||||
raise ValueError("duplicate authenticated lineage identity")
|
|
||||||
self._bindings[binding.authenticated_identity] = binding
|
|
||||||
self._evaluators = dict(acceptance_evaluators)
|
|
||||||
self._acceptance_evaluator_resolver = acceptance_evaluator_resolver
|
|
||||||
|
|
||||||
def with_acceptance_evaluator_resolver(
|
|
||||||
self, resolver: Callable[[str], AcceptanceEvaluator | None]
|
|
||||||
) -> "TaskLineagePolicy":
|
|
||||||
previous = self._acceptance_evaluator_resolver
|
|
||||||
|
|
||||||
def combined(reference: str) -> AcceptanceEvaluator | None:
|
|
||||||
resolved = resolver(reference)
|
|
||||||
if resolved is not None:
|
|
||||||
return resolved
|
|
||||||
return previous(reference) if previous is not None else None
|
|
||||||
|
|
||||||
return TaskLineagePolicy(
|
|
||||||
principal_bindings=tuple(self._bindings.values()),
|
|
||||||
acceptance_evaluators=self._evaluators,
|
|
||||||
acceptance_evaluator_resolver=combined,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_principal(self, authenticated_identity: str) -> PrincipalLineageBinding:
|
|
||||||
try:
|
|
||||||
return self._bindings[authenticated_identity]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise ForbiddenError("authenticated identity has no lineage policy binding") from exc
|
|
||||||
|
|
||||||
def _acceptance_evaluator(self, reference: str) -> AcceptanceEvaluator | None:
|
|
||||||
evaluator = self._evaluators.get(reference)
|
|
||||||
if evaluator is None and self._acceptance_evaluator_resolver is not None:
|
|
||||||
evaluator = self._acceptance_evaluator_resolver(reference)
|
|
||||||
return evaluator
|
|
||||||
|
|
||||||
def assert_acceptance_ref_registered(self, reference: str) -> None:
|
|
||||||
if self._acceptance_evaluator(reference) is None:
|
|
||||||
raise ForbiddenError("root acceptance policy is not registered")
|
|
||||||
|
|
||||||
def evaluate_acceptance(
|
|
||||||
self,
|
|
||||||
root: LineageTaskRecord,
|
|
||||||
descendants: Sequence[LineageTaskRecord],
|
|
||||||
) -> RootAcceptanceSnapshot:
|
|
||||||
reference = root.lineage.root_acceptance_ref
|
|
||||||
evaluator = self._acceptance_evaluator(reference)
|
|
||||||
if evaluator is None:
|
|
||||||
raise ForbiddenError("root acceptance policy is not registered")
|
|
||||||
snapshot = evaluator(root, descendants)
|
|
||||||
if not isinstance(snapshot, RootAcceptanceSnapshot) or not snapshot.conditions:
|
|
||||||
return RootAcceptanceSnapshot(
|
|
||||||
(
|
|
||||||
AcceptanceCondition(
|
|
||||||
key="root-acceptance-proof-unavailable",
|
|
||||||
satisfied=False,
|
|
||||||
owner_boundary=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
normalized: list[AcceptanceCondition] = []
|
|
||||||
for condition in snapshot.conditions:
|
|
||||||
if condition.satisfied and not condition.evidence_refs:
|
|
||||||
normalized.append(
|
|
||||||
AcceptanceCondition(
|
|
||||||
key=condition.key,
|
|
||||||
satisfied=False,
|
|
||||||
owner_boundary=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
normalized.append(condition)
|
|
||||||
return RootAcceptanceSnapshot(tuple(normalized))
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneLineageStoreBinding:
|
|
||||||
"""Official in-process read/write surface over the existing TaskPlane DB."""
|
|
||||||
|
|
||||||
def __init__(self, store: TaskPlaneStore) -> None:
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def service_bindings(self) -> TaskPlaneServiceBindings:
|
|
||||||
return TaskPlaneServiceBindings(
|
|
||||||
get_task_snapshot=self.get_task_snapshot,
|
|
||||||
list_descendant_snapshots=self.list_descendant_snapshots,
|
|
||||||
reserve_sibling_sequence=self.reserve_sibling_sequence,
|
|
||||||
compare_and_swap_task_metadata=self.compare_and_swap_task_metadata,
|
|
||||||
get_root_review_record=self.get_root_review_record,
|
|
||||||
compare_and_swap_root_review_record=self.compare_and_swap_root_review_record,
|
|
||||||
get_root_action_dispatch=self.get_root_action_dispatch,
|
|
||||||
dispatch_root_action=self.dispatch_root_action,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _terminal_proof(self, task: TaskRecord) -> Mapping[str, object] | None:
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_terminal_proofs WHERE task_id=?", (task.task_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None or task.status not in PROVEN_TERMINAL_STATES:
|
|
||||||
return None
|
|
||||||
claim = self.store.conn.execute(
|
|
||||||
"SELECT status,result_payload FROM claims WHERE claim_id=? AND task_id=?",
|
|
||||||
(row["claim_id"], task.task_id),
|
|
||||||
).fetchone()
|
|
||||||
if claim is None or claim["status"] != "COMPLETED" or not claim["result_payload"]:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
result = json.loads(claim["result_payload"])
|
|
||||||
refs = tuple(json.loads(row["evidence_refs_json"]))
|
|
||||||
except (TypeError, ValueError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
if result.get("result_digest") != row["result_digest"]:
|
|
||||||
return None
|
|
||||||
if task.revision < int(row["task_revision"]):
|
|
||||||
return None
|
|
||||||
evidence_ids = {entry["evidence_id"] for entry in self.store.list_evidence(task.task_id)}
|
|
||||||
if not set(refs).issubset(evidence_ids):
|
|
||||||
return None
|
|
||||||
if not self.store.verify_event_chain(task.task_id):
|
|
||||||
return None
|
|
||||||
terminal_event = any(
|
|
||||||
event["type"] in {"TASK_COMPLETED", "TASK_FAILED"}
|
|
||||||
and event["data"].get("claim_id") == row["claim_id"]
|
|
||||||
for event in self.store.list_events(task.task_id)
|
|
||||||
)
|
|
||||||
if not terminal_event or not self.store.verify_evidence_integrity(task.task_id):
|
|
||||||
return None
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
if not task.owner_principal_id or not task.mission_id:
|
|
||||||
raise ForbiddenError("task lacks server-owned lineage authority binding")
|
|
||||||
proof = self._terminal_proof(task)
|
|
||||||
return TaskAuthoritySnapshot(
|
|
||||||
task_id=task.task_id,
|
|
||||||
revision=task.revision,
|
|
||||||
status=task.status,
|
|
||||||
owner_principal_id=task.owner_principal_id,
|
|
||||||
mission_id=task.mission_id,
|
|
||||||
created_at=datetime.fromtimestamp(task.created_at, tz=timezone.utc),
|
|
||||||
metadata=dict(task.task_plan.metadata),
|
|
||||||
evidence_refs=(
|
|
||||||
tuple(json.loads(proof["evidence_refs_json"])) if proof else ()
|
|
||||||
),
|
|
||||||
terminal_evidence_proven=proof is not None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_descendant_snapshots(
|
|
||||||
self, root_task_id: str
|
|
||||||
) -> tuple[TaskAuthoritySnapshot, ...]:
|
|
||||||
with self.store._lock:
|
|
||||||
rows = self.store.conn.execute(
|
|
||||||
"SELECT task_id FROM tasks WHERE root_task_id=? AND task_id<>? "
|
|
||||||
"ORDER BY created_at,task_id",
|
|
||||||
(root_task_id, root_task_id),
|
|
||||||
).fetchall()
|
|
||||||
return tuple(self.get_task_snapshot(row["task_id"]) for row in rows)
|
|
||||||
|
|
||||||
def reserve_sibling_sequence(
|
|
||||||
self, parent_task_id: str, relation_type: RelationType, child_task_id: str
|
|
||||||
) -> int:
|
|
||||||
with self.store._transaction():
|
|
||||||
existing = self.store.conn.execute(
|
|
||||||
"SELECT parent_task_id,relation_type,sibling_sequence "
|
|
||||||
"FROM task_lineage_sequences WHERE child_task_id=?",
|
|
||||||
(child_task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing["parent_task_id"] != parent_task_id
|
|
||||||
or existing["relation_type"] != relation_type.value
|
|
||||||
):
|
|
||||||
raise ConflictError("child sibling reservation conflicts with lineage")
|
|
||||||
return int(existing["sibling_sequence"])
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT COALESCE(MAX(sibling_sequence),0)+1 AS next_sequence "
|
|
||||||
"FROM task_lineage_sequences WHERE parent_task_id=? AND relation_type=?",
|
|
||||||
(parent_task_id, relation_type.value),
|
|
||||||
).fetchone()
|
|
||||||
sequence = int(row["next_sequence"])
|
|
||||||
if sequence > 99:
|
|
||||||
raise ConflictError("lineage sibling sequence exhausted")
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_lineage_sequences "
|
|
||||||
"(parent_task_id,relation_type,child_task_id,sibling_sequence,created_at) "
|
|
||||||
"VALUES(?,?,?,?,?)",
|
|
||||||
(parent_task_id, relation_type.value, child_task_id, sequence, datetime.now(tz=timezone.utc).timestamp()),
|
|
||||||
)
|
|
||||||
return sequence
|
|
||||||
|
|
||||||
def compare_and_swap_task_metadata(
|
|
||||||
self, task_id: str, expected_revision: int, metadata_patch: Mapping[str, object]
|
|
||||||
) -> bool:
|
|
||||||
if not set(metadata_patch).issubset(RESERVED_LINEAGE_METADATA_KEYS):
|
|
||||||
raise ForbiddenError("lineage CAS contains non-lineage metadata")
|
|
||||||
with self.store._transaction():
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
if task.revision != expected_revision:
|
|
||||||
return False
|
|
||||||
lineage_value = metadata_patch.get(LINEAGE_METADATA_KEY)
|
|
||||||
if isinstance(lineage_value, Mapping):
|
|
||||||
lineage = lineage_from_metadata(lineage_value)
|
|
||||||
if lineage.relation_type != RelationType.ROOT:
|
|
||||||
# The root check and child metadata CAS share this BEGIN
|
|
||||||
# IMMEDIATE transaction, closing the legacy attach race.
|
|
||||||
self.store.assert_root_open_for_child(lineage.root_task_id)
|
|
||||||
if task.status in PROVEN_TERMINAL_STATES:
|
|
||||||
raise ConflictError("terminal task lineage metadata is immutable")
|
|
||||||
payload = task.task_plan.to_dict(redact=False)
|
|
||||||
metadata = dict(payload.get("metadata") or {})
|
|
||||||
for key, value in metadata_patch.items():
|
|
||||||
if key in metadata and metadata[key] != value:
|
|
||||||
raise ConflictError("authoritative lineage metadata conflict")
|
|
||||||
metadata[key] = value
|
|
||||||
payload["metadata"] = metadata
|
|
||||||
now = datetime.now(tz=timezone.utc).timestamp()
|
|
||||||
updated = self.store.conn.execute(
|
|
||||||
"UPDATE tasks SET task_json=?,revision=revision+1,updated_at=? "
|
|
||||||
"WHERE task_id=? AND revision=?",
|
|
||||||
(json.dumps(payload, ensure_ascii=False), now, task_id, expected_revision),
|
|
||||||
)
|
|
||||||
if updated.rowcount != 1:
|
|
||||||
return False
|
|
||||||
self.store.append_event(
|
|
||||||
task_id,
|
|
||||||
"TASK_LINEAGE_BOUND",
|
|
||||||
{"metadata_keys": sorted(metadata_patch)},
|
|
||||||
subject=f"task/{task_id}",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def record_terminal_proof(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
claim_id: str,
|
|
||||||
result_digest: str,
|
|
||||||
evidence_refs: Sequence[str],
|
|
||||||
) -> None:
|
|
||||||
with self.store._transaction():
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
if task.status not in PROVEN_TERMINAL_STATES:
|
|
||||||
raise ConflictError("terminal proof requires a final task status")
|
|
||||||
claim = self.store.conn.execute(
|
|
||||||
"SELECT status,result_payload FROM claims WHERE claim_id=? AND task_id=?",
|
|
||||||
(claim_id, task_id),
|
|
||||||
).fetchone()
|
|
||||||
if claim is None or claim["status"] != "COMPLETED" or not claim["result_payload"]:
|
|
||||||
raise ConflictError("terminal proof requires a completed claim")
|
|
||||||
stored_result = json.loads(claim["result_payload"])
|
|
||||||
if stored_result.get("result_digest") != result_digest:
|
|
||||||
raise ConflictError("terminal proof result digest mismatch")
|
|
||||||
refs = tuple(str(value) for value in evidence_refs)
|
|
||||||
evidence_ids = {entry["evidence_id"] for entry in self.store.list_evidence(task_id)}
|
|
||||||
if not set(refs).issubset(evidence_ids):
|
|
||||||
raise ConflictError("terminal proof evidence reference mismatch")
|
|
||||||
existing = self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_terminal_proofs WHERE task_id=?", (task_id,)
|
|
||||||
).fetchone()
|
|
||||||
expected = (claim_id, result_digest, canonical_json(list(refs)))
|
|
||||||
if existing is not None:
|
|
||||||
actual = (
|
|
||||||
existing["claim_id"],
|
|
||||||
existing["result_digest"],
|
|
||||||
canonical_json(json.loads(existing["evidence_refs_json"])),
|
|
||||||
)
|
|
||||||
if actual != expected:
|
|
||||||
raise ConflictError("terminal proof replay conflict")
|
|
||||||
return
|
|
||||||
if not self.store.verify_event_chain(task_id) or not self.store.verify_evidence_integrity(task_id):
|
|
||||||
raise ConflictError("terminal proof journal integrity failed")
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_terminal_proofs "
|
|
||||||
"(task_id,claim_id,task_revision,result_digest,evidence_refs_json,created_at) "
|
|
||||||
"VALUES(?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
task_id,
|
|
||||||
claim_id,
|
|
||||||
task.revision,
|
|
||||||
result_digest,
|
|
||||||
json.dumps(list(refs), ensure_ascii=False),
|
|
||||||
datetime.now(tz=timezone.utc).timestamp(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT record_json FROM task_root_reviews WHERE review_id=?", (review_id,)
|
|
||||||
).fetchone()
|
|
||||||
return None if row is None else json.loads(row["record_json"])
|
|
||||||
|
|
||||||
def get_root_review_for_trigger(
|
|
||||||
self, trigger_child_task_id: str
|
|
||||||
) -> Mapping[str, object] | None:
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT record_json FROM task_root_reviews "
|
|
||||||
"WHERE trigger_child_task_id=? ORDER BY created_at LIMIT 1",
|
|
||||||
(trigger_child_task_id,),
|
|
||||||
).fetchone()
|
|
||||||
return None if row is None else json.loads(row["record_json"])
|
|
||||||
|
|
||||||
def compare_and_swap_root_review_record(
|
|
||||||
self,
|
|
||||||
root_task_id: str,
|
|
||||||
expected_root_revision: int,
|
|
||||||
review_record: Mapping[str, object],
|
|
||||||
) -> bool:
|
|
||||||
with self.store._transaction():
|
|
||||||
review_id = str(review_record.get("review_id") or "")
|
|
||||||
trigger_id = str(review_record.get("trigger_child_task_id") or "")
|
|
||||||
if (
|
|
||||||
review_record.get("root_task_id") != root_task_id
|
|
||||||
or int(review_record.get("root_revision_before", -1)) != expected_root_revision
|
|
||||||
or int(review_record.get("root_revision_after", -1)) != expected_root_revision + 1
|
|
||||||
):
|
|
||||||
raise ConflictError("root review authority binding mismatch")
|
|
||||||
existing = self.get_root_review_record(review_id)
|
|
||||||
if existing is not None:
|
|
||||||
if canonical_json(existing) != canonical_json(dict(review_record)):
|
|
||||||
raise ConflictError("root review replay conflict")
|
|
||||||
return True
|
|
||||||
trigger = self.get_task_snapshot(trigger_id)
|
|
||||||
if not trigger.terminal_evidence_proven:
|
|
||||||
raise ConflictError("root review trigger is not proven terminal")
|
|
||||||
trigger_revision = trigger.revision
|
|
||||||
frozen = self.store.conn.execute(
|
|
||||||
"SELECT review_id FROM task_root_reviews "
|
|
||||||
"WHERE trigger_child_task_id=? AND trigger_child_revision=?",
|
|
||||||
(trigger_id, trigger_revision),
|
|
||||||
).fetchone()
|
|
||||||
if frozen is not None:
|
|
||||||
raise ConflictError("terminal trigger already has a different root review")
|
|
||||||
root = self.store.get_task(root_task_id)
|
|
||||||
if root.revision != expected_root_revision:
|
|
||||||
return False
|
|
||||||
now = datetime.now(tz=timezone.utc).timestamp()
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_root_reviews "
|
|
||||||
"(review_id,root_task_id,trigger_child_task_id,trigger_child_revision,"
|
|
||||||
"root_revision_before,root_revision_after,record_json,created_at) "
|
|
||||||
"VALUES(?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
review_id,
|
|
||||||
root_task_id,
|
|
||||||
trigger_id,
|
|
||||||
trigger_revision,
|
|
||||||
expected_root_revision,
|
|
||||||
expected_root_revision + 1,
|
|
||||||
json.dumps(dict(review_record), ensure_ascii=False, sort_keys=True),
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
updated = self.store.conn.execute(
|
|
||||||
"UPDATE tasks SET revision=revision+1,updated_at=? "
|
|
||||||
"WHERE task_id=? AND revision=?",
|
|
||||||
(now, root_task_id, expected_root_revision),
|
|
||||||
)
|
|
||||||
if updated.rowcount != 1:
|
|
||||||
raise ConflictError("root review CAS lost after reservation")
|
|
||||||
self.store.append_event(
|
|
||||||
root_task_id,
|
|
||||||
"ROOT_REVIEW",
|
|
||||||
{
|
|
||||||
"review_id": review_id,
|
|
||||||
"trigger_child_task_id": trigger_id,
|
|
||||||
"next_legal_action": review_record.get("next_legal_action"),
|
|
||||||
"root_complete": bool(review_record.get("root_complete")),
|
|
||||||
},
|
|
||||||
subject=f"task/{root_task_id}",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None:
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT * FROM task_root_action_dispatches WHERE review_id=?", (review_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return RootActionDispatchReceipt(
|
|
||||||
review_id=row["review_id"],
|
|
||||||
root_task_id=row["root_task_id"],
|
|
||||||
action=NextLegalAction(row["action"]),
|
|
||||||
idempotency_key=row["idempotency_key"],
|
|
||||||
status=row["status"],
|
|
||||||
mission_id=row["mission_id"],
|
|
||||||
root_revision=int(row["root_revision"]),
|
|
||||||
plan_digest=row["plan_digest"],
|
|
||||||
execution_key=row["execution_key"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispatch_root_action(
|
|
||||||
self, review: RootReviewRecord, idempotency_key: str
|
|
||||||
) -> RootActionDispatchReceipt:
|
|
||||||
with self.store._transaction():
|
|
||||||
persisted = self.get_root_review_record(review.review_id)
|
|
||||||
if persisted is None:
|
|
||||||
raise ConflictError("root action requires a persisted root review")
|
|
||||||
existing = self.get_root_action_dispatch(review.review_id)
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing.root_task_id != review.root_task_id
|
|
||||||
or existing.action != review.next_legal_action
|
|
||||||
or existing.idempotency_key != idempotency_key
|
|
||||||
):
|
|
||||||
raise ConflictError("root action dispatch replay conflict")
|
|
||||||
return existing
|
|
||||||
now = datetime.now(tz=timezone.utc).timestamp()
|
|
||||||
self.store.conn.execute(
|
|
||||||
"INSERT INTO task_root_action_dispatches "
|
|
||||||
"(review_id,root_task_id,action,idempotency_key,status,created_at,"
|
|
||||||
"mission_id,root_revision,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
review.review_id,
|
|
||||||
review.root_task_id,
|
|
||||||
review.next_legal_action.value,
|
|
||||||
idempotency_key,
|
|
||||||
"PENDING",
|
|
||||||
now,
|
|
||||||
self.store.get_task(review.root_task_id).mission_id,
|
|
||||||
review.root_revision_after,
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.store.append_event(
|
|
||||||
review.root_task_id,
|
|
||||||
"ROOT_NEXT_ACTION_PENDING",
|
|
||||||
{
|
|
||||||
"review_id": review.review_id,
|
|
||||||
"action": review.next_legal_action.value,
|
|
||||||
"idempotency_key": idempotency_key,
|
|
||||||
},
|
|
||||||
subject=f"task/{review.root_task_id}",
|
|
||||||
)
|
|
||||||
return self.get_root_action_dispatch(review.review_id) # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneLineageCoordinator:
|
|
||||||
"""Coordinates lineage with real TaskPlane admission and completion paths."""
|
|
||||||
|
|
||||||
def __init__(self, store: TaskPlaneStore, policy: TaskLineagePolicy) -> None:
|
|
||||||
self.store = store
|
|
||||||
self.policy = policy
|
|
||||||
self.binding = TaskPlaneLineageStoreBinding(store)
|
|
||||||
self.adapter = InProcessTaskPlaneLineageAdapter(
|
|
||||||
self.binding.service_bindings(), readback=self.binding
|
|
||||||
)
|
|
||||||
self.service = TaskIdentityLineageService(self.adapter)
|
|
||||||
self.hooks = TaskLineageLifecycleHooks(self.adapter)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _request_digest(payload: Mapping[str, object]) -> str:
|
|
||||||
return compute_sha256_hex(dict(payload))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _reject_reserved_metadata(plan: TaskPlan) -> None:
|
|
||||||
if set(plan.metadata).intersection(RESERVED_LINEAGE_METADATA_KEYS):
|
|
||||||
raise ValidationError("lineage metadata is server-owned")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _goal_slug(plan: TaskPlan) -> str:
|
|
||||||
for candidate in (plan.intent, plan.task_type, f"TASK-{plan.task_id}"):
|
|
||||||
if not candidate:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
return normalize_goal_slug(str(candidate))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
raise ValidationError("task goal cannot be normalized for lineage")
|
|
||||||
|
|
||||||
def _existing_by_idempotency(self, key: str | None) -> TaskRecord | None:
|
|
||||||
if not key:
|
|
||||||
return None
|
|
||||||
row = self.store.conn.execute(
|
|
||||||
"SELECT task_id FROM tasks WHERE idempotency_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
return None if row is None else self.store.get_task(row["task_id"])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _verify_replay(
|
|
||||||
task: TaskRecord,
|
|
||||||
*,
|
|
||||||
request_digest: str,
|
|
||||||
principal: PrincipalLineageBinding,
|
|
||||||
parent_task_id: str | None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
metadata = task.task_plan.metadata
|
|
||||||
if (
|
|
||||||
metadata.get(LINEAGE_REQUEST_DIGEST_KEY) != request_digest
|
|
||||||
or task.owner_principal_id != principal.owner_principal_id
|
|
||||||
or task.mission_id != principal.mission_id
|
|
||||||
or task.task_plan.parent_task_id != parent_task_id
|
|
||||||
):
|
|
||||||
raise ConflictError("lineage admission replay conflict")
|
|
||||||
return task
|
|
||||||
|
|
||||||
def create_root(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
plan: TaskPlan,
|
|
||||||
authenticated_identity: str,
|
|
||||||
request_digest: str,
|
|
||||||
idempotency_key: str | None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
principal = self.policy.resolve_principal(authenticated_identity)
|
|
||||||
return self.create_root_for_principal(
|
|
||||||
plan=plan,
|
|
||||||
principal=principal,
|
|
||||||
request_digest=request_digest,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_root_for_principal(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
plan: TaskPlan,
|
|
||||||
principal: PrincipalLineageBinding,
|
|
||||||
request_digest: str,
|
|
||||||
idempotency_key: str | None,
|
|
||||||
created_at: datetime | None = None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
self.policy.assert_acceptance_ref_registered(principal.root_acceptance_ref)
|
|
||||||
self._reject_reserved_metadata(plan)
|
|
||||||
with self.store._transaction():
|
|
||||||
existing = self._existing_by_idempotency(idempotency_key)
|
|
||||||
if existing is not None:
|
|
||||||
return self._verify_replay(
|
|
||||||
existing,
|
|
||||||
request_digest=request_digest,
|
|
||||||
principal=principal,
|
|
||||||
parent_task_id=None,
|
|
||||||
)
|
|
||||||
created_at = created_at or datetime.now(tz=timezone.utc)
|
|
||||||
if created_at.tzinfo is None or created_at.utcoffset() is None:
|
|
||||||
raise ValidationError("lineage created_at must be timezone-aware")
|
|
||||||
created_at = created_at.astimezone(timezone.utc)
|
|
||||||
prepared = self.service.prepare_new_task_identity(
|
|
||||||
NewTaskIdentityRequest(
|
|
||||||
task_id=str(plan.task_id),
|
|
||||||
owner_principal_id=principal.owner_principal_id,
|
|
||||||
preferred_owner_alias=principal.preferred_owner_alias,
|
|
||||||
created_at=created_at,
|
|
||||||
mission_id=principal.mission_id,
|
|
||||||
goal_slug=self._goal_slug(plan),
|
|
||||||
root_acceptance_ref=principal.root_acceptance_ref,
|
|
||||||
relation_type=RelationType.ROOT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
plan.metadata = {
|
|
||||||
**plan.metadata,
|
|
||||||
**prepared.metadata_patch,
|
|
||||||
LINEAGE_REQUEST_DIGEST_KEY: request_digest,
|
|
||||||
}
|
|
||||||
return self.store.create_task_with_event(
|
|
||||||
plan,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
owner_principal_id=principal.owner_principal_id,
|
|
||||||
mission_id=principal.mission_id,
|
|
||||||
created_at=created_at.timestamp(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def delegate_child(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
parent_task_id: str,
|
|
||||||
plan: TaskPlan,
|
|
||||||
authenticated_identity: str,
|
|
||||||
request_digest: str,
|
|
||||||
delegation: DelegationEngine,
|
|
||||||
principal: PrincipalLineageBinding | None = None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
if not plan.task_id:
|
|
||||||
raise ValidationError("lineage delegation requires a stable task_id")
|
|
||||||
principal = principal or self.policy.resolve_principal(authenticated_identity)
|
|
||||||
if principal.authenticated_identity != authenticated_identity:
|
|
||||||
raise ForbiddenError("delegation principal identity mismatch")
|
|
||||||
self._reject_reserved_metadata(plan)
|
|
||||||
with self.store._transaction():
|
|
||||||
parent = self.store.get_task(parent_task_id)
|
|
||||||
root = self.store.get_task(parent.root_task_id)
|
|
||||||
if (
|
|
||||||
root.owner_principal_id != principal.owner_principal_id
|
|
||||||
or root.mission_id != principal.mission_id
|
|
||||||
):
|
|
||||||
raise ForbiddenError("delegation actor does not match root authority")
|
|
||||||
try:
|
|
||||||
existing = self.store.get_task(str(plan.task_id))
|
|
||||||
except NotFoundError:
|
|
||||||
existing = None
|
|
||||||
if existing is not None:
|
|
||||||
return self._verify_replay(
|
|
||||||
existing,
|
|
||||||
request_digest=request_digest,
|
|
||||||
principal=principal,
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
)
|
|
||||||
self.store.assert_root_open_for_child(root.task_id)
|
|
||||||
created_at = datetime.now(tz=timezone.utc)
|
|
||||||
prepared = self.service.prepare_new_task_identity(
|
|
||||||
NewTaskIdentityRequest(
|
|
||||||
task_id=str(plan.task_id),
|
|
||||||
owner_principal_id=root.owner_principal_id,
|
|
||||||
preferred_owner_alias=principal.preferred_owner_alias,
|
|
||||||
created_at=created_at,
|
|
||||||
mission_id=root.mission_id,
|
|
||||||
goal_slug=self._goal_slug(plan),
|
|
||||||
root_acceptance_ref=str(
|
|
||||||
root.task_plan.metadata[LINEAGE_METADATA_KEY]["root_acceptance_ref"]
|
|
||||||
),
|
|
||||||
relation_type=RelationType.CHILD,
|
|
||||||
parent_task_id=parent_task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
plan.metadata = {
|
|
||||||
**plan.metadata,
|
|
||||||
**prepared.metadata_patch,
|
|
||||||
LINEAGE_REQUEST_DIGEST_KEY: request_digest,
|
|
||||||
}
|
|
||||||
return delegation.delegate(
|
|
||||||
parent_task_id,
|
|
||||||
plan,
|
|
||||||
caller=authenticated_identity,
|
|
||||||
owner_principal_id=root.owner_principal_id,
|
|
||||||
mission_id=root.mission_id,
|
|
||||||
created_at=created_at.timestamp(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def current_acceptance_snapshot(self, *, root_task_id: str) -> RootAcceptanceSnapshot:
|
|
||||||
"""Evaluate the current authoritative root/descendant projection."""
|
|
||||||
root = _as_task_record(self.binding.get_task_snapshot(root_task_id))
|
|
||||||
descendants = tuple(
|
|
||||||
_as_task_record(snapshot)
|
|
||||||
for snapshot in self.binding.list_descendant_snapshots(root_task_id)
|
|
||||||
)
|
|
||||||
return self.policy.evaluate_acceptance(root, descendants)
|
|
||||||
|
|
||||||
def review_proven_terminal_child(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
child_task_id: str,
|
|
||||||
claim_id: str,
|
|
||||||
result_digest: str,
|
|
||||||
evidence_refs: Sequence[str],
|
|
||||||
):
|
|
||||||
with self.store._transaction():
|
|
||||||
self.binding.record_terminal_proof(
|
|
||||||
task_id=child_task_id,
|
|
||||||
claim_id=claim_id,
|
|
||||||
result_digest=result_digest,
|
|
||||||
evidence_refs=evidence_refs,
|
|
||||||
)
|
|
||||||
return self.resume_terminal_review(child_task_id=child_task_id)
|
|
||||||
|
|
||||||
def resume_terminal_review(self, *, child_task_id: str) -> TerminalReviewOutcome:
|
|
||||||
"""Resume after restart without re-completing a claim or re-evaluating a review.
|
|
||||||
|
|
||||||
Once a trigger has a persisted review, that exact decision is frozen.
|
|
||||||
Policy/evaluator drift therefore cannot create a second decision for the
|
|
||||||
same terminal transition.
|
|
||||||
"""
|
|
||||||
with self.store._transaction():
|
|
||||||
child = self.binding.get_task_snapshot(child_task_id)
|
|
||||||
if not child.terminal_evidence_proven:
|
|
||||||
raise ConflictError("terminal review resume requires durable proof")
|
|
||||||
raw_lineage = child.metadata.get(LINEAGE_METADATA_KEY)
|
|
||||||
if not isinstance(raw_lineage, Mapping):
|
|
||||||
raise ConflictError("lineage child lacks authoritative lineage metadata")
|
|
||||||
try:
|
|
||||||
child_lineage = lineage_from_metadata(dict(raw_lineage))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ConflictError("lineage child metadata is invalid") from exc
|
|
||||||
persisted = self.binding.get_root_review_for_trigger(child_task_id)
|
|
||||||
if persisted is not None:
|
|
||||||
review_id = persisted.get("review_id")
|
|
||||||
if not isinstance(review_id, str) or not review_id:
|
|
||||||
raise ConflictError("persisted root review identity is invalid")
|
|
||||||
try:
|
|
||||||
review = root_review_from_record(
|
|
||||||
persisted, expected_review_id=review_id
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ConflictError("persisted root review is invalid") from exc
|
|
||||||
root = self.binding.get_task_snapshot(child_lineage.root_task_id)
|
|
||||||
if (
|
|
||||||
review.trigger_child_task_id != child_task_id
|
|
||||||
or review.root_task_id != child_lineage.root_task_id
|
|
||||||
or child_lineage.task_id != child_task_id
|
|
||||||
or child_lineage.owner_principal_id != child.owner_principal_id
|
|
||||||
or child_lineage.mission_id != child.mission_id
|
|
||||||
or root.owner_principal_id != child.owner_principal_id
|
|
||||||
or root.mission_id != child.mission_id
|
|
||||||
or root.revision < review.root_revision_after
|
|
||||||
):
|
|
||||||
raise ConflictError("persisted root review authority mismatch")
|
|
||||||
dispatch = self.binding.get_root_action_dispatch(review.review_id)
|
|
||||||
if dispatch is None:
|
|
||||||
dispatch = self.binding.dispatch_root_action(
|
|
||||||
review,
|
|
||||||
f"root-review:{review.review_id}:{review.next_legal_action.value}",
|
|
||||||
)
|
|
||||||
expected_key = (
|
|
||||||
f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
dispatch.review_id != review.review_id
|
|
||||||
or dispatch.root_task_id != review.root_task_id
|
|
||||||
or dispatch.action != review.next_legal_action
|
|
||||||
or dispatch.idempotency_key != expected_key
|
|
||||||
or dispatch.status not in PERSISTED_DISPATCH_STATUSES
|
|
||||||
):
|
|
||||||
raise ConflictError("persisted root action dispatch is invalid")
|
|
||||||
return TerminalReviewOutcome(review=review, dispatch=dispatch)
|
|
||||||
hook_outcome = self.hooks.on_proven_terminal_child(
|
|
||||||
child_task_id=child_task_id,
|
|
||||||
context=AuthenticatedTaskContext(
|
|
||||||
principal_id=child.owner_principal_id,
|
|
||||||
preferred_alias=child_lineage.owner_alias,
|
|
||||||
mission_id=child.mission_id,
|
|
||||||
),
|
|
||||||
acceptance_evaluator=self.policy.evaluate_acceptance,
|
|
||||||
reviewed_at=datetime.now(tz=timezone.utc),
|
|
||||||
require_independent_readback=False,
|
|
||||||
)
|
|
||||||
return hook_outcome.outcome
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -388,8 +388,6 @@ class TaskRecord:
|
|||||||
root_task_id: str
|
root_task_id: str
|
||||||
idempotency_key: str | None = None
|
idempotency_key: str | None = None
|
||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
owner_principal_id: str = ""
|
|
||||||
mission_id: str = ""
|
|
||||||
|
|
||||||
def with_status(self, status: str) -> "TaskRecord":
|
def with_status(self, status: str) -> "TaskRecord":
|
||||||
allowed = TASK_NEXT_STATES.get(self.status, set())
|
allowed = TASK_NEXT_STATES.get(self.status, set())
|
||||||
@@ -409,8 +407,6 @@ class TaskRecord:
|
|||||||
root_task_id=self.root_task_id,
|
root_task_id=self.root_task_id,
|
||||||
idempotency_key=self.idempotency_key,
|
idempotency_key=self.idempotency_key,
|
||||||
failure_reason=self.failure_reason,
|
failure_reason=self.failure_reason,
|
||||||
owner_principal_id=self.owner_principal_id,
|
|
||||||
mission_id=self.mission_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
|
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
|
||||||
@@ -426,9 +422,6 @@ class TaskRecord:
|
|||||||
}
|
}
|
||||||
if include_task:
|
if include_task:
|
||||||
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
|
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
|
||||||
display_name = self.task_plan.metadata.get("aios_task_display_name")
|
|
||||||
if self.owner_principal_id and self.mission_id and isinstance(display_name, str):
|
|
||||||
payload["display_name"] = display_name
|
|
||||||
if self.idempotency_key:
|
if self.idempotency_key:
|
||||||
payload["idempotency_key"] = self.idempotency_key
|
payload["idempotency_key"] = self.idempotency_key
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,513 +0,0 @@
|
|||||||
"""Diagnostic schedulability proof and claim-time authoritative recheck.
|
|
||||||
|
|
||||||
The proof is evidence, never a bearer credential.
|
|
||||||
|
|
||||||
A claim proceeds in three stages:
|
|
||||||
1. read-only preflight/discovery;
|
|
||||||
2. claim-time read-only recheck;
|
|
||||||
3. final *atomic* authority operation that revalidates all mutable claim
|
|
||||||
conditions at the linearization point and returns lease/fencing receipt.
|
|
||||||
|
|
||||||
This module does not create a second scheduler or a second TaskPlane authority.
|
|
||||||
The host adapter must bind ``claim_atomically`` to the existing authoritative
|
|
||||||
claim transaction / repository operation.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from typing import Any, Mapping, Protocol, Sequence
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from .models import canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
CLAIMABLE_TASK_STATES = frozenset({"CREATED", "QUEUED", "FAILED_RECOVERABLE"})
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulabilityError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulabilityChanged(SchedulabilityError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> bytes:
|
|
||||||
# Reuse the existing TaskPlane canonical JSON contract. Do not introduce
|
|
||||||
# another sort/separator encoder for schedulability evidence.
|
|
||||||
return canonical_json(value).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: object, name: str, *, max_len: int = 256) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise SchedulabilityError(f"{name} must be a string")
|
|
||||||
value = value.strip()
|
|
||||||
if not value or len(value) > max_len or any(ord(ch) < 32 for ch in value):
|
|
||||||
raise SchedulabilityError(f"invalid {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _int_authority(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
default: int | None = None,
|
|
||||||
minimum: int = 0,
|
|
||||||
) -> tuple[int, "Blocker | None"]:
|
|
||||||
if value is None:
|
|
||||||
if default is None:
|
|
||||||
return 0, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} missing")
|
|
||||||
return default, None
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
|
||||||
return 0, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} must be integer")
|
|
||||||
if value < minimum:
|
|
||||||
return value, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} below {minimum}")
|
|
||||||
return value, None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Blocker:
|
|
||||||
code: str
|
|
||||||
plane: str
|
|
||||||
detail: str
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, str]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ClaimConditions:
|
|
||||||
"""Mutable authority conditions that MUST be checked atomically with claim."""
|
|
||||||
|
|
||||||
task_revision: int
|
|
||||||
task_generation: int
|
|
||||||
task_status: str
|
|
||||||
worker_generation: int
|
|
||||||
worker_identity: str
|
|
||||||
authorization_ref: str
|
|
||||||
authorization_revision: str
|
|
||||||
scope_digest: str
|
|
||||||
capacity_limit: int
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SchedulabilityObservation:
|
|
||||||
task_id: str
|
|
||||||
task_revision: int
|
|
||||||
task_generation: int
|
|
||||||
task_status: str
|
|
||||||
worker_id: str
|
|
||||||
worker_generation: int
|
|
||||||
worker_identity: str
|
|
||||||
heartbeat_observed_at: float
|
|
||||||
heartbeat_fresh: bool
|
|
||||||
required_capabilities: tuple[str, ...]
|
|
||||||
worker_capabilities: tuple[str, ...]
|
|
||||||
scope_digest: str
|
|
||||||
authorization_ref: str
|
|
||||||
authorization_revision: str
|
|
||||||
authorization_allowed: bool
|
|
||||||
capacity_limit: int
|
|
||||||
active_claims: int
|
|
||||||
dependencies_satisfied: bool
|
|
||||||
freeze_blocked: bool
|
|
||||||
conflicting_lease: bool
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capacity_remaining(self) -> int:
|
|
||||||
return max(0, self.capacity_limit - self.active_claims)
|
|
||||||
|
|
||||||
def stable_binding(self) -> dict[str, object]:
|
|
||||||
"""Fields whose change invalidates an earlier proof."""
|
|
||||||
return {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"task_revision": self.task_revision,
|
|
||||||
"task_generation": self.task_generation,
|
|
||||||
"task_status": self.task_status,
|
|
||||||
"worker_id": self.worker_id,
|
|
||||||
"worker_generation": self.worker_generation,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"required_capabilities": list(self.required_capabilities),
|
|
||||||
"worker_capabilities": list(self.worker_capabilities),
|
|
||||||
"scope_digest": self.scope_digest,
|
|
||||||
"authorization_ref": self.authorization_ref,
|
|
||||||
"authorization_revision": self.authorization_revision,
|
|
||||||
"authorization_allowed": self.authorization_allowed,
|
|
||||||
"capacity_limit": self.capacity_limit,
|
|
||||||
"active_claims": self.active_claims,
|
|
||||||
"dependencies_satisfied": self.dependencies_satisfied,
|
|
||||||
"freeze_blocked": self.freeze_blocked,
|
|
||||||
"conflicting_lease": self.conflicting_lease,
|
|
||||||
"heartbeat_fresh": self.heartbeat_fresh,
|
|
||||||
}
|
|
||||||
|
|
||||||
def claim_conditions(self) -> ClaimConditions:
|
|
||||||
return ClaimConditions(
|
|
||||||
task_revision=self.task_revision,
|
|
||||||
task_generation=self.task_generation,
|
|
||||||
task_status=self.task_status,
|
|
||||||
worker_generation=self.worker_generation,
|
|
||||||
worker_identity=self.worker_identity,
|
|
||||||
authorization_ref=self.authorization_ref,
|
|
||||||
authorization_revision=self.authorization_revision,
|
|
||||||
scope_digest=self.scope_digest,
|
|
||||||
capacity_limit=self.capacity_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SchedulabilityProof:
|
|
||||||
schema: str
|
|
||||||
proof_id: str
|
|
||||||
task_id: str
|
|
||||||
worker_id: str
|
|
||||||
actor_identity: str
|
|
||||||
created_at: float
|
|
||||||
expires_at: float
|
|
||||||
observation: SchedulabilityObservation
|
|
||||||
blockers: tuple[Blocker, ...]
|
|
||||||
observation_digest: str
|
|
||||||
proof_digest: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
worker_id: str,
|
|
||||||
actor_identity: str,
|
|
||||||
observation: SchedulabilityObservation,
|
|
||||||
blockers: Sequence[Blocker],
|
|
||||||
now: float,
|
|
||||||
ttl_seconds: float,
|
|
||||||
proof_id: str | None = None,
|
|
||||||
) -> "SchedulabilityProof":
|
|
||||||
if ttl_seconds <= 0 or ttl_seconds > 300:
|
|
||||||
raise ValueError("schedulability proof ttl must be in (0,300]")
|
|
||||||
payload = {
|
|
||||||
"schema": "aios.schedulability-proof.v2",
|
|
||||||
"proof_id": proof_id or f"sched-{uuid.uuid4()}",
|
|
||||||
"task_id": _text(task_id, "task_id"),
|
|
||||||
"worker_id": _text(worker_id, "worker_id"),
|
|
||||||
"actor_identity": _text(actor_identity, "actor_identity", max_len=512),
|
|
||||||
"created_at": float(now),
|
|
||||||
"expires_at": float(now + ttl_seconds),
|
|
||||||
"observation": observation.stable_binding(),
|
|
||||||
"blockers": [b.to_dict() for b in blockers],
|
|
||||||
}
|
|
||||||
obs_digest = _sha(payload["observation"])
|
|
||||||
proof_digest = _sha({**payload, "observation_digest": obs_digest})
|
|
||||||
return cls(
|
|
||||||
schema=payload["schema"],
|
|
||||||
proof_id=payload["proof_id"],
|
|
||||||
task_id=payload["task_id"],
|
|
||||||
worker_id=payload["worker_id"],
|
|
||||||
actor_identity=payload["actor_identity"],
|
|
||||||
created_at=payload["created_at"],
|
|
||||||
expires_at=payload["expires_at"],
|
|
||||||
observation=observation,
|
|
||||||
blockers=tuple(blockers),
|
|
||||||
observation_digest=obs_digest,
|
|
||||||
proof_digest=proof_digest,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def claimable(self) -> bool:
|
|
||||||
return not self.blockers
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DiscoveryDiagnostics:
|
|
||||||
candidate_count: int
|
|
||||||
blocker_codes: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulabilityAuthority(Protocol):
|
|
||||||
"""Read authorities and perform the final existing claim transaction."""
|
|
||||||
|
|
||||||
def read_task(self, task_id: str) -> Mapping[str, Any]: ...
|
|
||||||
def list_candidate_tasks(self) -> Sequence[Mapping[str, Any]]: ...
|
|
||||||
def read_worker(self, worker_id: str) -> Mapping[str, Any]: ...
|
|
||||||
def read_authorization(
|
|
||||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
|
||||||
) -> Mapping[str, Any]: ...
|
|
||||||
def active_claim_count(self, worker_id: str) -> int: ...
|
|
||||||
def dependencies_satisfied(self, task: Mapping[str, Any]) -> bool: ...
|
|
||||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool: ...
|
|
||||||
def conflicting_lease(self, task_id: str) -> bool: ...
|
|
||||||
def exact_scope_digest(
|
|
||||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
|
||||||
) -> tuple[bool, str]: ...
|
|
||||||
def claim_atomically(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
worker_id: str,
|
|
||||||
actor_identity: str,
|
|
||||||
expected: ClaimConditions,
|
|
||||||
lease_seconds: int,
|
|
||||||
schedulability_proof_digest: str,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
"""Claim at the existing TaskPlane linearization point.
|
|
||||||
|
|
||||||
The implementation MUST, in the same authoritative transaction / fenced
|
|
||||||
operation that writes the claim:
|
|
||||||
- verify task revision/generation/status is still claimable;
|
|
||||||
- verify worker registration/identity/health/capacity;
|
|
||||||
- verify authorization ref/revision remains allowed;
|
|
||||||
- verify exact scope;
|
|
||||||
- verify dependencies/freeze/conflicting lease;
|
|
||||||
- create claim + lease/fencing receipt.
|
|
||||||
|
|
||||||
This protocol is an adapter contract; it is not evidence that a specific
|
|
||||||
production adapter already implements the transaction.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ProofRepository(Protocol):
|
|
||||||
def put(self, proof: SchedulabilityProof) -> None: ...
|
|
||||||
def get(self, proof_id: str) -> SchedulabilityProof | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryProofRepository:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._values: dict[str, SchedulabilityProof] = {}
|
|
||||||
|
|
||||||
def put(self, proof: SchedulabilityProof) -> None:
|
|
||||||
existing = self._values.get(proof.proof_id)
|
|
||||||
if existing is not None and existing.proof_digest != proof.proof_digest:
|
|
||||||
raise SchedulabilityError("proof id replay conflict")
|
|
||||||
self._values[proof.proof_id] = proof
|
|
||||||
|
|
||||||
def get(self, proof_id: str) -> SchedulabilityProof | None:
|
|
||||||
return self._values.get(proof_id)
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulabilityService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
authority: SchedulabilityAuthority,
|
|
||||||
proofs: ProofRepository,
|
|
||||||
*,
|
|
||||||
clock=time.time,
|
|
||||||
proof_ttl_seconds: float = 30.0,
|
|
||||||
heartbeat_grace_factor: float = 3.0,
|
|
||||||
) -> None:
|
|
||||||
self.authority = authority
|
|
||||||
self.proofs = proofs
|
|
||||||
self.clock = clock
|
|
||||||
self.proof_ttl_seconds = proof_ttl_seconds
|
|
||||||
self.heartbeat_grace_factor = heartbeat_grace_factor
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _caps(value: object) -> tuple[str, ...]:
|
|
||||||
if not isinstance(value, (list, tuple)):
|
|
||||||
return ()
|
|
||||||
return tuple(sorted({str(v) for v in value if isinstance(v, str) and v}))
|
|
||||||
|
|
||||||
def observe(
|
|
||||||
self, task_id: str, worker_id: str, actor_identity: str
|
|
||||||
) -> tuple[SchedulabilityObservation, tuple[Blocker, ...]]:
|
|
||||||
task = self.authority.read_task(task_id)
|
|
||||||
worker = self.authority.read_worker(worker_id)
|
|
||||||
auth = self.authority.read_authorization(
|
|
||||||
task=task, worker=worker, actor_identity=actor_identity
|
|
||||||
)
|
|
||||||
|
|
||||||
required = self._caps(task.get("required_capabilities") or ())
|
|
||||||
offered = self._caps(worker.get("capabilities") or ())
|
|
||||||
now = float(self.clock())
|
|
||||||
last = float(worker.get("last_heartbeat") or worker.get("last_seen_at_epoch") or 0.0)
|
|
||||||
interval = float(worker.get("heartbeat_interval_seconds") or 15.0)
|
|
||||||
fresh = (
|
|
||||||
str(worker.get("status") or "").lower() == "active"
|
|
||||||
and not bool(worker.get("disabled"))
|
|
||||||
and last <= now
|
|
||||||
and now - last <= max(interval * self.heartbeat_grace_factor, 1.0)
|
|
||||||
)
|
|
||||||
scope_ok, scope_digest = self.authority.exact_scope_digest(
|
|
||||||
task=task, worker=worker, actor_identity=actor_identity
|
|
||||||
)
|
|
||||||
deps = bool(self.authority.dependencies_satisfied(task))
|
|
||||||
freeze = bool(self.authority.freeze_blocked(task))
|
|
||||||
lease = bool(self.authority.conflicting_lease(task_id))
|
|
||||||
active_raw = self.authority.active_claim_count(worker_id)
|
|
||||||
active, active_error = _int_authority(active_raw, name="active_claim_count", minimum=0)
|
|
||||||
capacity, capacity_error = _int_authority(
|
|
||||||
worker.get("max_concurrency"),
|
|
||||||
name="max_concurrency",
|
|
||||||
default=1,
|
|
||||||
minimum=0,
|
|
||||||
)
|
|
||||||
auth_allowed = bool(auth.get("allowed"))
|
|
||||||
task_status = str(task.get("status") or "")
|
|
||||||
blockers: list[Blocker] = []
|
|
||||||
|
|
||||||
if task_status not in CLAIMABLE_TASK_STATES:
|
|
||||||
blockers.append(
|
|
||||||
Blocker(
|
|
||||||
"TASK_STATE_NOT_CLAIMABLE",
|
|
||||||
"task",
|
|
||||||
f"task status {task_status or '<missing>'} is not claimable",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if active_error:
|
|
||||||
blockers.append(active_error)
|
|
||||||
if capacity_error:
|
|
||||||
blockers.append(capacity_error)
|
|
||||||
if not fresh:
|
|
||||||
blockers.append(Blocker("STALE_OR_INACTIVE_WORKER", "worker", "worker heartbeat is not fresh"))
|
|
||||||
missing = sorted(set(required) - set(offered))
|
|
||||||
if missing:
|
|
||||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(missing)))
|
|
||||||
if not scope_ok:
|
|
||||||
blockers.append(Blocker("EXACT_SCOPE_MISMATCH", "authorization", "worker/task signed scope mismatch"))
|
|
||||||
if not auth_allowed:
|
|
||||||
blockers.append(Blocker("AUTHORIZATION_BLOCKED", "authorization", "current authorization does not allow claim"))
|
|
||||||
if capacity >= 0 and active >= capacity:
|
|
||||||
blockers.append(Blocker("CAPACITY_EXHAUSTED", "worker", f"{active}/{capacity} active"))
|
|
||||||
if not deps:
|
|
||||||
blockers.append(Blocker("DEPENDENCY_BLOCKED", "task", "task dependencies are not satisfied"))
|
|
||||||
if freeze:
|
|
||||||
blockers.append(Blocker("FREEZE_BLOCKED", "task", "task/environment freeze is active"))
|
|
||||||
if lease:
|
|
||||||
blockers.append(Blocker("CONFLICTING_LEASE", "task", "a live conflicting lease exists"))
|
|
||||||
|
|
||||||
obs = SchedulabilityObservation(
|
|
||||||
task_id=_text(task.get("task_id"), "task_id"),
|
|
||||||
task_revision=int(task.get("revision") or 0),
|
|
||||||
task_generation=int(task.get("generation") or task.get("metadata", {}).get("generation") or 0),
|
|
||||||
task_status=task_status,
|
|
||||||
worker_id=_text(worker.get("worker_id"), "worker_id"),
|
|
||||||
worker_generation=int(worker.get("registration_revision") or worker.get("generation") or 0),
|
|
||||||
worker_identity=_text(worker.get("identity"), "worker_identity", max_len=512),
|
|
||||||
heartbeat_observed_at=last,
|
|
||||||
heartbeat_fresh=fresh,
|
|
||||||
required_capabilities=required,
|
|
||||||
worker_capabilities=offered,
|
|
||||||
scope_digest=_text(scope_digest, "scope_digest", max_len=256),
|
|
||||||
authorization_ref=_text(auth.get("authorization_ref"), "authorization_ref", max_len=512),
|
|
||||||
authorization_revision=_text(auth.get("revision"), "authorization_revision", max_len=256),
|
|
||||||
authorization_allowed=auth_allowed,
|
|
||||||
capacity_limit=capacity,
|
|
||||||
active_claims=active,
|
|
||||||
dependencies_satisfied=deps,
|
|
||||||
freeze_blocked=freeze,
|
|
||||||
conflicting_lease=lease,
|
|
||||||
)
|
|
||||||
return obs, tuple(blockers)
|
|
||||||
|
|
||||||
def preflight(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
worker_id: str,
|
|
||||||
actor_identity: str,
|
|
||||||
persist: bool = False,
|
|
||||||
) -> SchedulabilityProof:
|
|
||||||
now = float(self.clock())
|
|
||||||
obs, blockers = self.observe(task_id, worker_id, actor_identity)
|
|
||||||
proof = SchedulabilityProof.create(
|
|
||||||
task_id=task_id,
|
|
||||||
worker_id=worker_id,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
observation=obs,
|
|
||||||
blockers=blockers,
|
|
||||||
now=now,
|
|
||||||
ttl_seconds=self.proof_ttl_seconds,
|
|
||||||
)
|
|
||||||
if persist:
|
|
||||||
self.proofs.put(proof)
|
|
||||||
return proof
|
|
||||||
|
|
||||||
def discover_with_diagnostics(
|
|
||||||
self, *, worker_id: str, actor_identity: str
|
|
||||||
) -> tuple[SchedulabilityProof | None, DiscoveryDiagnostics]:
|
|
||||||
candidates = sorted(
|
|
||||||
self.authority.list_candidate_tasks(),
|
|
||||||
key=lambda task: (
|
|
||||||
-int(task.get("priority") or 0),
|
|
||||||
str(task.get("created_at") or ""),
|
|
||||||
str(task.get("task_id") or ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
codes: set[str] = set()
|
|
||||||
observed = 0
|
|
||||||
for task in candidates:
|
|
||||||
task_id = str(task.get("task_id") or "")
|
|
||||||
if not task_id:
|
|
||||||
continue
|
|
||||||
observed += 1
|
|
||||||
proof = self.preflight(
|
|
||||||
task_id=task_id,
|
|
||||||
worker_id=worker_id,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
persist=False,
|
|
||||||
)
|
|
||||||
if proof.claimable:
|
|
||||||
self.proofs.put(proof)
|
|
||||||
return proof, DiscoveryDiagnostics(observed, tuple(sorted(codes)))
|
|
||||||
codes.update(b.code for b in proof.blockers)
|
|
||||||
return None, DiscoveryDiagnostics(observed, tuple(sorted(codes)))
|
|
||||||
|
|
||||||
def discover(
|
|
||||||
self, *, worker_id: str, actor_identity: str
|
|
||||||
) -> SchedulabilityProof | None:
|
|
||||||
proof, _ = self.discover_with_diagnostics(
|
|
||||||
worker_id=worker_id, actor_identity=actor_identity
|
|
||||||
)
|
|
||||||
return proof
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_claim_receipt(result: Mapping[str, Any]) -> None:
|
|
||||||
required = ("claim_id", "lease_id", "fencing_generation", "receipt_revision")
|
|
||||||
missing = [name for name in required if result.get(name) in (None, "")]
|
|
||||||
if missing:
|
|
||||||
raise SchedulabilityChanged(
|
|
||||||
"claim authority did not return required lease/fence receipt: "
|
|
||||||
+ ",".join(missing)
|
|
||||||
)
|
|
||||||
|
|
||||||
def claim_with_recheck(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
proof_id: str,
|
|
||||||
worker_id: str,
|
|
||||||
actor_identity: str,
|
|
||||||
lease_seconds: int = 30,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
proof = self.proofs.get(proof_id)
|
|
||||||
if proof is None:
|
|
||||||
raise SchedulabilityChanged("proof not found")
|
|
||||||
now = float(self.clock())
|
|
||||||
if proof.expires_at <= now:
|
|
||||||
raise SchedulabilityChanged("proof expired")
|
|
||||||
if proof.worker_id != worker_id or proof.actor_identity != actor_identity:
|
|
||||||
raise SchedulabilityChanged("proof identity mismatch")
|
|
||||||
current, blockers = self.observe(proof.task_id, worker_id, actor_identity)
|
|
||||||
if blockers:
|
|
||||||
raise SchedulabilityChanged(
|
|
||||||
"claim-time blockers: " + ",".join(sorted(b.code for b in blockers))
|
|
||||||
)
|
|
||||||
if current.stable_binding() != proof.observation.stable_binding():
|
|
||||||
raise SchedulabilityChanged("claim-time authority changed since proof")
|
|
||||||
|
|
||||||
result = self.authority.claim_atomically(
|
|
||||||
task_id=proof.task_id,
|
|
||||||
worker_id=worker_id,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
expected=current.claim_conditions(),
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
schedulability_proof_digest=proof.proof_digest,
|
|
||||||
)
|
|
||||||
self._validate_claim_receipt(result)
|
|
||||||
return result
|
|
||||||
@@ -272,13 +272,6 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self._ensure_columns(
|
|
||||||
"tasks",
|
|
||||||
{
|
|
||||||
"owner_principal_id": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self._ensure_columns(
|
self._ensure_columns(
|
||||||
"workers",
|
"workers",
|
||||||
{
|
{
|
||||||
@@ -312,167 +305,6 @@ class TaskPlaneStore:
|
|||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_events_task_seq ON events(task_id, seq)"
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_events_task_seq ON events(task_id, seq)"
|
||||||
)
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox(delivery_status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox(delivery_status)")
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_lineage_sequences (
|
|
||||||
parent_task_id TEXT NOT NULL,
|
|
||||||
relation_type TEXT NOT NULL,
|
|
||||||
child_task_id TEXT NOT NULL UNIQUE,
|
|
||||||
sibling_sequence INTEGER NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY(parent_task_id, relation_type, sibling_sequence)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_terminal_proofs (
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
claim_id TEXT NOT NULL UNIQUE,
|
|
||||||
task_revision INTEGER NOT NULL,
|
|
||||||
result_digest TEXT NOT NULL,
|
|
||||||
evidence_refs_json TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_root_reviews (
|
|
||||||
review_id TEXT PRIMARY KEY,
|
|
||||||
root_task_id TEXT NOT NULL,
|
|
||||||
trigger_child_task_id TEXT NOT NULL,
|
|
||||||
trigger_child_revision INTEGER NOT NULL,
|
|
||||||
root_revision_before INTEGER NOT NULL,
|
|
||||||
root_revision_after INTEGER NOT NULL,
|
|
||||||
record_json TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_root_action_dispatches (
|
|
||||||
review_id TEXT PRIMARY KEY,
|
|
||||||
root_task_id TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL UNIQUE,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self._ensure_columns(
|
|
||||||
"task_root_action_dispatches",
|
|
||||||
{
|
|
||||||
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"root_revision": "INTEGER NOT NULL DEFAULT 0",
|
|
||||||
"row_version": "INTEGER NOT NULL DEFAULT 0",
|
|
||||||
"plan_json": "TEXT",
|
|
||||||
"plan_digest": "TEXT",
|
|
||||||
"execution_key": "TEXT",
|
|
||||||
"claim_owner": "TEXT",
|
|
||||||
"claim_token": "TEXT",
|
|
||||||
"claim_fence": "INTEGER",
|
|
||||||
"claim_expires_at": "REAL",
|
|
||||||
"execution_receipt_json": "TEXT",
|
|
||||||
"execution_receipt_digest": "TEXT",
|
|
||||||
"rereview_json": "TEXT",
|
|
||||||
"rereview_digest": "TEXT",
|
|
||||||
"retry_at": "REAL",
|
|
||||||
"last_error_code": "TEXT",
|
|
||||||
"updated_at": "REAL",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_root_action_effects (
|
|
||||||
execution_key TEXT PRIMARY KEY,
|
|
||||||
root_task_id TEXT NOT NULL,
|
|
||||||
target_task_id TEXT,
|
|
||||||
target_job_id TEXT,
|
|
||||||
request_digest TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
effect_phase TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN',
|
|
||||||
receipt_json TEXT,
|
|
||||||
receipt_digest TEXT,
|
|
||||||
retry_at REAL,
|
|
||||||
last_error_code TEXT,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
updated_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self._ensure_columns(
|
|
||||||
"task_root_action_effects",
|
|
||||||
{
|
|
||||||
# Existing v1/v2 EFFECT_UNKNOWN rows may already have sent
|
|
||||||
# an external request. They must never migrate to a phase
|
|
||||||
# that permits a new lease or commit.
|
|
||||||
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_root_action_fences (
|
|
||||||
root_task_id TEXT PRIMARY KEY,
|
|
||||||
next_fence INTEGER NOT NULL,
|
|
||||||
active_review_id TEXT,
|
|
||||||
active_fence INTEGER,
|
|
||||||
active_phase TEXT,
|
|
||||||
updated_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_ready "
|
|
||||||
"ON task_root_action_dispatches(status,retry_at,created_at)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_root "
|
|
||||||
"ON task_root_action_dispatches(root_task_id,root_revision)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
|
||||||
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_missions (
|
|
||||||
mission_id TEXT PRIMARY KEY,
|
|
||||||
root_task_id TEXT NOT NULL UNIQUE,
|
|
||||||
authenticated_identity TEXT NOT NULL,
|
|
||||||
owner_principal_id TEXT NOT NULL,
|
|
||||||
owner_alias TEXT NOT NULL,
|
|
||||||
goal_slug TEXT NOT NULL,
|
|
||||||
root_acceptance_ref TEXT NOT NULL UNIQUE,
|
|
||||||
acceptance_verifier_id TEXT NOT NULL,
|
|
||||||
original_acceptance_json TEXT NOT NULL,
|
|
||||||
request_digest TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
UNIQUE(authenticated_identity, idempotency_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_task_missions_owner "
|
|
||||||
"ON task_missions(authenticated_identity, mission_id)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_mission_delegations (
|
|
||||||
authenticated_identity TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
mission_id TEXT NOT NULL,
|
|
||||||
parent_task_id TEXT NOT NULL,
|
|
||||||
child_task_id TEXT NOT NULL UNIQUE,
|
|
||||||
request_digest TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY(authenticated_identity, idempotency_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
|
def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
|
||||||
existing = {
|
existing = {
|
||||||
@@ -527,8 +359,6 @@ class TaskPlaneStore:
|
|||||||
root_task_id=row["root_task_id"],
|
root_task_id=row["root_task_id"],
|
||||||
idempotency_key=row["idempotency_key"],
|
idempotency_key=row["idempotency_key"],
|
||||||
failure_reason=row["failure_reason"],
|
failure_reason=row["failure_reason"],
|
||||||
owner_principal_id=row["owner_principal_id"],
|
|
||||||
mission_id=row["mission_id"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _row_to_worker(self, row: sqlite3.Row | None) -> WorkerRegistration:
|
def _row_to_worker(self, row: sqlite3.Row | None) -> WorkerRegistration:
|
||||||
@@ -555,32 +385,20 @@ class TaskPlaneStore:
|
|||||||
disabled_reason=row["disabled_reason"],
|
disabled_reason=row["disabled_reason"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_task(
|
def create_task(self, task_plan: TaskPlan, idempotency_key: str | None = None) -> TaskRecord:
|
||||||
self,
|
|
||||||
task_plan: TaskPlan,
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
*,
|
|
||||||
owner_principal_id: str = "",
|
|
||||||
mission_id: str = "",
|
|
||||||
created_at: float | None = None,
|
|
||||||
) -> TaskRecord:
|
|
||||||
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))
|
||||||
now = _now() if created_at is None else float(created_at)
|
now = _now()
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
existing = self.conn.execute(
|
existing = self.conn.execute(
|
||||||
"SELECT task_id,task_json,owner_principal_id,mission_id FROM tasks WHERE idempotency_key=?",
|
"SELECT task_id,task_json FROM tasks WHERE idempotency_key=?",
|
||||||
(idempotency_key,),
|
(idempotency_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
||||||
raise ConflictError("idempotency key payload mismatch")
|
raise ConflictError("idempotency key payload mismatch")
|
||||||
if owner_principal_id and existing["owner_principal_id"] != owner_principal_id:
|
|
||||||
raise ConflictError("idempotency key owner mismatch")
|
|
||||||
if mission_id and existing["mission_id"] != mission_id:
|
|
||||||
raise ConflictError("idempotency key mission 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
|
||||||
for dependency in task_plan.dependencies:
|
for dependency in task_plan.dependencies:
|
||||||
@@ -594,8 +412,8 @@ 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, owner_principal_id, mission_id)
|
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
task_id,
|
task_id,
|
||||||
@@ -608,8 +426,6 @@ class TaskPlaneStore:
|
|||||||
idempotency_key,
|
idempotency_key,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
owner_principal_id,
|
|
||||||
mission_id,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._set_budget(task_id, task_plan.budgets)
|
self._set_budget(task_id, task_plan.budgets)
|
||||||
@@ -621,22 +437,10 @@ class TaskPlaneStore:
|
|||||||
return self.get_task(task_id)
|
return self.get_task(task_id)
|
||||||
|
|
||||||
def create_task_with_event(
|
def create_task_with_event(
|
||||||
self,
|
self, task_plan: TaskPlan, idempotency_key: str | None = None
|
||||||
task_plan: TaskPlan,
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
*,
|
|
||||||
owner_principal_id: str = "",
|
|
||||||
mission_id: str = "",
|
|
||||||
created_at: float | None = None,
|
|
||||||
) -> TaskRecord:
|
) -> TaskRecord:
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
task = self.create_task(
|
task = self.create_task(task_plan, idempotency_key=idempotency_key)
|
||||||
task_plan,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
owner_principal_id=owner_principal_id,
|
|
||||||
mission_id=mission_id,
|
|
||||||
created_at=created_at,
|
|
||||||
)
|
|
||||||
if not self.list_events(task.task_id):
|
if not self.list_events(task.task_id):
|
||||||
self._fault("create.after_task_before_event")
|
self._fault("create.after_task_before_event")
|
||||||
self.append_event(
|
self.append_event(
|
||||||
@@ -657,26 +461,6 @@ class TaskPlaneStore:
|
|||||||
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 assert_root_open_for_child(self, root_task_id: str) -> TaskRecord:
|
|
||||||
"""Fence child admission against root completion/action finalization."""
|
|
||||||
root = self.get_task(root_task_id)
|
|
||||||
if root.task_id != root.root_task_id:
|
|
||||||
raise ConflictError("child admission authority is not a root task")
|
|
||||||
if root.status == "COMPLETED":
|
|
||||||
raise ConflictError("cannot add a child below a completed root")
|
|
||||||
fence = self.conn.execute(
|
|
||||||
"SELECT active_phase FROM task_root_action_fences WHERE root_task_id=?",
|
|
||||||
(root_task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if fence is not None and fence["active_phase"] in {
|
|
||||||
"CLAIMED",
|
|
||||||
"EFFECT_UNKNOWN",
|
|
||||||
"EXECUTED_PENDING_REREVIEW",
|
|
||||||
"ROOT_CLOSING",
|
|
||||||
}:
|
|
||||||
raise ConflictError("root action fence blocks child admission")
|
|
||||||
return root
|
|
||||||
|
|
||||||
def list_tasks(self) -> list[TaskRecord]:
|
def list_tasks(self) -> list[TaskRecord]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
@@ -1467,14 +1251,7 @@ class TaskPlaneStore:
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
def delegate_task_with_budget_and_events(
|
def delegate_task_with_budget_and_events(
|
||||||
self,
|
self, parent_task_id: str, child_plan: TaskPlan, *, caller: str
|
||||||
parent_task_id: str,
|
|
||||||
child_plan: TaskPlan,
|
|
||||||
*,
|
|
||||||
caller: str,
|
|
||||||
owner_principal_id: str = "",
|
|
||||||
mission_id: str = "",
|
|
||||||
created_at: float | None = None,
|
|
||||||
) -> TaskRecord:
|
) -> TaskRecord:
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
parent_budget = self.get_task_budget(parent_task_id)
|
parent_budget = self.get_task_budget(parent_task_id)
|
||||||
@@ -1493,12 +1270,7 @@ class TaskPlaneStore:
|
|||||||
self.consume_budget(parent_task_id, needed)
|
self.consume_budget(parent_task_id, needed)
|
||||||
self._fault("delegate.after_budget_before_child")
|
self._fault("delegate.after_budget_before_child")
|
||||||
child_plan.parent_task_id = parent_task_id
|
child_plan.parent_task_id = parent_task_id
|
||||||
child = self.create_task(
|
child = self.create_task(child_plan)
|
||||||
child_plan,
|
|
||||||
owner_principal_id=owner_principal_id,
|
|
||||||
mission_id=mission_id,
|
|
||||||
created_at=created_at,
|
|
||||||
)
|
|
||||||
self.append_event(
|
self.append_event(
|
||||||
parent_task_id,
|
parent_task_id,
|
||||||
"TASK_DELEGATED",
|
"TASK_DELEGATED",
|
||||||
|
|||||||
@@ -1,617 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Trusted evidence-provider executor (V2R2C2 P0 Round-3).
|
|
||||||
|
|
||||||
This module is the *formal trusted executor* that produces root-owned provider
|
|
||||||
receipts under each evidence-provider store. It is a real provider execution
|
|
||||||
path: for each evidence kind it executes the actual provider logic (pytest /
|
|
||||||
Gitea machine+API consistency / gitleaks), derives the result from real tool
|
|
||||||
output, and only then writes an ``aios.trusted-evidence-provider-receipt.v2``
|
|
||||||
receipt into the store. It never manufactures a bare ``{"passed": true}``:
|
|
||||||
every receipt carries a canonical content digest over a real result.
|
|
||||||
|
|
||||||
Security contract (matches TrustedProviderReceiptResolver):
|
|
||||||
- run as root (``os.geteuid() == 0``); store dirs and receipts are root-owned.
|
|
||||||
- store dir mode in ``{0700, 0750}``, receipt mode in ``{0600, 0640}``;
|
|
||||||
no group/world write; no symlink followed (O_NOFOLLOW path).
|
|
||||||
- receipt ref is relative and free of ``..``/``.``/absolute components.
|
|
||||||
- content_sha256 = canonical_sha256(result) (sort_keys, compact, utf-8).
|
|
||||||
- source_oid / tree_oid bound from the exact release source checkout.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
PROVIDER_RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
|
||||||
PROVIDER_RECEIPT_VERSION = 2
|
|
||||||
|
|
||||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
|
|
||||||
# Storage layout is resolved from the governed providers policy; these defaults
|
|
||||||
# mirror config/gitops/trusted-evidence-providers.json so the executor is usable
|
|
||||||
# before the policy is deployed, but the policy is authoritative at runtime.
|
|
||||||
POLICY_PATH = (
|
|
||||||
Path(__file__).resolve().parents[2]
|
|
||||||
/ "config"
|
|
||||||
/ "gitops"
|
|
||||||
/ "trusted-evidence-providers.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrustedProviderExecutionError(RuntimeError):
|
|
||||||
"""Fail-closed provider execution / receipt write error."""
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json_bytes(value: Any) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_sha256(value: Any) -> str:
|
|
||||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_root() -> None:
|
|
||||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
||||||
raise TrustedProviderExecutionError(
|
|
||||||
"PROVIDER_EXECUTOR_REQUIRES_ROOT (run via the governed root executor)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_oid(value: str, field: str) -> str:
|
|
||||||
v = str(value or "").strip().lower()
|
|
||||||
if not _HEX40.fullmatch(v):
|
|
||||||
raise TrustedProviderExecutionError(f"{field.upper()}_INVALID")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_relative_ref(receipt_ref: str) -> tuple[str, ...]:
|
|
||||||
ref = receipt_ref.strip()
|
|
||||||
if not ref or ref.startswith("/"):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_REF_ABSOLUTE_DENIED")
|
|
||||||
parts = ref.split("/")
|
|
||||||
if not parts or any(part in {"", ".", ".."} for part in parts):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_REF_TRAVERSAL_DENIED")
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def load_provider_policy(path: Path = POLICY_PATH) -> Mapping[str, Any]:
|
|
||||||
try:
|
|
||||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
|
||||||
if (
|
|
||||||
not isinstance(decoded, Mapping)
|
|
||||||
or decoded.get("schema") != "aios.trusted-evidence-providers.v1"
|
|
||||||
):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_INVALID")
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
def provider_store_root(kind: str, policy_path: Path = POLICY_PATH) -> Path:
|
|
||||||
policy = load_provider_policy(policy_path)
|
|
||||||
providers = policy.get("providers")
|
|
||||||
if not isinstance(providers, Mapping) or kind not in providers:
|
|
||||||
raise TrustedProviderExecutionError(f"PROVIDER_POLICY_{kind.upper()}_NOT_FOUND")
|
|
||||||
raw = providers[kind]
|
|
||||||
if not isinstance(raw, Mapping):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_INVALID")
|
|
||||||
store_root = str(raw.get("store_root") or "").strip()
|
|
||||||
if not store_root:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_ROOT_UNSET")
|
|
||||||
ec_path = os.environ.get("AIOS_EVIDENCE_PROVIDERS_ROOT")
|
|
||||||
if ec_path:
|
|
||||||
store_root = ec_path
|
|
||||||
root = Path(store_root).expanduser()
|
|
||||||
if not root.is_absolute():
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_ROOT_MUST_BE_ABSOLUTE")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
command: list[str],
|
|
||||||
*,
|
|
||||||
cwd: Path | None = None,
|
|
||||||
timeout: int = 600,
|
|
||||||
extra_env: Mapping[str, str] | None = None,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
env = dict(os.environ)
|
|
||||||
if extra_env:
|
|
||||||
env.update(extra_env)
|
|
||||||
try:
|
|
||||||
return subprocess.run(
|
|
||||||
command,
|
|
||||||
cwd=cwd,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_TOOL_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _tests_env(repo_root: Path) -> dict[str, str]:
|
|
||||||
"""Forward source PYTHONPATH to pytest subprocess spawns.
|
|
||||||
|
|
||||||
Mirrors tools/baseline_v1_1/run_acceptance.py: subprocess-based tests (e.g.
|
|
||||||
job-safety process-crash recovery) import runtime modules from
|
|
||||||
active-runtime/app, so PYTHONPATH must be propagated or they fail with
|
|
||||||
ModuleNotFoundError. See docs/implementation-baseline-1.1/ACCEPTANCE_RESULTS.md.
|
|
||||||
"""
|
|
||||||
app = repo_root / "active-runtime" / "app"
|
|
||||||
return {"PYTHONPATH": os.pathsep.join(str(p) for p in (repo_root, app))}
|
|
||||||
|
|
||||||
|
|
||||||
def _write_receipt(root: Path, receipt_ref: str, payload: Mapping[str, Any], *, mode: int = 0o600) -> str:
|
|
||||||
"""Atomically write a root-owned receipt with fail-closed store checks."""
|
|
||||||
_require_root()
|
|
||||||
root_lstat = os.lstat(root)
|
|
||||||
if stat.S_ISLNK(root_lstat.st_mode):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_SYMLINK_DENIED")
|
|
||||||
if not stat.S_ISDIR(root_lstat.st_mode):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_DIRECTORY")
|
|
||||||
if root_lstat.st_uid != 0:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_ROOT_OWNED")
|
|
||||||
if stat.S_IMODE(root_lstat.st_mode) & 0o022:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_WRITABLE_DENIED")
|
|
||||||
|
|
||||||
parts = _safe_relative_ref(receipt_ref)
|
|
||||||
current = root
|
|
||||||
for part in parts[:-1]:
|
|
||||||
current = current / part
|
|
||||||
try:
|
|
||||||
st = os.lstat(current)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# Provider executor owns the store: create missing root-owned dirs
|
|
||||||
# under the (already validated) store root, fail-closed if we cannot.
|
|
||||||
os.mkdir(current, 0o700)
|
|
||||||
os.chown(current, 0, 0)
|
|
||||||
os.chmod(current, 0o700)
|
|
||||||
st = os.lstat(current)
|
|
||||||
if stat.S_ISLNK(st.st_mode):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_SYMLINK_DENIED")
|
|
||||||
if not stat.S_ISDIR(st.st_mode):
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_DIRECTORY")
|
|
||||||
if st.st_uid != 0:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_ROOT_OWNED")
|
|
||||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_STORE_WRITABLE_DENIED")
|
|
||||||
|
|
||||||
target = current / parts[-1]
|
|
||||||
raw = canonical_json_bytes(payload)
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
||||||
if hasattr(os, "O_NOFOLLOW"):
|
|
||||||
flags |= os.O_NOFOLLOW
|
|
||||||
try:
|
|
||||||
fd = os.open(target, flags, mode)
|
|
||||||
except OSError as exc:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_WRITE_DENIED") from exc
|
|
||||||
try:
|
|
||||||
os.write(fd, raw)
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.chown(target, 0, 0)
|
|
||||||
os.chmod(target, mode)
|
|
||||||
st = os.lstat(target)
|
|
||||||
if stat.S_IMODE(st.st_mode) & 0o022 or st.st_uid != 0:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_PERMISSION_DENIED")
|
|
||||||
return hashlib.sha256(raw).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_receipt(
|
|
||||||
*,
|
|
||||||
kind: str,
|
|
||||||
provider_identity: str,
|
|
||||||
provider_operation: str,
|
|
||||||
operation_run_id: str,
|
|
||||||
result_reference: str,
|
|
||||||
result: Mapping[str, Any],
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
store_id: str,
|
|
||||||
) -> Mapping[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": PROVIDER_RECEIPT_SCHEMA,
|
|
||||||
"version": PROVIDER_RECEIPT_VERSION,
|
|
||||||
"provider_identity": provider_identity,
|
|
||||||
"provider_operation": provider_operation,
|
|
||||||
"operation_run_id": operation_run_id,
|
|
||||||
"result_reference": result_reference,
|
|
||||||
"content_sha256": canonical_sha256(result),
|
|
||||||
"source_oid": source_oid,
|
|
||||||
"tree_oid": tree_oid,
|
|
||||||
"environment": "linux-isolated",
|
|
||||||
"issued_at": now_iso(),
|
|
||||||
"result": dict(result),
|
|
||||||
"authentication": {
|
|
||||||
"kind": "root-owned-store-v1",
|
|
||||||
"store_id": store_id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Provider executors (real tool execution; results derived from real output)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _junit_totals(junit_path: Path) -> dict[str, int]:
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
if not junit_path.is_file():
|
|
||||||
raise TrustedProviderExecutionError("TESTS_JUNIT_MISSING")
|
|
||||||
root = ET.parse(junit_path).getroot()
|
|
||||||
# pytest emits <testsuites><testsuite tests=.. failures=.. />...</testsuites>;
|
|
||||||
# aggregate every <testsuite> child (never trust the outer <testsuites> attrib).
|
|
||||||
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
|
|
||||||
suites = list(root.iter("testsuite"))
|
|
||||||
for suite in suites:
|
|
||||||
n = int(suite.attrib.get("tests", 0))
|
|
||||||
totals["tests"] += n
|
|
||||||
totals["failures"] += int(suite.attrib.get("failures", 0))
|
|
||||||
totals["errors"] += int(suite.attrib.get("errors", 0))
|
|
||||||
totals["skipped"] += int(suite.attrib.get("skipped", 0))
|
|
||||||
return totals
|
|
||||||
|
|
||||||
|
|
||||||
def run_tests_provider(
|
|
||||||
*,
|
|
||||||
repo_root: Path,
|
|
||||||
collection_inventory: Path,
|
|
||||||
junit_xml: Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
store_id: str = "tests",
|
|
||||||
provider_identity: str = "aios.tests-runner",
|
|
||||||
) -> tuple[Mapping[str, Any], str]:
|
|
||||||
"""Execute the real test suite, collect-JUnit, and produce a root receipt."""
|
|
||||||
# First prove POSIX modules are collected (never trust a bare exit code).
|
|
||||||
collect = _run(
|
|
||||||
["python3", "-m", "pytest", "--collect-only", "-q", str(repo_root / "tests")],
|
|
||||||
cwd=repo_root,
|
|
||||||
timeout=900,
|
|
||||||
extra_env=_tests_env(repo_root),
|
|
||||||
)
|
|
||||||
collected_names = {
|
|
||||||
line.strip()
|
|
||||||
for line in collect.stdout.splitlines()
|
|
||||||
if line.strip()
|
|
||||||
}
|
|
||||||
posix_probed = {
|
|
||||||
"fcntl": any("fcntl" in n for n in collected_names),
|
|
||||||
"grp": any("grp" in n for n in collected_names),
|
|
||||||
"so_peercrd": any("peercred" in n.lower() for n in collected_names),
|
|
||||||
"posix_mode": any("mode" in n.lower() for n in collected_names),
|
|
||||||
"symlink": any("symlink" in n.lower() for n in collected_names),
|
|
||||||
"signer_provisioning": any("signer_provisioning" in n.lower() for n in collected_names),
|
|
||||||
}
|
|
||||||
if junit_xml.parent and not junit_xml.parent.exists():
|
|
||||||
junit_xml.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
executed = _run(
|
|
||||||
[
|
|
||||||
"python3", "-m", "pytest",
|
|
||||||
"--junitxml=" + str(junit_xml),
|
|
||||||
"-q", str(repo_root / "tests"),
|
|
||||||
],
|
|
||||||
cwd=repo_root,
|
|
||||||
timeout=3600,
|
|
||||||
extra_env=_tests_env(repo_root),
|
|
||||||
)
|
|
||||||
totals = _junit_totals(junit_xml)
|
|
||||||
collection_inventory.write_text(
|
|
||||||
json.dumps({"collected": sorted(collected_names), "posix_probed": posix_probed}, sort_keys=True),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
# Passed is only true when a real suite actually ran and was green: a
|
|
||||||
# vacuous 0-tests JUnit (unwritable junit, collection failure) must never
|
|
||||||
# report passed. pytest exit codes: 0=passed, 1=test failures, 2=interrupted,
|
|
||||||
# 3=internal error, 4=usage error, 5=no tests collected.
|
|
||||||
ran_real_tests = totals["tests"] > 0
|
|
||||||
green = totals["failures"] == 0 and totals["errors"] == 0
|
|
||||||
clean_exit = executed.returncode in (0, 5)
|
|
||||||
result = {
|
|
||||||
"passed": ran_real_tests and green and clean_exit,
|
|
||||||
"exit_code": executed.returncode,
|
|
||||||
"totals": totals,
|
|
||||||
"junit": junit_xml.name,
|
|
||||||
"collection_inventory": collection_inventory.name,
|
|
||||||
"posix_probed": posix_probed,
|
|
||||||
"run_kind": "full-test-suite",
|
|
||||||
"green": green,
|
|
||||||
"ran_real_tests": ran_real_tests,
|
|
||||||
}
|
|
||||||
return result, _receipt_ref_for(result, "tests")
|
|
||||||
|
|
||||||
|
|
||||||
def run_gitea_provider(
|
|
||||||
*,
|
|
||||||
gitea_api_url: str,
|
|
||||||
repository_identity: str,
|
|
||||||
token_file: Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
local_repo: Path,
|
|
||||||
store_id: str = "gitea",
|
|
||||||
provider_identity: str = "aios.gitea-machine-transport",
|
|
||||||
) -> tuple[Mapping[str, Any], str]:
|
|
||||||
"""Execute real Gitea machine+API consistency and produce a root receipt."""
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
token = token_file.read_text(encoding="utf-8").strip()
|
|
||||||
def _api(path: str) -> dict[str, Any]:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
gitea_api_url.rstrip("/") + path,
|
|
||||||
headers={
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Authorization": f"token {token}",
|
|
||||||
"User-Agent": "aios-trusted-gitea-provider/1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
repository = _api(f"/repos/{repository_identity}")
|
|
||||||
full_name = str(repository.get("full_name") or "")
|
|
||||||
ref = _api(f"/repos/{repository_identity}/git/refs/heads/main")
|
|
||||||
items = ref if isinstance(ref, list) else [ref]
|
|
||||||
exact = [
|
|
||||||
i for i in items
|
|
||||||
if isinstance(i, dict) and i.get("ref") in {"refs/heads/main", "heads/main"}
|
|
||||||
]
|
|
||||||
local_main = _run(
|
|
||||||
["git", "-C", str(local_repo), "rev-parse", "refs/heads/main"], cwd=local_repo
|
|
||||||
)
|
|
||||||
remote_main = str((exact[0].get("object") or {}).get("sha") or "").lower() if len(exact) == 1 else ""
|
|
||||||
result = {
|
|
||||||
"consistent": (
|
|
||||||
full_name.lower() == repository_identity.lower()
|
|
||||||
and len(exact) == 1
|
|
||||||
and remote_main == source_oid.lower()
|
|
||||||
),
|
|
||||||
"repository": full_name,
|
|
||||||
"source_commit": source_oid,
|
|
||||||
"remote_ref_oid": remote_main,
|
|
||||||
"tree_oid": tree_oid,
|
|
||||||
"local_main_oid": local_main.stdout.strip() if local_main.returncode == 0 else "",
|
|
||||||
}
|
|
||||||
return result, _receipt_ref_for(result, "gitea")
|
|
||||||
|
|
||||||
|
|
||||||
def run_secret_scan_provider(
|
|
||||||
*,
|
|
||||||
gitleaks_bin: str,
|
|
||||||
target: Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
store_id: str = "secret_scan",
|
|
||||||
provider_identity: str = "aios.secret-scanner",
|
|
||||||
) -> tuple[Mapping[str, Any], str]:
|
|
||||||
"""Execute the real secret scanner (gitleaks) and produce a root receipt."""
|
|
||||||
if not Path(gitleaks_bin).is_file():
|
|
||||||
raise TrustedProviderExecutionError("SECRET_SCANNER_UNAVAILABLE")
|
|
||||||
# --no-git scans the working tree as a filesystem (does not require a .git
|
|
||||||
# dir), so an exported release checkout can be scanned faithfully.
|
|
||||||
scanned = _run(
|
|
||||||
[
|
|
||||||
gitleaks_bin, "detect",
|
|
||||||
"--source", str(target),
|
|
||||||
"--no-git",
|
|
||||||
"--no-banner",
|
|
||||||
"--exit-code", "0",
|
|
||||||
],
|
|
||||||
cwd=target,
|
|
||||||
timeout=1800,
|
|
||||||
)
|
|
||||||
warnings = 0
|
|
||||||
# gitleaks exits 1 when findings are present; with --exit-code 0 it returns 0
|
|
||||||
# but prints findings. Count lines that indicate a finding.
|
|
||||||
finding_lines = [
|
|
||||||
ln for ln in scanned.stdout.splitlines()
|
|
||||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
|
||||||
] + [
|
|
||||||
ln for ln in scanned.stderr.splitlines()
|
|
||||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
|
||||||
]
|
|
||||||
artifact_sha = None
|
|
||||||
if target.is_file():
|
|
||||||
artifact_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
||||||
# Passed only when the scanner actually ran cleanly (exit 0) AND found zero
|
|
||||||
# leaks. A scanner crash (non-zero exit) is never a pass.
|
|
||||||
clean_run = scanned.returncode == 0
|
|
||||||
result = {
|
|
||||||
"passed": clean_run and len(finding_lines) == 0,
|
|
||||||
"scanner": "gitleaks",
|
|
||||||
"exit_code": scanned.returncode,
|
|
||||||
"target": str(target),
|
|
||||||
"findings": len(finding_lines),
|
|
||||||
"artifact_sha256": artifact_sha,
|
|
||||||
"clean_run": clean_run,
|
|
||||||
}
|
|
||||||
return result, _receipt_ref_for(result, "secret_scan")
|
|
||||||
|
|
||||||
|
|
||||||
def _receipt_ref_for(result: Mapping[str, Any], kind: str) -> str:
|
|
||||||
run_id = hashlib.sha256(canonical_json_bytes(result)).hexdigest()[:16]
|
|
||||||
return f"run-{run_id}/receipt.json"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public orchestration: execute a provider kind and write the root receipt.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Provider-kind wiring: each kind maps to a real executor and its governed
|
|
||||||
# identity / operation. The gate kind (Gate-W vs Gate-R) is read from the
|
|
||||||
# policy; a Gate-R kind is refused by the Gate-W producer boundary.
|
|
||||||
_PROVIDER_EXECUTORS: Mapping[str, Mapping[str, Any]] = {
|
|
||||||
"tests": {
|
|
||||||
"identity": "aios.tests-runner",
|
|
||||||
"operation": "tests.run",
|
|
||||||
"gate": "W",
|
|
||||||
},
|
|
||||||
"gitea": {
|
|
||||||
"identity": "aios.gitea-machine-transport",
|
|
||||||
"operation": "gitea.consistency",
|
|
||||||
"gate": "W",
|
|
||||||
},
|
|
||||||
"secret_scan": {
|
|
||||||
"identity": "aios.secret-scanner",
|
|
||||||
"operation": "secret.scan",
|
|
||||||
"gate": "W",
|
|
||||||
},
|
|
||||||
"canary": {
|
|
||||||
"identity": "aios.canary-gate",
|
|
||||||
"operation": "canary.run",
|
|
||||||
"gate": "R",
|
|
||||||
},
|
|
||||||
"rollback": {
|
|
||||||
"identity": "aios.rollback-controller",
|
|
||||||
"operation": "rollback.validate",
|
|
||||||
"gate": "R",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _operation_for(kind: str) -> str:
|
|
||||||
entry = _PROVIDER_EXECUTORS.get(kind)
|
|
||||||
if entry is None:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_KIND_UNKNOWN")
|
|
||||||
return entry["operation"]
|
|
||||||
|
|
||||||
|
|
||||||
def _run_id_for(kind: str) -> str:
|
|
||||||
return f"{kind}-{int(time.time())}"
|
|
||||||
|
|
||||||
|
|
||||||
def _run_provider_executor(
|
|
||||||
kind: str,
|
|
||||||
*,
|
|
||||||
repo_root: Path,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
) -> tuple[Mapping[str, Any], str]:
|
|
||||||
"""Run the real provider executor and return (result, receipt_ref).
|
|
||||||
|
|
||||||
Every provider derives its result from *real tool output* — never a
|
|
||||||
manufactured ``{"passed": true}`` and never an acceptance-script-invented
|
|
||||||
identity. The executor must be invoked under the governed root executor
|
|
||||||
(root-owned store); any non-executable kind fails closed.
|
|
||||||
"""
|
|
||||||
if kind == "tests":
|
|
||||||
return run_tests_provider(
|
|
||||||
repo_root=repo_root,
|
|
||||||
collection_inventory=repo_root / ".provider" / "tests-collection.json",
|
|
||||||
junit_xml=repo_root / ".provider" / "junit.xml",
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
)
|
|
||||||
if kind == "secret_scan":
|
|
||||||
return run_secret_scan_provider(
|
|
||||||
gitleaks_bin=os.environ.get("AIOS_GITLEAKS_BIN", "/usr/bin/gitleaks"),
|
|
||||||
target=repo_root,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
)
|
|
||||||
if kind == "gitea":
|
|
||||||
gitea_api_url = os.environ.get("AIOS_GITEA_API_URL", "").strip()
|
|
||||||
token_file = os.environ.get("AIOS_GITEA_TOKEN_FILE", "").strip()
|
|
||||||
repository_identity = os.environ.get("AIOS_GITEA_REPOSITORY", "").strip()
|
|
||||||
if not (gitea_api_url and token_file and repository_identity):
|
|
||||||
raise TrustedProviderExecutionError(
|
|
||||||
"PROVIDER_MECHANISM_REQUIRED (gitea: API URL / token / repository "
|
|
||||||
"identity not provisioned; no real provider path available)"
|
|
||||||
)
|
|
||||||
return run_gitea_provider(
|
|
||||||
gitea_api_url=gitea_api_url,
|
|
||||||
repository_identity=repository_identity,
|
|
||||||
token_file=Path(token_file),
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
local_repo=repo_root,
|
|
||||||
)
|
|
||||||
raise TrustedProviderExecutionError(f"PROVIDER_MECHANISM_REQUIRED (kind={kind})")
|
|
||||||
|
|
||||||
|
|
||||||
def produce(kind: str, *, source_oid: str, tree_oid: str, receipt_ref: str, store_root: Path) -> str:
|
|
||||||
"""Execute real provider kind, write its root-owned receipt, return sha256.
|
|
||||||
|
|
||||||
Refuses Gate-R kinds at the Gate-W producer boundary, and refuses unless a
|
|
||||||
real provider execution path (an executor that derives its result from real
|
|
||||||
tool output under the governed root executor) exists for the kind.
|
|
||||||
"""
|
|
||||||
entry = _PROVIDER_EXECUTORS.get(kind)
|
|
||||||
if entry is None:
|
|
||||||
raise TrustedProviderExecutionError("PROVIDER_KIND_UNKNOWN")
|
|
||||||
if entry["gate"] != "W":
|
|
||||||
raise TrustedProviderExecutionError("GATE_R_PROVIDER_REFUSED_AT_GATE_W_BOUNDARY")
|
|
||||||
|
|
||||||
# Resolve a real source checkout for the executor to bind source/tree OID.
|
|
||||||
repo_root = Path(os.environ.get("AIOS_REPO_ROOT", store_root.parent / "checkout")).resolve()
|
|
||||||
result, auto_ref = _run_provider_executor(
|
|
||||||
kind,
|
|
||||||
repo_root=repo_root,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
)
|
|
||||||
|
|
||||||
store_root.mkdir(mode=0o700, exist_ok=True)
|
|
||||||
os.chown(store_root, 0, 0)
|
|
||||||
os.chmod(store_root, 0o700)
|
|
||||||
return _write_receipt(store_root, receipt_ref, _build_receipt(
|
|
||||||
kind=kind,
|
|
||||||
provider_identity=entry["identity"],
|
|
||||||
provider_operation=_operation_for(kind),
|
|
||||||
operation_run_id=_run_id_for(kind),
|
|
||||||
result_reference=auto_ref,
|
|
||||||
result=result,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
store_id=kind,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# CLI: trusted_evidence_providers.py <kind> --source-oid=X --tree-oid=Y --store-root=Z --receipt-ref=R
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("kind", choices=("tests", "gitea", "secret_scan"))
|
|
||||||
parser.add_argument("--source-oid", required=True)
|
|
||||||
parser.add_argument("--tree-oid", required=True)
|
|
||||||
parser.add_argument("--store-root", required=True)
|
|
||||||
parser.add_argument("--receipt-ref", required=True)
|
|
||||||
parser.add_argument("--repo-root", default=None, help="Real source checkout to execute against")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.repo_root:
|
|
||||||
os.environ["AIOS_REPO_ROOT"] = args.repo_root
|
|
||||||
try:
|
|
||||||
digest = produce(
|
|
||||||
args.kind,
|
|
||||||
source_oid=_validate_oid(args.source_oid, "source_oid"),
|
|
||||||
tree_oid=_validate_oid(args.tree_oid, "tree_oid"),
|
|
||||||
receipt_ref=args.receipt_ref,
|
|
||||||
store_root=Path(args.store_root),
|
|
||||||
)
|
|
||||||
except TrustedProviderExecutionError as exc:
|
|
||||||
raise SystemExit(str(exc))
|
|
||||||
print(json.dumps({"receipt_sha256": digest}, sort_keys=True, separators=(",", ":")))
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
from typing import Any, Callable, Mapping
|
|
||||||
|
|
||||||
PROVIDER_RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
|
||||||
PROVIDER_RECEIPT_VERSION = 2
|
|
||||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderAuthenticationError(RuntimeError):
|
|
||||||
"""Fail-closed provider receipt authentication error."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TrustedProviderPolicy:
|
|
||||||
evidence_kind: str
|
|
||||||
provider_identity: str
|
|
||||||
store_id: str
|
|
||||||
store_root: Path
|
|
||||||
allowed_operations: frozenset[str]
|
|
||||||
expected_owner_uid: int = 0
|
|
||||||
allowed_directory_modes: frozenset[int] = frozenset({0o700, 0o750})
|
|
||||||
allowed_receipt_modes: frozenset[int] = frozenset({0o600, 0o640})
|
|
||||||
authentication_kind: str = "root-owned-store-v1"
|
|
||||||
hmac_key_id: str | None = None
|
|
||||||
hmac_namespace: str = "aios.trusted-evidence-provider-receipt.v2"
|
|
||||||
max_age_seconds: int | None = None
|
|
||||||
max_future_skew_seconds: int = 30
|
|
||||||
max_receipt_bytes: int = 1 << 20
|
|
||||||
require_single_link: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthenticatedProviderReceipt:
|
|
||||||
evidence_kind: str
|
|
||||||
provider_identity: str
|
|
||||||
provider_operation: str
|
|
||||||
operation_run_id: str
|
|
||||||
result_reference: str
|
|
||||||
result: Mapping[str, Any]
|
|
||||||
content_sha256: str
|
|
||||||
source_oid: str
|
|
||||||
tree_oid: str
|
|
||||||
environment: str
|
|
||||||
issued_at: str
|
|
||||||
authentication: Mapping[str, Any]
|
|
||||||
receipt_ref: str
|
|
||||||
receipt_file_sha256: str
|
|
||||||
|
|
||||||
def provenance(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema": PROVIDER_RECEIPT_SCHEMA,
|
|
||||||
"version": PROVIDER_RECEIPT_VERSION,
|
|
||||||
"provider_identity": self.provider_identity,
|
|
||||||
"provider_operation": self.provider_operation,
|
|
||||||
"operation_run_id": self.operation_run_id,
|
|
||||||
"result_reference": self.result_reference,
|
|
||||||
"content_sha256": self.content_sha256,
|
|
||||||
"source_oid": self.source_oid,
|
|
||||||
"tree_oid": self.tree_oid,
|
|
||||||
"environment": self.environment,
|
|
||||||
"issued_at": self.issued_at,
|
|
||||||
"authentication": dict(self.authentication),
|
|
||||||
"receipt_ref": self.receipt_ref,
|
|
||||||
"receipt_file_sha256": self.receipt_file_sha256,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ResolvedEvidence:
|
|
||||||
result: Mapping[str, Any]
|
|
||||||
provider_receipt: Mapping[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json_bytes(value: Any) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value,
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_sha256(value: Any) -> str:
|
|
||||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_issued_at(value: str) -> datetime:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID")
|
|
||||||
text = value.strip()
|
|
||||||
if text.endswith("Z"):
|
|
||||||
text = text[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID") from exc
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID")
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_ref(receipt_ref: str) -> tuple[str, ...]:
|
|
||||||
if not isinstance(receipt_ref, str) or not receipt_ref.strip():
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_REQUIRED")
|
|
||||||
ref = PurePosixPath(receipt_ref.strip())
|
|
||||||
if ref.is_absolute():
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_ABSOLUTE_DENIED")
|
|
||||||
parts = ref.parts
|
|
||||||
if not parts or any(part in {"", ".", ".."} for part in parts):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_TRAVERSAL_DENIED")
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_directory_stat(st: os.stat_result, policy: TrustedProviderPolicy) -> None:
|
|
||||||
if not stat.S_ISDIR(st.st_mode):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_NOT_DIRECTORY")
|
|
||||||
if st.st_uid != policy.expected_owner_uid:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_OWNER_MISMATCH")
|
|
||||||
mode = stat.S_IMODE(st.st_mode)
|
|
||||||
if mode not in policy.allowed_directory_modes:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_MODE_INVALID")
|
|
||||||
if mode & 0o022:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_WRITABLE_DENIED")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_receipt_stat(st: os.stat_result, policy: TrustedProviderPolicy) -> None:
|
|
||||||
if not stat.S_ISREG(st.st_mode):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_NOT_REGULAR")
|
|
||||||
if st.st_uid != policy.expected_owner_uid:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_OWNER_MISMATCH")
|
|
||||||
mode = stat.S_IMODE(st.st_mode)
|
|
||||||
if mode not in policy.allowed_receipt_modes:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_MODE_INVALID")
|
|
||||||
if mode & 0o022:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_WRITABLE_DENIED")
|
|
||||||
if policy.require_single_link and st.st_nlink != 1:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_LINK_COUNT_INVALID")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_nofollow(root: Path, parts: tuple[str, ...], policy: TrustedProviderPolicy) -> bytes:
|
|
||||||
root = Path(root)
|
|
||||||
try:
|
|
||||||
root_lstat = os.lstat(root)
|
|
||||||
except OSError as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_UNAVAILABLE") from exc
|
|
||||||
if stat.S_ISLNK(root_lstat.st_mode):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_SYMLINK_DENIED")
|
|
||||||
_validate_directory_stat(root_lstat, policy)
|
|
||||||
|
|
||||||
# Validate every parent component with lstat; this is portable and catches
|
|
||||||
# symlink/path-mode violations before the final no-follow open.
|
|
||||||
current = root
|
|
||||||
for part in parts[:-1]:
|
|
||||||
current = current / part
|
|
||||||
try:
|
|
||||||
st = os.lstat(current)
|
|
||||||
except OSError as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_COMPONENT_UNAVAILABLE") from exc
|
|
||||||
if stat.S_ISLNK(st.st_mode):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_SYMLINK_DENIED")
|
|
||||||
_validate_directory_stat(st, policy)
|
|
||||||
|
|
||||||
target = current / parts[-1]
|
|
||||||
try:
|
|
||||||
st = os.lstat(target)
|
|
||||||
except OSError as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_UNAVAILABLE") from exc
|
|
||||||
if stat.S_ISLNK(st.st_mode):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_SYMLINK_DENIED")
|
|
||||||
_validate_receipt_stat(st, policy)
|
|
||||||
|
|
||||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0)
|
|
||||||
if hasattr(os, "O_NOFOLLOW"):
|
|
||||||
flags |= os.O_NOFOLLOW
|
|
||||||
try:
|
|
||||||
fd = os.open(target, flags)
|
|
||||||
except OSError as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_OPEN_DENIED") from exc
|
|
||||||
try:
|
|
||||||
opened = os.fstat(fd)
|
|
||||||
_validate_receipt_stat(opened, policy)
|
|
||||||
if (opened.st_dev, opened.st_ino) != (st.st_dev, st.st_ino):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_RACE_DETECTED")
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
total = 0
|
|
||||||
while True:
|
|
||||||
chunk = os.read(fd, min(65536, policy.max_receipt_bytes + 1 - total))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
total += len(chunk)
|
|
||||||
if total > policy.max_receipt_bytes:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_TOO_LARGE")
|
|
||||||
chunks.append(chunk)
|
|
||||||
return b"".join(chunks)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _authentication_payload(receipt: Mapping[str, Any]) -> bytes:
|
|
||||||
clone = dict(receipt)
|
|
||||||
authentication = dict(clone.get("authentication") or {})
|
|
||||||
authentication.pop("mac_hex", None)
|
|
||||||
clone["authentication"] = authentication
|
|
||||||
return canonical_json_bytes(clone)
|
|
||||||
|
|
||||||
|
|
||||||
class TrustedProviderReceiptResolver:
|
|
||||||
"""Resolve and authenticate evidence-provider receipts from governed stores."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
policies: Mapping[str, TrustedProviderPolicy],
|
|
||||||
*,
|
|
||||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
|
||||||
now_fn: Callable[[], datetime] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._policies = dict(policies)
|
|
||||||
self._hmac_key_resolver = hmac_key_resolver
|
|
||||||
self._now_fn = now_fn or (lambda: datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self,
|
|
||||||
evidence_kind: str,
|
|
||||||
receipt_ref: str,
|
|
||||||
*,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
) -> AuthenticatedProviderReceipt:
|
|
||||||
policy = self._policies.get(evidence_kind)
|
|
||||||
if policy is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_POLICY_NOT_FOUND")
|
|
||||||
if not _HEX40.fullmatch(source_oid):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_SOURCE_OID_INVALID")
|
|
||||||
if not _HEX40.fullmatch(tree_oid):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_TREE_OID_INVALID")
|
|
||||||
|
|
||||||
parts = _validate_ref(receipt_ref)
|
|
||||||
raw = _read_nofollow(policy.store_root, parts, policy)
|
|
||||||
file_sha = hashlib.sha256(raw).hexdigest()
|
|
||||||
try:
|
|
||||||
receipt = json.loads(raw.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_JSON_INVALID") from exc
|
|
||||||
if not isinstance(receipt, dict):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_JSON_INVALID")
|
|
||||||
|
|
||||||
if receipt.get("schema") != PROVIDER_RECEIPT_SCHEMA:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_SCHEMA_INVALID")
|
|
||||||
if receipt.get("version") != PROVIDER_RECEIPT_VERSION:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_VERSION_INVALID")
|
|
||||||
if receipt.get("provider_identity") != policy.provider_identity:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_IDENTITY_MISMATCH")
|
|
||||||
operation = receipt.get("provider_operation")
|
|
||||||
if operation not in policy.allowed_operations:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_OPERATION_DENIED")
|
|
||||||
|
|
||||||
run_id = receipt.get("operation_run_id")
|
|
||||||
result_reference = receipt.get("result_reference")
|
|
||||||
environment = receipt.get("environment")
|
|
||||||
issued_at = receipt.get("issued_at")
|
|
||||||
if not all(isinstance(v, str) and v.strip() for v in (run_id, result_reference, environment, issued_at)):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REQUIRED_FIELD_INVALID")
|
|
||||||
|
|
||||||
if receipt.get("source_oid") != source_oid:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_SOURCE_OID_MISMATCH")
|
|
||||||
if receipt.get("tree_oid") != tree_oid:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_TREE_OID_MISMATCH")
|
|
||||||
|
|
||||||
result = receipt.get("result")
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RESULT_INVALID")
|
|
||||||
content_sha = receipt.get("content_sha256")
|
|
||||||
if not isinstance(content_sha, str) or not _HEX64.fullmatch(content_sha):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_CONTENT_SHA256_INVALID")
|
|
||||||
if canonical_sha256(result) != content_sha:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_CONTENT_SHA256_MISMATCH")
|
|
||||||
|
|
||||||
issued = _parse_issued_at(issued_at)
|
|
||||||
now = self._now_fn().astimezone(timezone.utc)
|
|
||||||
if issued > now and (issued - now).total_seconds() > policy.max_future_skew_seconds:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_FROM_FUTURE")
|
|
||||||
if policy.max_age_seconds is not None and (now - issued).total_seconds() > policy.max_age_seconds:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_EXPIRED")
|
|
||||||
|
|
||||||
authentication = receipt.get("authentication")
|
|
||||||
if not isinstance(authentication, dict):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_MISSING")
|
|
||||||
kind = authentication.get("kind")
|
|
||||||
if kind != policy.authentication_kind:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_KIND_MISMATCH")
|
|
||||||
|
|
||||||
if kind == "root-owned-store-v1":
|
|
||||||
if authentication.get("store_id") != policy.store_id:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_STORE_ID_MISMATCH")
|
|
||||||
elif kind == "hmac-sha256-v1":
|
|
||||||
key_id = authentication.get("key_id")
|
|
||||||
mac_hex = authentication.get("mac_hex")
|
|
||||||
if policy.hmac_key_id is None or key_id != policy.hmac_key_id:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_ID_MISMATCH")
|
|
||||||
if not isinstance(mac_hex, str) or not _HEX64.fullmatch(mac_hex):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_HMAC_INVALID")
|
|
||||||
if self._hmac_key_resolver is None:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_RESOLVER_REQUIRED")
|
|
||||||
key = self._hmac_key_resolver(key_id)
|
|
||||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_UNAVAILABLE")
|
|
||||||
namespace = policy.hmac_namespace.encode("utf-8")
|
|
||||||
expected = hmac.new(bytes(key), namespace + b"\0" + _authentication_payload(receipt), hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(mac_hex, expected):
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_HMAC_MISMATCH")
|
|
||||||
else:
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_KIND_UNSUPPORTED")
|
|
||||||
|
|
||||||
return AuthenticatedProviderReceipt(
|
|
||||||
evidence_kind=evidence_kind,
|
|
||||||
provider_identity=policy.provider_identity,
|
|
||||||
provider_operation=operation,
|
|
||||||
operation_run_id=run_id,
|
|
||||||
result_reference=result_reference,
|
|
||||||
result=result,
|
|
||||||
content_sha256=content_sha,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
environment=environment,
|
|
||||||
issued_at=issued_at,
|
|
||||||
authentication=dict(authentication),
|
|
||||||
receipt_ref="/".join(parts),
|
|
||||||
receipt_file_sha256=file_sha,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_evidence(
|
|
||||||
self,
|
|
||||||
evidence_kind: str,
|
|
||||||
evidence_request: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
source_oid: str,
|
|
||||||
tree_oid: str,
|
|
||||||
) -> ResolvedEvidence:
|
|
||||||
if not isinstance(evidence_request, Mapping):
|
|
||||||
raise ProviderAuthenticationError("EVIDENCE_REQUEST_INVALID")
|
|
||||||
if "receipt" in evidence_request or "provider_receipt" in evidence_request:
|
|
||||||
raise ProviderAuthenticationError("CALLER_SUPPLIED_PROVIDER_RECEIPT_DENIED")
|
|
||||||
receipt_ref = evidence_request.get("receipt_ref")
|
|
||||||
if not isinstance(receipt_ref, str) or not receipt_ref.strip():
|
|
||||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_REQUIRED")
|
|
||||||
authenticated = self.resolve(
|
|
||||||
evidence_kind,
|
|
||||||
receipt_ref,
|
|
||||||
source_oid=source_oid,
|
|
||||||
tree_oid=tree_oid,
|
|
||||||
)
|
|
||||||
# The authenticated receipt is authoritative for the evidence result.
|
|
||||||
# Request-side result fields may only match; they can never override.
|
|
||||||
request_body = {k: v for k, v in evidence_request.items() if k != "receipt_ref"}
|
|
||||||
if request_body and canonical_sha256(request_body) != authenticated.content_sha256:
|
|
||||||
raise ProviderAuthenticationError("EVIDENCE_REQUEST_CONTENT_MISMATCH")
|
|
||||||
return ResolvedEvidence(
|
|
||||||
result=dict(authenticated.result),
|
|
||||||
provider_receipt=authenticated.provenance(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_hmac_authentication(
|
|
||||||
receipt_without_mac: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
key_id: str,
|
|
||||||
key: bytes,
|
|
||||||
namespace: str = "aios.trusted-evidence-provider-receipt.v2",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not key:
|
|
||||||
raise ValueError("key is required")
|
|
||||||
draft = dict(receipt_without_mac)
|
|
||||||
auth = dict(draft.get("authentication") or {})
|
|
||||||
auth["kind"] = "hmac-sha256-v1"
|
|
||||||
auth["key_id"] = key_id
|
|
||||||
auth.pop("mac_hex", None)
|
|
||||||
draft["authentication"] = auth
|
|
||||||
mac = hmac.new(key, namespace.encode("utf-8") + b"\0" + canonical_json_bytes(draft), hashlib.sha256).hexdigest()
|
|
||||||
return {**auth, "mac_hex": mac}
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
INSERT OR IGNORE INTO node_status(node_id, health, active, score, last_seen_at, note) VALUES ('server-hw','ok',1,100,datetime('now'),'primary logical node'),('server-local','ok',0,95,datetime('now'),'backup logical node');
|
INSERT OR IGNORE INTO node_status(node_id, health, active, score, last_seen_at, note) VALUES ('server-hw','ok',1,100,datetime('now'),'primary logical node'),('server-local','ok',0,95,datetime('now'),'backup logical node');
|
||||||
INSERT OR IGNORE INTO routing_policy(role, primary_node, backup_node, active_node, failover_priority, updated_at) VALUES ('owner','server-hw','server-local','server-hw',1,datetime('now')),('admin','server-hw','server-local','server-hw',2,datetime('now')),('user','server-hw','server-local','server-hw',3,datetime('now'));
|
INSERT OR IGNORE INTO routing_policy(role, primary_node, backup_node, active_node, failover_priority, updated_at) VALUES ('owner','server-hw','server-local','server-hw',1,datetime('now')),('admin','server-hw','server-local','server-hw',2,datetime('now')),('user','server-hw','server-local','server-hw',3,datetime('now'));
|
||||||
-- SEC-P0-BOOTSTRAP-CREDENTIAL hardening: seed NO usable password. Empty hash
|
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','bootstrap-change-me',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','bootstrap-change-me',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','bootstrap-change-me',datetime('now'),datetime('now'));
|
||||||
-- means "not set / forced reset" -> owner/admin must set a strong password via
|
|
||||||
-- the bootstrap flow (scripts/patch_runtime_owner_admin_user.py) over a secure channel.
|
|
||||||
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','',datetime('now'),datetime('now'));
|
|
||||||
-- One-time cleanup: neutralize any legacy known plaintext bootstrap seed already present.
|
|
||||||
UPDATE users SET password_hash='', updated_at=datetime('now') WHERE password_hash='bootstrap-change-me';
|
|
||||||
INSERT OR IGNORE INTO model_policy(role, default_model, allowed_models, updated_at) VALUES ('owner','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b","gpt-5.5"]',datetime('now')),('admin','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b"]',datetime('now')),('user','deepseekv4-flash','["deepseekv4-flash"]',datetime('now'));
|
INSERT OR IGNORE INTO model_policy(role, default_model, allowed_models, updated_at) VALUES ('owner','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b","gpt-5.5"]',datetime('now')),('admin','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b"]',datetime('now')),('user','deepseekv4-flash','["deepseekv4-flash"]',datetime('now'));
|
||||||
INSERT OR IGNORE INTO quota_policy(username, daily_limit, used_today, updated_at) VALUES ('owner',10000,0,datetime('now')),('admin',2000,0,datetime('now')),('sample-user',200,0,datetime('now'));
|
INSERT OR IGNORE INTO quota_policy(username, daily_limit, used_today, updated_at) VALUES ('owner',10000,0,datetime('now')),('admin',2000,0,datetime('now')),('sample-user',200,0,datetime('now'));
|
||||||
|
|||||||
@@ -55,36 +55,10 @@ def seed_base_models():
|
|||||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||||
|
|
||||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
|
||||||
def token_identity(token: str):
|
|
||||||
"""Resolve the server-authoritative identity bound to a token.
|
|
||||||
|
|
||||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
|
||||||
<token> -> authenticated principal, role 'user'
|
|
||||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
|
||||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
|
||||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
|
||||||
"""
|
|
||||||
if DEV_ALLOW:
|
|
||||||
return {'username': 'dev-owner', 'role': 'owner'}
|
|
||||||
if not token or not TOKEN_FILE.exists():
|
|
||||||
return None
|
|
||||||
for line in TOKEN_FILE.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = [x.strip() for x in line.split(':')]
|
|
||||||
if not parts or parts[0] != token:
|
|
||||||
continue
|
|
||||||
if len(parts) >= 3:
|
|
||||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
|
||||||
return {'username': safe_name(parts[1]), 'role': role}
|
|
||||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def valid_token(token: str) -> bool:
|
def valid_token(token: str) -> bool:
|
||||||
if DEV_ALLOW: return True
|
if DEV_ALLOW: return True
|
||||||
return token_identity(token) is not None
|
if not token or not TOKEN_FILE.exists(): return False
|
||||||
|
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||||
def git_cmd(args, check=False):
|
def git_cmd(args, check=False):
|
||||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||||
@@ -114,10 +88,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||||
def actor(self):
|
def actor(self):
|
||||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||||
ident = token_identity(token)
|
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||||
if ident is None:
|
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
|
||||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
|
||||||
def require_write(self, roles=None):
|
def require_write(self, roles=None):
|
||||||
a = self.actor()
|
a = self.actor()
|
||||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||||
|
|||||||
+29
-14
@@ -1,8 +1,8 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# AIOS Agent Command Plane Registry
|
# AIOS Agent Command Plane Registry
|
||||||
# Git is the single source of truth for all agent commands.
|
# Git is the single source of truth for all agent commands.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
registry_version: 1.0.0
|
created_at: 2026-07-08T15:20:00+08:00
|
||||||
created_at: 2026-07-08T15:44:00+08:00
|
|
||||||
baseline: V1R3C1
|
baseline: V1R3C1
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
@@ -12,12 +12,6 @@ commands:
|
|||||||
completed_path: "agents/commands/completed/"
|
completed_path: "agents/commands/completed/"
|
||||||
failed_path: "agents/commands/failed/"
|
failed_path: "agents/commands/failed/"
|
||||||
|
|
||||||
directories:
|
|
||||||
commands/active: "New commands pending execution — Git is the only entry point"
|
|
||||||
commands/running: "Commands currently being executed by an agent"
|
|
||||||
commands/completed: "Commands that finished successfully — moved here on success"
|
|
||||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
|
||||||
|
|
||||||
policies:
|
policies:
|
||||||
path: "agents/policies/"
|
path: "agents/policies/"
|
||||||
source: "git"
|
source: "git"
|
||||||
@@ -37,12 +31,6 @@ agents:
|
|||||||
type: reviewer
|
type: reviewer
|
||||||
role: review
|
role: review
|
||||||
|
|
||||||
lifecycle:
|
|
||||||
new: active/
|
|
||||||
running: running/
|
|
||||||
success: completed/
|
|
||||||
failure: failed/
|
|
||||||
|
|
||||||
# Audit plane configuration
|
# Audit plane configuration
|
||||||
audit:
|
audit:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -50,3 +38,30 @@ audit:
|
|||||||
agent_runs: "audit/agent-runs/"
|
agent_runs: "audit/agent-runs/"
|
||||||
version_history: "audit/version-history/"
|
version_history: "audit/version-history/"
|
||||||
command_history: "audit/command-history/"
|
command_history: "audit/command-history/"
|
||||||
|
=======
|
||||||
|
# AIOS Agent Command Registry
|
||||||
|
# Git is the single source of truth for all agent commands.
|
||||||
|
|
||||||
|
registry_version: 1.0.0
|
||||||
|
created_at: "2026-07-08T15:44:00+08:00"
|
||||||
|
baseline: V1R3C1
|
||||||
|
|
||||||
|
directories:
|
||||||
|
commands/active: "New commands pending execution — Git is the only entry point"
|
||||||
|
commands/running: "Commands currently being executed by an agent"
|
||||||
|
commands/completed: "Commands that finished successfully — moved here on success"
|
||||||
|
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||||
|
|
||||||
|
state: agents/state/
|
||||||
|
policies: agents/policies/
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
new: active/
|
||||||
|
running: running/
|
||||||
|
success: completed/
|
||||||
|
failure: failed/
|
||||||
|
|
||||||
|
# Command lifecycle state machine:
|
||||||
|
# active/ → locked & moved to running/ → execute → success → completed/
|
||||||
|
# → failure → failed/
|
||||||
|
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS GitOps Model API V2R2C2 Baseline1.1 isolated candidate (2852c2a) - control-plane bootstrap only
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
Requires=aios-trusted-candidate-builder.socket
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aiman
|
|
||||||
Group=gitops
|
|
||||||
WorkingDirectory=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime
|
|
||||||
UMask=0077
|
|
||||||
Environment=PYTHONUNBUFFERED=1
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
Environment=AIOS_GITOPS_ROOT=/var/lib/aios-gitops
|
|
||||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime
|
|
||||||
Environment=AIOS_GITOPS_REPO=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
|
||||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops-runtime-anchor/baseline/current.json
|
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_WORKSPACE_ROLE=runtime-anchor
|
|
||||||
Environment=AIOS_GITOPS_RUNTIME_OID=2852c2a40394195192c6c02a215ba31764e2cdda
|
|
||||||
Environment=AIOS_GITOPS_ARTIFACT=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/governed-runtime-package.json
|
|
||||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=932431dbbdee55522342d9c94457f40d8f6f7e4fc7eaadc71c66d0a6bdf8472f
|
|
||||||
Environment=AIOS_GITOPS_DEPLOYMENT_STATE=CANDIDATE_ISOLATED
|
|
||||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c2-baseline11-candidate.service
|
|
||||||
Environment=AIOS_RUNTIME_CANDIDATE_TREE_OID=ccd67c2357e7a4cd74e06be61ab81bba820d7671
|
|
||||||
Environment=AIOS_RUNTIME_PROVENANCE_COMMIT=2852c2a40394195192c6c02a215ba31764e2cdda
|
|
||||||
Environment=AIOS_AUTHORITY_SOURCE_PUBLISHED=true
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_LEGACY_GLOBAL_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
|
||||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-v2r2c2-baseline11-candidate/silent-release
|
|
||||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
|
||||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
|
||||||
Environment=AIOS_GITOPS_PORT=18897
|
|
||||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime:/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/app:/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/runtime-agent-os
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED=true
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-builder.sock
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/config/gitops/trusted-candidate-builder-policy.json
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=%d/builder_public_keyring
|
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
|
||||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/config/gitops/trusted-release-policy.json
|
|
||||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
LoadCredential=builder_public_keyring:/etc/aios-gitops/trusted-builder/public-keyring.json
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/app/aios_model_api_staging.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
LockPersonality=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
|
||||||
ReadOnlyPaths=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime /data/aios-gitops/repo.git /etc/aios-gitops/trusted-builder/public-keyring.json /var/lib/aios-release-source-admission /var/lib/aios-release-migration
|
|
||||||
ReadWritePaths=/var/lib/aios-gitops/model-api-v2r2c2-baseline11-candidate
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
a36e9ef667f0e6be3a9e77b2082c7f2b1b2b0e020af9dcd38af8c8083c28acac aios-round3-provider-auth-c6fe1a486fa0b2fe560cbdfea646116bcac637a0.tar.gz
|
|
||||||
BIN
Binary file not shown.
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"package": "aios-round3-provider-auth",
|
|
||||||
"schema": "aios.immutable-package-ledger.v1",
|
|
||||||
"generated_at": "2026-09-09T07:15:00Z",
|
|
||||||
"platform": "linux-amd64",
|
|
||||||
"freeze": {
|
|
||||||
"PREVIOUS_FREEZE_HEAD": "a6dc0c3a63836a3668d972f632131f772626c363",
|
|
||||||
"PROVIDER_AUTH_HEAD": "f7f53d0568767d6deec2c47008a89f4d8413f93d",
|
|
||||||
"PROVIDER_EXECUTOR_HEAD": "2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d",
|
|
||||||
"P0_FINAL_HEAD": "c6fe1a486fa0b2fe560cbdfea646116bcac637a0",
|
|
||||||
"P0_FINAL_TREE": "2e36ccf170b59ac38f62c6c20a5259b3fe9a181b"
|
|
||||||
},
|
|
||||||
"receipt_binding": {
|
|
||||||
"source_oid": "2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d",
|
|
||||||
"tree_oid": "505a66a79062e1161b69d93063ffeb94ad1b95b9"
|
|
||||||
},
|
|
||||||
"gate_w": {
|
|
||||||
"produced": ["tests", "secret_scan"],
|
|
||||||
"mechanism_required": ["gitea"],
|
|
||||||
"resolver_closure": "PASS",
|
|
||||||
"tests_passed": false,
|
|
||||||
"tests_failures": 3,
|
|
||||||
"tests_note": "3 pre-existing non-round3 env failures (V2R1C6 digest, rollback script dep, missing deployed signer binary)",
|
|
||||||
"secret_scan_passed": true
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"tests": {
|
|
||||||
"provider_identity": "aios.tests-runner",
|
|
||||||
"gate": "W",
|
|
||||||
"receipt_ref": "round3-gate-w/tests.json",
|
|
||||||
"store": "/var/lib/aios-evidence-providers/tests"
|
|
||||||
},
|
|
||||||
"secret_scan": {
|
|
||||||
"provider_identity": "aios.secret-scanner",
|
|
||||||
"gate": "W",
|
|
||||||
"receipt_ref": "round3-gate-w/secret_scan.json",
|
|
||||||
"store": "/var/lib/aios-evidence-providers/secret-scan"
|
|
||||||
},
|
|
||||||
"gitea": {
|
|
||||||
"provider_identity": "aios.gitea-machine-transport",
|
|
||||||
"gate": "W",
|
|
||||||
"status": "PROVIDER_MECHANISM_REQUIRED",
|
|
||||||
"note": "API URL / token / repository identity not provisioned; fail-closed, no fake receipt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"verification": {
|
|
||||||
"bundle24_and_affected": "24 passed",
|
|
||||||
"verify_round3_integration": "ROUND3_PROVIDER_AUTH_INTEGRATION=PASS",
|
|
||||||
"no_manufactured_passed": true,
|
|
||||||
"no_acceptance_invented_identity": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.immutable-package-tariff.v1",
|
|
||||||
"package": "aios-round3-provider-auth",
|
|
||||||
"from": "a6dc0c3a63836a3668d972f632131f772626c363",
|
|
||||||
"to": "c6fe1a486fa0b2fe560cbdfea646116bcac637a0",
|
|
||||||
"summary": {
|
|
||||||
"files": 13,
|
|
||||||
"insertions": 2233,
|
|
||||||
"deletions": 27
|
|
||||||
},
|
|
||||||
"changes": [
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/governed_authenticated_evidence_operation.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/governed_provider_auth_adapter.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/trusted_evidence_providers.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/trusted_provider_receipts.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "config/gitops/trusted-evidence-providers.json",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "scripts/round3_provider_gatew_prove.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "tests/test_governed_authenticated_evidence_operation.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "tests/test_governed_provider_auth_adapter.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "tests/test_trusted_provider_receipts.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "verify_round3_integration.py",
|
|
||||||
"status": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/governed_evidence_runtime.py",
|
|
||||||
"status": "M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "active-runtime/app/governed_release_evidence_producer.py",
|
|
||||||
"status": "M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs/V2R2C2-P0-convergence-20260909.md",
|
|
||||||
"status": "M"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"activation_file":"/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json","current_components":[{"artifact_sha_env":"AIOS_GITOPS_ARTIFACT_SHA256","code_line_id":"legacy-governed-main-v15","component_id":"gitops-model-api-staging","expected_source_oid":"91f104ef62a3b688a9259b96e2ca247e0363b564","interface_files":["app/aios_model_api_staging.py","app/silent_release_hardening.py"],"marker_files":["app/aios_model_api_staging.py"],"service":"aios-gitops-model-api-v2r2c1-staging.service","source_oid_env":"AIOS_GITOPS_RUNTIME_OID"},{"artifact_sha_env":"AIOS_CONTROL_PLANE_ARTIFACT_SHA256","code_line_id":"legacy-p0-task-principal","component_id":"chatgpt-bridge-v2-staging","expected_source_oid":"91f104ef62a3b688a9259b96e2ca247e0363b564","interface_files":["app/aios_chatgpt_bridge_v2_server.py","app/aios_task_principal_admission.py"],"marker_files":["app/aios_task_principal_admission.py"],"service":"aios-chatgpt-bridge-v2-staging.service","source_oid_env":"AIOS_CONTROL_PLANE_TARGET_OID"}],"profile_id":"baseline11-principal-recovery-cross-code-line-v1","repository":"aios/aios","rollback_window_seconds":3600,"schema":"aios.release-migration-profile.v1","state_db":"/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3","state_mode":"REUSE_IN_PLACE_WITH_SNAPSHOT","state_ref":"state:task-admission","target_components":[{"code_line_id":"baseline11-release-source-admission","component_id":"gitops-model-api-staging","interface_files":["active-runtime/app/aios_model_api_staging.py","active-runtime/app/silent_release_hardening.py","active-runtime/app/aios_trusted_candidate_builder.py","active-runtime/app/release_migration_plan.py"],"service":"aios-gitops-model-api-v2r2c1-staging.service"},{"code_line_id":"baseline11-task-admission","component_id":"chatgpt-bridge-v2-staging","interface_files":["active-runtime/app/aios_chatgpt_bridge_v2_server.py","active-runtime/app/aios_task_admission_bootstrap.py","active-runtime/app/aios_task_admission_live_adapters.py"],"service":"aios-chatgpt-bridge-v2-staging.service"}],"version":"V2R2C2-baseline11-principal-recovery"}
|
|
||||||
@@ -1 +1,57 @@
|
|||||||
{"allowed_peer_cgroups":["/system.slice/aios-gitops-model-api-v2r2c1-staging.service"],"allowed_peer_executable_prefix":"/usr/bin/python3","allowed_peer_runtime_prefix":"/opt/aios/gitops-model-api/","allowed_peer_runtime_suffix":"/runtime/app/aios_model_api_staging.py","allowed_peer_uid":1000,"evidence_layout":{"artifact":"{version}/artifact.tar.gz","canary":"{version}/canary.json","gitea":"{version}/gitea.json","manifest":"{version}/manifest.json","package_closure":"{version}/checksums.sha256","policy":"{version}/trusted-release-policy.json","rollback":"{version}/rollback.json","secret_scan":"{version}/secret-scan.json","tests":"{version}/tests.json"},"governed_recipes":{},"maximum_request_age_seconds":60,"operation":"BuildTrustedCandidateV1","readiness_operation":"ProbeTrustedCandidateBuilderV1","recipes":{"V2R2C2":{"base_oid":"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6","candidate_id":"rc-v2r2c2-d2b5a8339a34","mode":"fixed","purpose":"AIOS_TRUSTED_RELEASE_BUILDER","source_commit":"d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6","task_id":"v12-durable-trusted-release-controller-activation-20260829","version":"V2R2C2"},"V2R2C3":{"base_oid":"5cf20128d8be2d10aef87b09f8f7a2aecb2000f3","candidate_id":"rc-v2r2c3-9d2843c6b04c","derives_from":"V2R2C2","mode":"governed-derived-only","purpose":"AIOS_TRUSTED_RELEASE_BUILDER","source_commit":"9d2843c6b04cefcce1bae3090eba6c19cdd89147","version":"V2R2C3"}},"repository":"aios/aios","request_schema":"aios.trusted-candidate-builder.request.v1","request_timeout_seconds":30,"required_checks":["authoritative_git","artifact","canary","gitea","manifest","package_closure","rollback","secret_scan","tests"],"response_schema":"aios.trusted-candidate-builder.response.v1","schema":"aios.trusted-candidate-builder-policy.v1"}
|
{
|
||||||
|
"allowed_peer_cgroups": [
|
||||||
|
"/system.slice/aios-gitops-model-api-v2r2c1-staging.service"
|
||||||
|
],
|
||||||
|
"allowed_peer_executable_prefix": "/usr/bin/python3",
|
||||||
|
"allowed_peer_runtime_prefix": "/opt/aios/gitops-model-api/",
|
||||||
|
"allowed_peer_runtime_suffix": "/runtime/app/aios_model_api_staging.py",
|
||||||
|
"allowed_peer_uid": 1000,
|
||||||
|
"evidence_layout": {
|
||||||
|
"artifact": "{version}/artifact.tar.gz",
|
||||||
|
"canary": "{version}/canary.json",
|
||||||
|
"gitea": "{version}/gitea.json",
|
||||||
|
"manifest": "{version}/manifest.json",
|
||||||
|
"package_closure": "{version}/checksums.sha256",
|
||||||
|
"policy": "{version}/trusted-release-policy.json",
|
||||||
|
"rollback": "{version}/rollback.json",
|
||||||
|
"secret_scan": "{version}/secret-scan.json",
|
||||||
|
"tests": "{version}/tests.json"
|
||||||
|
},
|
||||||
|
"governed_recipes": {},
|
||||||
|
"maximum_request_age_seconds": 60,
|
||||||
|
"operation": "BuildTrustedCandidateV1",
|
||||||
|
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
||||||
|
"recipes": {
|
||||||
|
"V2R2C2": {
|
||||||
|
"base_oid": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||||
|
"candidate_id": "rc-v2r2c2-d2b5a8339a34",
|
||||||
|
"mode": "fixed",
|
||||||
|
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||||
|
"source_commit": "d2b5a8339a34a8f958a177f8a3b9e8296a57a7a6",
|
||||||
|
"task_id": "v12-durable-trusted-release-controller-activation-20260829",
|
||||||
|
"version": "V2R2C2"
|
||||||
|
},
|
||||||
|
"V2R2C3": {
|
||||||
|
"derives_from": "V2R2C2",
|
||||||
|
"mode": "governed-derived-only",
|
||||||
|
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||||
|
"version": "V2R2C3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
||||||
|
"request_timeout_seconds": 30,
|
||||||
|
"required_checks": [
|
||||||
|
"authoritative_git",
|
||||||
|
"artifact",
|
||||||
|
"canary",
|
||||||
|
"gitea",
|
||||||
|
"manifest",
|
||||||
|
"package_closure",
|
||||||
|
"rollback",
|
||||||
|
"secret_scan",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
||||||
|
"schema": "aios.trusted-candidate-builder-policy.v1"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.trusted-evidence-providers.v1",
|
|
||||||
"defaults": {
|
|
||||||
"authentication_kind": "root-owned-store-v1",
|
|
||||||
"expected_owner_uid": 0,
|
|
||||||
"allowed_directory_modes": ["0700", "0750"],
|
|
||||||
"allowed_receipt_modes": ["0600", "0640"],
|
|
||||||
"max_age_seconds": 3600
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"tests": {
|
|
||||||
"provider_identity": "aios.tests-runner",
|
|
||||||
"store_id": "tests",
|
|
||||||
"store_root": "/var/lib/aios-evidence-providers/tests",
|
|
||||||
"allowed_operations": ["tests.run"],
|
|
||||||
"gate": "W"
|
|
||||||
},
|
|
||||||
"gitea": {
|
|
||||||
"provider_identity": "aios.gitea-machine-transport",
|
|
||||||
"store_id": "gitea",
|
|
||||||
"store_root": "/var/lib/aios-evidence-providers/gitea",
|
|
||||||
"allowed_operations": ["gitea.consistency"],
|
|
||||||
"gate": "W"
|
|
||||||
},
|
|
||||||
"secret_scan": {
|
|
||||||
"provider_identity": "aios.secret-scanner",
|
|
||||||
"store_id": "secret_scan",
|
|
||||||
"store_root": "/var/lib/aios-evidence-providers/secret-scan",
|
|
||||||
"allowed_operations": ["secret.scan"],
|
|
||||||
"gate": "W"
|
|
||||||
},
|
|
||||||
"canary": {
|
|
||||||
"provider_identity": "aios.canary-gate",
|
|
||||||
"store_id": "canary",
|
|
||||||
"store_root": "/var/lib/aios-evidence-providers/canary",
|
|
||||||
"allowed_operations": ["canary.run"],
|
|
||||||
"gate": "R"
|
|
||||||
},
|
|
||||||
"rollback": {
|
|
||||||
"provider_identity": "aios.rollback-controller",
|
|
||||||
"store_id": "rollback",
|
|
||||||
"store_root": "/var/lib/aios-evidence-providers/rollback",
|
|
||||||
"allowed_operations": ["rollback.validate"],
|
|
||||||
"gate": "R"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.unified-platform-program-contract.v1",
|
|
||||||
"goal_slug": "AIOS-UNIFIED-PLATFORM-CONVERGENCE",
|
|
||||||
"authority_base": "beabd053961047f170e87e1110f2636dbd50f5fb",
|
|
||||||
"existing_identity_lineage_dependency": {
|
|
||||||
"mission_id": "mission-f3af205b-7dd6-455f-a561-88f0294cc815",
|
|
||||||
"root_task_id": "task-133ad96e-e0d7-4202-934f-80c75053b11f",
|
|
||||||
"acceptance_mutation_allowed": false
|
|
||||||
},
|
|
||||||
"tracks": {
|
|
||||||
"A": "IDENTITY_LINEAGE_EXISTING_MISSION",
|
|
||||||
"B": "ANNEX_2_3_R2_AUTO_CLAIM",
|
|
||||||
"C": "PLATFORM_GENERATION_G0",
|
|
||||||
"D": "GOVERNED_MERGE_STAGING_RELEASE"
|
|
||||||
},
|
|
||||||
"program_acceptance": [
|
|
||||||
"U01",
|
|
||||||
"U02",
|
|
||||||
"U03",
|
|
||||||
"U04",
|
|
||||||
"U05",
|
|
||||||
"U06",
|
|
||||||
"U07",
|
|
||||||
"U08"
|
|
||||||
],
|
|
||||||
"deployment_requires_review_go": true,
|
|
||||||
"new_business_authority_created": false
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"bindings":[{"acceptance_key":"C01","acceptance_position":0,"lineage_request_digest":"e9946481e7c9fe8dee94aa42ffa0a9b41546ce9d46601ff221a958710c72f6cc","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":"GOVERNED_GIT","schema":"aios.autonomous-child-binding.v1","sibling_sequence":1,"task_id":"task-1668131d-7484-4c8e-83b4-2cf9927842fe","task_plan_digest":"3f0824f51d824de7d38821205c012c2a23ac11e9f5889b8b0aafb07cce85333f"},{"acceptance_key":"C02","acceptance_position":1,"lineage_request_digest":"84f28d350ef3a2dce47fb5f3163b61bfd329ce5fe85bd59c886d4813e806b602","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":"SEMANTIC_IMPORT","schema":"aios.autonomous-child-binding.v1","sibling_sequence":2,"task_id":"task-24fe3d9a-783e-4a68-bf9c-5b1a9d2b56c7","task_plan_digest":"e01680583b8cc2ec3d33026dda9f7d095692d0ae79fc43f380066511566e305e"},{"acceptance_key":"C03","acceptance_position":2,"lineage_request_digest":"148fa8b55224649ac80f4d9d5c344019dfef24b1d5ed614ef62ee71ccaf8a6d3","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":3,"task_id":"task-78e0dff5-c1fe-491a-99d2-81ad5cf180da","task_plan_digest":"aef0c0cfee2686ad7bff122328408c5094370bf29fac37a49ddc50173fa60986"},{"acceptance_key":"C04","acceptance_position":3,"lineage_request_digest":"927dde8068b9dde4d57e0f9eb6bd52d95d761533974a7437d01707ea3c5c807d","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":4,"task_id":"task-9b6e0cb1-c54a-4f91-8762-7c72112cf71d","task_plan_digest":"d1eae4e90a5892dd851fbe80c2ee0920e7060cd6ce701ae0522d110dcd9af638"},{"acceptance_key":"C05","acceptance_position":4,"lineage_request_digest":"4336de284be15d504af661c18a98c820da3c3518ef5df25d4607c19c3ba724b2","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":5,"task_id":"task-bd8c44c5-7081-4a3f-a3b0-4de4ad6e3b29","task_plan_digest":"dd7e02ef51f601210295c9bda1061dcd7ff944bc7a5dcb2e813db2d601b138a4"},{"acceptance_key":"C06","acceptance_position":5,"lineage_request_digest":"54bdc5cf4414bc52105e966072009c9b0ce8235392f767b38e856072dd01148c","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":6,"task_id":"task-591a87fa-e722-4647-a71d-80624b7e6b6a","task_plan_digest":"9b0d092f6a0a987844edae129eaedfc6bed87c135d8d3db390b0f01b43f896c8"}],"policy_version":"identity-lineage-autonomous-next-child-v1","schema":"aios.autonomous-child-binding-set.v1"}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# G7 — Security-Critical Closure 证据与判定
|
|
||||||
|
|
||||||
- **Gate**: G7_SECURITY_CRITICAL_CLOSURE
|
|
||||||
- **判定**: `G7_CHANGES_REQUIRED`(修复已就绪,等待上游治理合入 + staging_readback + ROOT_REVIEW 后升为 APPROVED_FOR_GOVERNED_SUBMISSION)
|
|
||||||
- **Authority HEAD (base OID)**: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
|
||||||
- **工作分支**: `agent/g7-security-critical-closure-20260914`(基于 origin/main;本地修复分支,待 PR + Owner 合入 main,不绕过保护 main)
|
|
||||||
- **日期**: 2026-09-14
|
|
||||||
- **扫描源**: `aios-scan-full.json` (1.08MB, 1039 findings, 高召回含误报)
|
|
||||||
- **规模**: 729 文件 / 24 个指派文件修改 / 5 个新增负面测试套件
|
|
||||||
|
|
||||||
## 摘要
|
|
||||||
|
|
||||||
全仓 review 为高召回设计并含误报,故未机械修复全部 1039 条发现。本次仅闭环被
|
|
||||||
权威源码复核为 CONFIRMED 且直接影响控制面授权、凭证安全、任意文件访问、特权 UI
|
|
||||||
的 **P0 阻塞项(6 项)+ P1 交付正确性包(若干)**。DEAD_PATH/FALSE_POSITIVE 项已
|
|
||||||
按契约排除,并记录依据。
|
|
||||||
|
|
||||||
## 修复分账(S1–S5)
|
|
||||||
|
|
||||||
| 阻塞项 | 角色 | 分类 | 文件 | pre→post SHA | 修复要点 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | adapters/gitops/aios_gitops_model_api.py | 60396e0→8990317 | role/user 由服务端 token→identity 映射派生,忽略 X-AIOS-User/Role 头 |
|
|
||||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | active-runtime/app/aios_gitops_model_api.py | ac8c1bf→80b04bd | 同上 |
|
|
||||||
| SEC-P0-TASK-ACTION-ROLE | S1 | CONFIRMED | active-runtime/app/task_action_api.py | b18e0a5→31c6f6e | 忽略 body role,role 由注入认证主体派生,默认 user |
|
|
||||||
| SEC-P0-OBSERVABILITY-ROLE | S1 | CONFIRMED | active-runtime/app/agent_observability_api.py | f516ef9→6b065fb | 认证 tenant 决定查询范围,客户端 role=owner 被忽略 |
|
|
||||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/migrations/002_seed_owner_admin_user.sql | 7fff9e5→4cb5a33 | 种子空哈希(强制重置),清除 bootstrap-change-me |
|
|
||||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_webops_app.py | 83387a8→7001142 | PBKDF2 随机盐;SECRET 缺失 fail-closed;拒绝明文回退;会话 HMAC+过期 |
|
|
||||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_v5_webops_app.py | 83387a8→7001142 | 同上 |
|
|
||||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_chatgpt_actions_api.py | 47ae2c8→4f345a0 | 命令读取:认证+严格标识+包含校验+1MiB 上限 |
|
|
||||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 同上 |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | services/aios-event-ingress/server.py | c790d41→c4e93cb | ID 白名单+resolve 包含校验 |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/agent_observability_store.py | 40cc555→4746b3a | execution_id 白名单+包含校验 |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/aios_campaign_recovery.py | 0d09fd2→06e208f | 证据路径 resolve+realpath 包含校验 |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/gitops/task_worktree.sh | 72d33f4→19ea429 | TASK_ID 正则收紧,拒绝 .. ../ ..\ |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/init_gitops_baseline.sh | 472e60c→48f584e | zip-slip 解包前条目校验 |
|
|
||||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | ops/gitops-migration/sync_gitea_owner_password.sh | 11573c9→8e85831 | 消除 env 来源注入,argv+引号 heredoc |
|
|
||||||
| SEC-P0-ADMIN-UI-XSS | S4 | CONFIRMED | active-runtime/app/static/admin-ai-evolution.html | 6da3ce8→05d26ab | textContent 全面转义;修复 JS 语法;用真实 governed 审批端点替代本地 fake Approve |
|
|
||||||
| SEC-P1-WEB-CONTROL-SURFACE | S4 | CONFIRMED | overlays/.../gitops-dashboard/index.html | 7f459a1→484447b | 客户端 CSRF token 传播;状态变更带 token;branch 服务端校验 |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | agents/registry.yaml | 7b69d96→0037e73 | 解决阻塞性 merge conflict,恢复可解析 YAML |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 消除 do_POST 遮蔽(合并 bridge 路由) |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | docs/gitops/openapi-chatgpt-actions.yaml | 5278b91→9f339c0 | 补全 securitySchemes.taskPrincipalAuth |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | deploy/install/install.sh | ab1f433→b90b759 | 移除 `\|\| true`,启动失败传播 |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | scripts/silent_install_v5.sh | 59e9552→e26a3b3 | 缺失脚本显式报错 |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | delta-fix/apply_delta_fix_only.sh | de1e2fd→d09d2ab | 修复未定义变量 |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | active-runtime/app/architecture_owner_gate_http.py | 未改 | 全库零引用,非活路径 |
|
|
||||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | runtime/agent-os/api.py | 未改 | 全库零引用独立模块,facade 签名不符但无活路径 |
|
|
||||||
|
|
||||||
## 负面测试(S6 独立 QA 验证)
|
|
||||||
|
|
||||||
新增 5 个测试套件,均针对**真实已修复模块**(经 tests/conftest.py 的 active_runtime 命名空间映射到实际文件):
|
|
||||||
|
|
||||||
- `tests/test_g7_auth_negative.py` — 9 passed
|
|
||||||
- `tests/test_g7_credential_negative.py` — 4 passed
|
|
||||||
- `tests/test_g7_path_containment_negative.py` — 7 passed(+8 bash 用例如下)
|
|
||||||
- `tests/test_g7_runtime_delivery.py` — 4 passed
|
|
||||||
- `tests/test_g7_web_surface_negative.py` — 42 passed
|
|
||||||
|
|
||||||
**合计 66 passed / 0 failed**。8 个 bash 子进程用例如 Windows 沙箱 pytest 无法拉起 bash 而 skip,
|
|
||||||
已在本机 Bash 环境实测真实脚本:遍历 TASK_ID → rc=65 拒绝;zip-slip `../` 条目 → BLOCKED 退出 11。
|
|
||||||
|
|
||||||
### 覆盖的负面场景
|
|
||||||
- spoof `X-AIOS-Role: owner` / `X-AIOS-User` → 不能提权
|
|
||||||
- JSON `role=owner` → 被忽略
|
|
||||||
- observability `role=owner` + 外来 user/group → 限定自身
|
|
||||||
- 非 owner token 下 `require_write(['owner'])` → 403
|
|
||||||
- 已知 bootstrap 口令拒绝登录;哈希非明文且随机盐;缺 SECRET 拒绝启动;过期令牌失效
|
|
||||||
- POSIX `../` / Windows `..\` / 编码遍历 / 超大读取 / zip-slip / 绝对路径拒绝
|
|
||||||
- HTML `<img onerror>` / script 载荷保持文本;CSRF-less 状态变更被拒;无效 branch 被拒
|
|
||||||
- registry.yaml / openapi 可解析且引用完整;install.sh 失败传播;脚本无未定义变量
|
|
||||||
|
|
||||||
### 回归
|
|
||||||
- 全部 `active-runtime/app` + `adapters` 211 个 py 文件 `py_compile` 通过(无导入/Syntax 回归)
|
|
||||||
- 全部 15 个改动/新增 py 文件编译通过
|
|
||||||
|
|
||||||
## 证据字段映射(G7_SECURITY_GATE.md Required evidence)
|
|
||||||
|
|
||||||
对每个阻塞项均已具备:finding_id / authority_oid(=beabd05…) / affected_path / live_path=true /
|
|
||||||
preimage_sha256(上表)、fix_commit_oid(见下方提交)、postimage_sha256(上表)、
|
|
||||||
negative_tests(通过)、positive_tests(编译+回归)、independent_review(S6 独立运行通过)、
|
|
||||||
runtime/staging_readback、rollback(见 S2 迁移/回滚路径)。
|
|
||||||
|
|
||||||
## 判定与下一步
|
|
||||||
|
|
||||||
- **当前判定**: `G7_CHANGES_REQUIRED` → 修复就绪,待合入流程推进。
|
|
||||||
- **下一步**(需 Owner/治理,非本 Agent 越权执行):
|
|
||||||
1. 审查并合入 `agent/g7-security-critical-closure-20260914`(走 protected main PR + Owner approval)
|
|
||||||
2. staging 部署后作 runtime/staging_readback(真实服务启动验证,尤其 S2 的 AIOS_WEBOPS_SECRET 注入与 S1 的 token 映射文件格式)
|
|
||||||
3. 执行 S2 owner 引导设密流程(scripts/patch_runtime_owner_admin_user.py)设置真实 owner 强口令
|
|
||||||
4. 独立 final review → governed submission → staging → live E2E → production → ROOT_REVIEW
|
|
||||||
- **已知未闭环**(诚实标注): S1 的 task_action/observability 认证主体需宿主 FastAPI 应用注入
|
|
||||||
`set_authenticator()`,当前默认按最低权限 user(安全降级但 owner 能力需接线);staging_readback
|
|
||||||
与 rollback 实机验证需在部署环境执行,本环境未具备。
|
|
||||||
|
|
||||||
## 备注(Triage 纪律)
|
|
||||||
- 未机械处理 1000+ 条 medium/low/维护性/样式/历史死代码注释(契约明确非当前发布阻塞项)。
|
|
||||||
- DEAD_PATH 项未改动并给出全库引用证据。
|
|
||||||
@@ -15,5 +15,5 @@ bash "$PKG_ROOT/scripts/smoke_test.sh" "$AIOS_RUNTIME_ROOT"
|
|||||||
python3 "$PKG_ROOT/scripts/generate_release_manifest.py" "$AIOS_RUNTIME_ROOT"
|
python3 "$PKG_ROOT/scripts/generate_release_manifest.py" "$AIOS_RUNTIME_ROOT"
|
||||||
echo "DELTA_FIX_ONLY_APPLY_OK release=$AIOS_RELEASE_ID runtime=$AIOS_RUNTIME_ROOT"
|
echo "DELTA_FIX_ONLY_APPLY_OK release=$AIOS_RELEASE_ID runtime=$AIOS_RUNTIME_ROOT"
|
||||||
|
|
||||||
python3 "$PKG_ROOT/fixes/patch_runtime_v5_guards.py" "$AIOS_RUNTIME_ROOT"
|
python3 "$PKG/fixes/patch_runtime_v5_guards.py" "$TARGET"
|
||||||
echo "V5_DIGITAL_LIFE_DELTA_APPLIED"
|
echo "V5_DIGITAL_LIFE_DELTA_APPLIED"
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
|||||||
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable aios-autonomous-daemon.service
|
systemctl enable aios-autonomous-daemon.service || true
|
||||||
systemctl enable aios-v1r1-wechat-worker.service
|
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||||
|
|
||||||
systemctl restart aios-autonomous-daemon.service
|
systemctl restart aios-autonomous-daemon.service || true
|
||||||
systemctl restart aios-v1r1-wechat-worker.service
|
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
systemctl status aios-autonomous-daemon.service --no-pager || true
|
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-bu
|
|||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
||||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
||||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
|
||||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
||||||
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
||||||
LoadCredential=source_admission_hmac:/etc/aios-gitops/credential-references/release-source-admission-hmac
|
|
||||||
# The credential source above is a reference only; no secret value belongs here.
|
# The credential source above is a reference only; no secret value belongs here.
|
||||||
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
|||||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
|
||||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||||
Environment=AIOS_GITOPS_PORT=18893
|
Environment=AIOS_GITOPS_PORT=18893
|
||||||
@@ -45,7 +44,7 @@ ProtectControlGroups=true
|
|||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
ReadOnlyPaths=@RUNTIME_ROOT@ /data/aios-gitops/repo.git /var/lib/aios-gitops-runtime-anchor/baseline -/var/lib/aios-release-source-admission
|
ReadOnlyPaths=@RUNTIME_ROOT@ /data/aios-gitops/repo.git /var/lib/aios-gitops-runtime-anchor/baseline
|
||||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS governed trusted-builder evidence materializer
|
|
||||||
After=aios-release-source-admission.service aios-release-migration-planner.service
|
|
||||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
|
||||||
ConditionPathExists=/var/lib/aios-release-migration
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
Group=gitops
|
|
||||||
UMask=0027
|
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_RELEASE_EVIDENCE_SOURCE_ROOT=/var/lib/aios-release-evidence-staging
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
|
||||||
EnvironmentFile=-/run/aios-release-evidence-materializer/request.env
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-evidence-materializer/current/app/release_evidence_materializer.py materialize --version ${AIOS_RELEASE_VERSION} --candidate-id ${AIOS_RELEASE_CANDIDATE_ID} --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --builder-policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --authority-git-dir /data/aios-gitops/repo.git --source-root ${AIOS_RELEASE_EVIDENCE_SOURCE_ROOT} --output-root ${AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT} --source-admission-root /var/lib/aios-release-source-admission --migration-plan-root /var/lib/aios-release-migration --migration-profile-root /etc/aios-gitops/release-migration-profiles
|
|
||||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-evidence-materializer/request.env
|
|
||||||
RuntimeDirectory=aios-release-evidence-materializer
|
|
||||||
RuntimeDirectoryMode=0750
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateNetwork=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
SystemCallArchitectures=native
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/release-evidence-materializer /opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-release-evidence-staging /var/lib/aios-release-source-admission /var/lib/aios-release-migration /etc/aios-gitops/release-migration-profiles /etc/credstore.encrypted
|
|
||||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder/evidence /run/aios-release-evidence-materializer
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS governed cross-code-line release migration planner
|
|
||||||
After=aios-release-source-admission.service
|
|
||||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
Group=gitops
|
|
||||||
UMask=0027
|
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_RELEASE_MIGRATION_PROFILE=/etc/aios-gitops/release-migration-profiles/V2R2C2-baseline11-principal-recovery.json
|
|
||||||
EnvironmentFile=-/run/aios-release-migration-planner/request.env
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-migration/current/app/release_migration_plan.py --authority-git-dir /data/aios-gitops/repo.git --profile ${AIOS_RELEASE_MIGRATION_PROFILE} --output-root /var/lib/aios-release-migration --source-admission-root /var/lib/aios-release-source-admission --builder-policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --candidate-id ${AIOS_RELEASE_CANDIDATE_ID}
|
|
||||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-migration-planner/request.env
|
|
||||||
StateDirectory=aios-release-migration
|
|
||||||
StateDirectoryMode=0750
|
|
||||||
RuntimeDirectory=aios-release-migration-planner
|
|
||||||
RuntimeDirectoryMode=0750
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateNetwork=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
SystemCallArchitectures=native
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/release-migration /data/aios-gitops/repo.git /var/lib/aios-release-source-admission /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json /etc/aios-gitops/release-migration-profiles /var/lib/aios-gitops/bridge-v2-staging
|
|
||||||
ReadWritePaths=/var/lib/aios-release-migration /run/aios-release-migration-planner
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS governed Source Admission isolated-peer policy rebind
|
|
||||||
After=aios-release-source-admission.service
|
|
||||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
Group=gitops
|
|
||||||
UMask=0027
|
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
|
||||||
Environment=AIOS_RELEASE_ISOLATED_PEER_CGROUP=/system.slice/aios-gitops-model-api-v2r2c2-baseline11-candidate.service
|
|
||||||
EnvironmentFile=-/run/aios-release-source-admission-rebind/request.env
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-source-admission/current/app/release_source_admission.py rebind-policy --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --previous-policy ${AIOS_RELEASE_PREVIOUS_POLICY} --policy ${AIOS_RELEASE_POLICY} --isolated-peer-cgroup ${AIOS_RELEASE_ISOLATED_PEER_CGROUP} --expected-admission-id ${AIOS_RELEASE_EXPECTED_ADMISSION_ID} --reason ${AIOS_RELEASE_REBIND_REASON} --store-root /var/lib/aios-release-source-admission
|
|
||||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-source-admission-rebind/request.env
|
|
||||||
RuntimeDirectory=aios-release-source-admission-rebind
|
|
||||||
RuntimeDirectoryMode=0750
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateNetwork=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectKernelLogs=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
SystemCallArchitectures=native
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/release-source-admission /etc/credstore.encrypted
|
|
||||||
ReadWritePaths=/var/lib/aios-release-source-admission /run/aios-release-source-admission-rebind
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS governed release source admission
|
|
||||||
After=network-online.target aios-gitea.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
Group=gitops
|
|
||||||
UMask=0027
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
|
||||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_GITEA_REMOTE_URL=https://g.cnnetmkt.top/git/aios/aios.git
|
|
||||||
Environment=AIOS_GITEA_MACHINE_CREDENTIAL_NAME=gitea-machine-token
|
|
||||||
EnvironmentFile=/run/aios-release-source-admission/request.env
|
|
||||||
LoadCredentialEncrypted=gitea-machine-token:/etc/aios/credentials/gitea-machine.token.cred
|
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-source-admission/current/app/release_source_admission.py admit --version ${AIOS_RELEASE_VERSION} --task-id ${AIOS_RELEASE_TASK_ID} --source-ref ${AIOS_RELEASE_SOURCE_REF} --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --store-root /var/lib/aios-release-source-admission
|
|
||||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-source-admission/request.env
|
|
||||||
RuntimeDirectory=aios-release-source-admission
|
|
||||||
RuntimeDirectoryMode=0750
|
|
||||||
StateDirectory=aios-release-source-admission
|
|
||||||
StateDirectoryMode=0750
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
PrivateDevices=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
LockPersonality=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
RestrictRealtime=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
||||||
CapabilityBoundingSet=
|
|
||||||
ReadOnlyPaths=/opt/aios/release-source-admission /opt/aios/trusted-candidate-builder /etc/aios/credentials /etc/credstore.encrypted
|
|
||||||
ReadWritePaths=/data/aios-gitops/repo.git /var/lib/aios-release-source-admission /run/aios-release-source-admission
|
|
||||||
@@ -10,14 +10,10 @@ Group=root
|
|||||||
UMask=0077
|
UMask=0077
|
||||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
||||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
||||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
||||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
||||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
||||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT=/var/lib/aios-release-migration
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT=/etc/aios-gitops/release-migration-profiles
|
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
||||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
@@ -40,9 +36,8 @@ LockPersonality=true
|
|||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
AmbientCapabilities=CAP_SYS_PTRACE
|
CapabilityBoundingSet=
|
||||||
CapabilityBoundingSet=CAP_SYS_PTRACE
|
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
||||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence -/var/lib/aios-release-source-admission -/var/lib/aios-release-migration -/etc/aios-gitops/release-migration-profiles
|
|
||||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -34,11 +34,6 @@
|
|||||||
"source_mode": "0644",
|
"source_mode": "0644",
|
||||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.socket"
|
"source_path": "deploy/systemd/aios-trusted-candidate-builder.socket"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"install_mode": "0644",
|
|
||||||
"source_mode": "0644",
|
|
||||||
"source_path": "docs/security/trusted-builder-captrace.md"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"install_mode": "0644",
|
"install_mode": "0644",
|
||||||
"source_mode": "0644",
|
"source_mode": "0644",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# ADR: R2 evidence digests reuse TaskPlane canonical JSON
|
|
||||||
|
|
||||||
Status: ACCEPTED FOR R2 REVIEW CANDIDATE
|
|
||||||
Date: 2026-09-14
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
R1 introduced local sorted/separator JSON encoders in schedulability proof and
|
|
||||||
Retry/Toil fingerprint code. Platform Baseline V4 §1.3/§4.4/§12 prohibits new
|
|
||||||
parallel canonical encoders unless a scoped ADR proves the existing contract is
|
|
||||||
not applicable.
|
|
||||||
|
|
||||||
The candidate already carries the established TaskPlane contract:
|
|
||||||
|
|
||||||
`app.task_plane.models.canonical_json(value)`
|
|
||||||
|
|
||||||
which serializes UTF-8 JSON deterministically with sorted keys and compact
|
|
||||||
separators.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
R2 reuses `task_plane.models.canonical_json` for:
|
|
||||||
|
|
||||||
1. schedulability observation/proof digests;
|
|
||||||
2. Retry/Toil operation fingerprint payloads.
|
|
||||||
|
|
||||||
Retry/Toil adds a domain schema wrapper
|
|
||||||
`aios.retry-toil-fingerprint.v1` before hashing so the digest domain is
|
|
||||||
separated without inventing a new byte encoder.
|
|
||||||
|
|
||||||
No generic canonical platform, second encoder, or new write authority is added.
|
|
||||||
|
|
||||||
## Bytes and algorithm
|
|
||||||
|
|
||||||
- canonical text producer: `task_plane.models.canonical_json`
|
|
||||||
- bytes: UTF-8 encoding of that exact text
|
|
||||||
- digest: SHA-256 hex
|
|
||||||
- schedulability schema: `aios.schedulability-proof.v2`
|
|
||||||
- retry fingerprint schema: `aios.retry-toil-fingerprint.v1`
|
|
||||||
|
|
||||||
## Migration / compatibility
|
|
||||||
|
|
||||||
These R2 modules are not yet live authorities. Their R1 digests are review
|
|
||||||
candidate evidence only and do not require backwards-compatible production
|
|
||||||
decoding. Any future persisted proof/fingerprint consumer must bind schema
|
|
||||||
version explicitly.
|
|
||||||
|
|
||||||
## Guard
|
|
||||||
|
|
||||||
R2 tests reject a reintroduction of local `json.dumps(sort_keys=True,
|
|
||||||
separators=...)` encoders in the two modules.
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
# AIOS Unified Platform Convergence — Architecture Change V2
|
|
||||||
|
|
||||||
Status: R2 REVIEW CANDIDATE
|
|
||||||
Date: 2026-09-14
|
|
||||||
Authority base for rebind: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
R2 preserves the R1 architectural direction but corrects the eight review
|
|
||||||
findings F01–F08. It does not authorize deployment.
|
|
||||||
|
|
||||||
The program still has four tracks:
|
|
||||||
|
|
||||||
1. existing Identity / Lineage S3;
|
|
||||||
2. ANNEX-2.3 R2 Auto-Claim / Execution Integrity;
|
|
||||||
3. Platform Generation G0 (WP0a–e/WP1/WP2 only);
|
|
||||||
4. governed merge, staging, release and root/program acceptance.
|
|
||||||
|
|
||||||
Existing Identity/Lineage mission/root/children remain authoritative and are
|
|
||||||
not recreated or redefined by this program.
|
|
||||||
|
|
||||||
## 2. Authority split
|
|
||||||
|
|
||||||
- Git authority main: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
|
||||||
- release-control runtime: `3c320aeb89d9ba8f0ed516abeb57252682b18983`
|
|
||||||
- production V2R2C2: `4642b74af62c89a505ae49241e805a5a88ca897f`
|
|
||||||
|
|
||||||
A review candidate is not a governed commit. All target preimages must be
|
|
||||||
refreshed from live Git authority before submission.
|
|
||||||
|
|
||||||
## 3. R2 safety corrections
|
|
||||||
|
|
||||||
### 3.1 Caller evidence is fail-closed
|
|
||||||
|
|
||||||
`caller_scan v2` separates:
|
|
||||||
|
|
||||||
- static source caller evidence;
|
|
||||||
- active runtime caller evidence;
|
|
||||||
- test-only evidence;
|
|
||||||
- opaque/dynamic uncertainty;
|
|
||||||
- complete negative evidence.
|
|
||||||
|
|
||||||
Static source imports alone do not prove runtime LIVE.
|
|
||||||
|
|
||||||
`PROVEN_ORPHAN` requires all of:
|
|
||||||
- zero production static caller;
|
|
||||||
- zero active systemd/API/scheduler/runtime-loaded caller;
|
|
||||||
- relevant runtime host set non-empty and completely scanned;
|
|
||||||
- loaded bytes negative proof;
|
|
||||||
- systemd/API/scheduler scan coverage;
|
|
||||||
- complete observation window;
|
|
||||||
- no probe failures/opaque dynamic references;
|
|
||||||
- rollback reference;
|
|
||||||
- independent acceptance `GO`.
|
|
||||||
|
|
||||||
Incomplete negative evidence returns `LIKELY_ORPHAN`; probe failure, opaque
|
|
||||||
dynamic import, or unresolved production source caller returns `UNKNOWN`.
|
|
||||||
UNKNOWN can never drive Sunset.
|
|
||||||
|
|
||||||
### 3.2 Schedulability proof is diagnostic, final claim is authoritative
|
|
||||||
|
|
||||||
R2 checks task lifecycle state and treats explicit `max_concurrency=0` as zero.
|
|
||||||
Invalid capacity values are structured blockers.
|
|
||||||
|
|
||||||
The proof remains non-bearer. Claim has three stages:
|
|
||||||
|
|
||||||
1. preflight observation;
|
|
||||||
2. claim-time read-only recheck;
|
|
||||||
3. existing TaskPlane authority performs `claim_atomically` at the
|
|
||||||
linearization point.
|
|
||||||
|
|
||||||
The atomic adapter contract must revalidate task revision/generation/status,
|
|
||||||
worker generation/identity/health/capacity, authorization ref/revision,
|
|
||||||
scope, dependencies, freeze and conflicting lease inside the same
|
|
||||||
authoritative transaction/fenced operation that creates the claim.
|
|
||||||
|
|
||||||
The returned receipt must include claim id, lease id, fencing generation and
|
|
||||||
receipt revision.
|
|
||||||
|
|
||||||
R2 reference tests verify the adapter contract only. They do **not** prove a
|
|
||||||
production adapter is atomic; real staging A18–A23 remain mandatory.
|
|
||||||
|
|
||||||
### 3.3 Retry Governor is whitelist-only
|
|
||||||
|
|
||||||
- UNKNOWN/indeterminate → RECONCILE;
|
|
||||||
- governance/dependency boundary → BLOCK;
|
|
||||||
- proven success → SUPPRESS/REUSE;
|
|
||||||
- unrecognized state → BLOCK;
|
|
||||||
- only explicitly executable states may return EXECUTE.
|
|
||||||
|
|
||||||
A prior success for the same operation conflicting with a later NOT_APPLIED
|
|
||||||
observation must reconcile rather than re-execute.
|
|
||||||
|
|
||||||
### 3.4 Canonical bytes are reused
|
|
||||||
|
|
||||||
Schedulability and Retry/Toil reuse
|
|
||||||
`app.task_plane.models.canonical_json`; no second JSON canonical encoder is
|
|
||||||
introduced. See `ADR_CANONICAL_EVIDENCE_DIGEST_V1.md`.
|
|
||||||
|
|
||||||
### 3.5 Platform G0 discovery is not G0 acceptance
|
|
||||||
|
|
||||||
WP0a–d and WP1 now expose completeness/missing evidence. Lexical/static
|
|
||||||
discovery cannot by itself satisfy Generation-G0 Exit.
|
|
||||||
|
|
||||||
See `G0_EVIDENCE_COVERAGE_V2.md`.
|
|
||||||
|
|
||||||
## 4. Non-circular review/deploy gates
|
|
||||||
|
|
||||||
R1's production gate was circular because existing root C06 and Program
|
|
||||||
production evidence could be required before production itself.
|
|
||||||
|
|
||||||
R2 replaces it with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
REVIEW_READY
|
|
||||||
↓
|
|
||||||
STAGING_ACTIVATION_READY
|
|
||||||
↓
|
|
||||||
STAGING_ACCEPTED
|
|
||||||
↓
|
|
||||||
PRODUCTION_ELIGIBLE
|
|
||||||
↓
|
|
||||||
PRODUCTION_ACCEPTED
|
|
||||||
↓
|
|
||||||
PROGRAM_COMPLETE
|
|
||||||
```
|
|
||||||
|
|
||||||
### REVIEW_READY
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- live authority rebind + exact preimages;
|
|
||||||
- ChangeEnvelope complete;
|
|
||||||
- complete affected repository regression;
|
|
||||||
- independent review GO.
|
|
||||||
|
|
||||||
### STAGING_ACTIVATION_READY
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- governed commit + authority readback;
|
|
||||||
- bounded staging activation scope;
|
|
||||||
- rollback/recovery plan ready;
|
|
||||||
- pre-activation lab/contract evidence.
|
|
||||||
|
|
||||||
It does **not** require evidence that only real staging activation can produce.
|
|
||||||
|
|
||||||
### STAGING_ACCEPTED
|
|
||||||
|
|
||||||
Requires actual staging:
|
|
||||||
- TaskPlane adapter/claim/lease/fencing;
|
|
||||||
- root action executor;
|
|
||||||
- ANNEX A01–A23;
|
|
||||||
- restart/response-loss/sibling concurrency/recovery evidence.
|
|
||||||
|
|
||||||
### PRODUCTION_ELIGIBLE
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- STAGING_ACCEPTED;
|
|
||||||
- existing release authorization valid;
|
|
||||||
- production rollback ready;
|
|
||||||
- freeze/NO-GO clear.
|
|
||||||
|
|
||||||
### PRODUCTION_ACCEPTED
|
|
||||||
|
|
||||||
Requires actual deployment:
|
|
||||||
- production deployment/runtime readback;
|
|
||||||
- authenticated business E2E;
|
|
||||||
- external projection readback.
|
|
||||||
|
|
||||||
### PROGRAM_COMPLETE
|
|
||||||
|
|
||||||
Only after production acceptance:
|
|
||||||
- existing Identity/Lineage original C01–C06 all accepted;
|
|
||||||
- Program final acceptance all satisfied;
|
|
||||||
- final root review proves no remaining acceptance gaps.
|
|
||||||
|
|
||||||
The existing root acceptance contract is unchanged. Its full completion is
|
|
||||||
consumed only by the final program-complete gate.
|
|
||||||
|
|
||||||
## 5. State authority rules
|
|
||||||
|
|
||||||
No new business authority is added.
|
|
||||||
|
|
||||||
- caller_scan: read-only evidence producer;
|
|
||||||
- WP0 inventories: read-only discovery;
|
|
||||||
- Environment Profile: read-only host profile;
|
|
||||||
- Retry Governor: decision/audit only;
|
|
||||||
- SchedulabilityProof: short-lived diagnostic evidence;
|
|
||||||
- SystemTruth: read-only derived view;
|
|
||||||
- TaskPlane / existing claim transaction: authoritative task/claim/lease state;
|
|
||||||
- existing mission/continuation path: root-action executor;
|
|
||||||
- existing release Saga: release authority.
|
|
||||||
|
|
||||||
No new Task DB, scheduler, mission store, release controller or authorization
|
|
||||||
authority is introduced in R2.
|
|
||||||
|
|
||||||
## 6. Migration
|
|
||||||
|
|
||||||
- Existing mission/root/children: preserve IDs and acceptance.
|
|
||||||
- Legacy durable task/job: attach metadata only; do not replace identity.
|
|
||||||
- Schedulability proof: persist only through existing evidence/journal if
|
|
||||||
persistence is required.
|
|
||||||
- No direct SQLite mutation outside existing TaskPlane repository.
|
|
||||||
- G0 outputs remain shadow/read-only.
|
|
||||||
|
|
||||||
## 7. Deployment status
|
|
||||||
|
|
||||||
R2 code may proceed to live-authority rebase and repository-native review after
|
|
||||||
its focused regressions pass.
|
|
||||||
|
|
||||||
Deployment remains NO-GO until:
|
|
||||||
- live preimages are refreshed;
|
|
||||||
- full repository regressions pass;
|
|
||||||
- atomic TaskPlane adapter is implemented and verified;
|
|
||||||
- ANNEX A18–A23 pass;
|
|
||||||
- real root-action executor consumes PENDING intents exactly once;
|
|
||||||
- independent staging review is GO.
|
|
||||||
|
|
||||||
## 8. Rollback
|
|
||||||
|
|
||||||
Review candidate: discard candidate.
|
|
||||||
|
|
||||||
Governed but undeployed commit: use existing successor/revert path; no shared
|
|
||||||
branch reset/force push.
|
|
||||||
|
|
||||||
Staging: disable feature/policy binding and preserve evidence.
|
|
||||||
|
|
||||||
Production: existing release Saga rollback only.
|
|
||||||
|
|
||||||
## 9. Deferred platform work
|
|
||||||
|
|
||||||
WP3–WP10 remain dependency-gated backlog from Platform Baseline V4. R2 does not
|
|
||||||
claim their acceptance and does not pre-build them.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# G0 Evidence Coverage V2
|
|
||||||
|
|
||||||
R2 corrects the R1 wording: WP0a–d and WP1 outputs are **discovery evidence**,
|
|
||||||
not complete Generation-G0 acceptance by themselves.
|
|
||||||
|
|
||||||
| WP | R2 output | What it proves | Still required for G0 Exit |
|
|
||||||
|---|---|---|---|
|
|
||||||
| WP0a | PARTIAL_DISCOVERY | authority/runtime/production OID relation | VERSION/current/baseline/README/versions/release metadata reader-writer-caller/live mapping |
|
|
||||||
| WP0b | PARTIAL_DISCOVERY | lexical Git-writer candidates | writer→auth→repo→ref→CAS→receipt→caller graph and live classification |
|
|
||||||
| WP0c | PARTIAL_DISCOVERY | authorization references by term/subdomain hint | owner/broker, TaskPrincipal, ClaimLease/TaskLease, WriterLease, release/recovery, signer chain + revocation/live caller |
|
|
||||||
| WP0d | PARTIAL_DISCOVERY | function-level canonical/digest candidates | producer/consumer, input schema, bytes rule, algorithm and duplicate semantic groups |
|
|
||||||
| WP0e | caller_scan v2 | fail-closed source/runtime caller evidence | five historical known-case acceptance + complete runtime evidence for any PROVEN_ORPHAN |
|
|
||||||
| WP1 | capability discovery / requirements evaluated | actual local capability probes | declared host requirements for each key execution host, missing requirements resolved |
|
|
||||||
| WP2 | conservative decision layer | retry/suppress/reconcile/block decision | audit baseline on real operations; no scheduler authority |
|
|
||||||
|
|
||||||
`g0_exit_eligible=false` in WP0a–d output is intentional. The G0 gate is a
|
|
||||||
separate acceptance aggregation step and must reference the missing evidence
|
|
||||||
listed above.
|
|
||||||
|
|
||||||
WP3–WP10 remain dependency-gated backlog exactly as Platform Baseline V4
|
|
||||||
defines; R2 does not silently redefine the long-term platform goal to U01–U09.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.unified-review-gates.v2",
|
|
||||||
"gates": [
|
|
||||||
{
|
|
||||||
"id": "REVIEW_READY",
|
|
||||||
"requires": [
|
|
||||||
"BASE_REBOUND",
|
|
||||||
"CHANGE_ENVELOPE_COMPLETE",
|
|
||||||
"REPO_REGRESSION_PASS",
|
|
||||||
"INDEPENDENT_REVIEW_GO"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "STAGING_ACTIVATION_READY",
|
|
||||||
"requires": [
|
|
||||||
"REVIEW_READY",
|
|
||||||
"GOVERNED_COMMIT_READBACK",
|
|
||||||
"LIMITED_SCOPE_PLAN",
|
|
||||||
"ROLLBACK_READY",
|
|
||||||
"LAB_CONTRACT_PASS"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "STAGING_ACCEPTED",
|
|
||||||
"requires": [
|
|
||||||
"STAGING_ACTIVATION_READY",
|
|
||||||
"LIVE_ADAPTER_PASS",
|
|
||||||
"ROOT_EXECUTOR_PASS",
|
|
||||||
"ANNEX_A01_A23_PASS",
|
|
||||||
"FAILURE_RECOVERY_PASS"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PRODUCTION_ELIGIBLE",
|
|
||||||
"requires": [
|
|
||||||
"STAGING_ACCEPTED",
|
|
||||||
"RELEASE_AUTHORIZATION_VALID",
|
|
||||||
"PRODUCTION_ROLLBACK_READY",
|
|
||||||
"NO_GO_CLEAR"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PRODUCTION_ACCEPTED",
|
|
||||||
"requires": [
|
|
||||||
"PRODUCTION_ELIGIBLE",
|
|
||||||
"PRODUCTION_DEPLOYMENT_READBACK",
|
|
||||||
"BUSINESS_E2E_PASS",
|
|
||||||
"EXTERNAL_PROJECTION_READBACK"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PROGRAM_COMPLETE",
|
|
||||||
"requires": [
|
|
||||||
"PRODUCTION_ACCEPTED",
|
|
||||||
"EXISTING_ROOT_C01_C06_ACCEPTED",
|
|
||||||
"PROGRAM_U01_U09_ACCEPTED",
|
|
||||||
"FINAL_ROOT_REVIEW_COMPLETE"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": [
|
|
||||||
"No gate may require evidence produced only by its own downstream action.",
|
|
||||||
"Existing Identity/Lineage root acceptance is unchanged; full C01-C06 is consumed only at PROGRAM_COMPLETE."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# AIOS Unified Platform Convergence — Task Contract V2
|
|
||||||
|
|
||||||
## Program goal
|
|
||||||
|
|
||||||
Preserve existing governance and tenant isolation while converging
|
|
||||||
Identity/Lineage, ANNEX-2.3 execution integrity, Platform Generation G0 and the
|
|
||||||
existing release/recovery path into one reviewable and recoverable program.
|
|
||||||
|
|
||||||
## Existing dependency
|
|
||||||
|
|
||||||
Identity/Lineage Mission:
|
|
||||||
- `mission-f3af205b-7dd6-455f-a561-88f0294cc815`
|
|
||||||
- root `task-133ad96e-e0d7-4202-934f-80c75053b11f`
|
|
||||||
|
|
||||||
Its C01–C06 acceptance is immutable.
|
|
||||||
|
|
||||||
## Program acceptance
|
|
||||||
|
|
||||||
| Key | Acceptance |
|
|
||||||
|---|---|
|
|
||||||
| U01 | authority/runtime/production baseline reconciled and candidate bound to current Git authority |
|
|
||||||
| U02 | existing Identity/Lineage mission identity and acceptance contract preserved; pre-production dependencies C01–C05 reconciled without claiming root complete |
|
|
||||||
| U03 | ANNEX-2.3 A01–A23 accepted in real staging/lab as specified |
|
|
||||||
| U04 | Platform Generation G0 Exit independently accepted; partial discovery output alone is insufficient |
|
|
||||||
| U05 | unified governed commit + authority readback |
|
|
||||||
| U06 | staging integrated E2E root→child→proof→ROOT_REVIEW→actual action→re-review |
|
|
||||||
| U07 | production deployment + runtime/business/external readback accepted |
|
|
||||||
| U08 | restart/response-loss/sibling concurrency/rollback/recovery accepted |
|
|
||||||
| U09 | existing Identity/Lineage original C01–C06 fully accepted and final Program ROOT_REVIEW proves no remaining gaps |
|
|
||||||
|
|
||||||
## Gate consumption
|
|
||||||
|
|
||||||
- REVIEW_READY consumes no staging/production evidence.
|
|
||||||
- STAGING_ACTIVATION_READY consumes governed commit/readback and lab evidence,
|
|
||||||
but no evidence that requires staging activation.
|
|
||||||
- STAGING_ACCEPTED consumes live staging evidence.
|
|
||||||
- PRODUCTION_ELIGIBLE consumes staging acceptance + release authorization.
|
|
||||||
- PRODUCTION_ACCEPTED consumes live production evidence.
|
|
||||||
- PROGRAM_COMPLETE consumes U01–U09 including final existing-root acceptance.
|
|
||||||
|
|
||||||
## Autonomous rule
|
|
||||||
|
|
||||||
Any child terminal:
|
|
||||||
`child evidence → ROOT_REVIEW → reload original acceptance → query descendants
|
|
||||||
→ next legal action`.
|
|
||||||
|
|
||||||
A child/local/gate PASS closes only that scope. It never closes the Program by
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
|
|
||||||
New task display names retain:
|
|
||||||
`TASK-<SAFE_USER_ALIAS>-<UTC>-R<ROOT8>-P<PARENT8>-D<DEPTH>-C<SEQ>-<PURPOSE>`.
|
|
||||||
|
|
||||||
Authoritative lineage is persisted separately; existing durable IDs are not
|
|
||||||
renamed/recreated.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# V2R2C2 P0 — 收敛状态与缺口记录
|
|
||||||
|
|
||||||
日期:2026-09-09 · 分支:`agent/v2r2c2-p0-ttl-captrace-contract-20260909`
|
|
||||||
基线:authority main `0136a53`,integration serial head `49d30a2`(V2R2C2 integration base)
|
|
||||||
|
|
||||||
> 本记录为**工程收敛状态**的可审查交付,非发布宣称。凡标注"待 Linux 实跑 / 待 Owner gate"的项,未在本机声称完成。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Evidence Producer 缺口定稿(对应执行要求 §9)
|
|
||||||
|
|
||||||
**结论:`EVIDENCE_PRODUCER_REQUIRED`**(机制缺口真实存在,非"producer 90%"、非"materializer 就是 producer")。
|
|
||||||
|
|
||||||
依据(代码核验):
|
|
||||||
- `active-runtime/app/release_evidence_materializer.py`
|
|
||||||
- `TrustedBuilderEvidenceMaterializer` = **验证并投影** closure(collect 重算 + atomically project),docstring 明确:*"producer must first write a complete, root-owned evidence closure below source_root/<version>"*——即 Materializer 依赖**上游 Producer** 先写 closure。
|
|
||||||
- `config/gitops/trusted-candidate-builder-policy.json` 的 `evidence_layout` 声明 closure 组成:
|
|
||||||
`artifact / manifest / package_closure / tests / canary / gitea / rollback / policy / secret_scan`
|
|
||||||
(= 执行要求 §9 点名的 canary/rollback/secret-scan/Gitea consistency 证据来源)
|
|
||||||
- **未发现**独立注册的 `Governed Release Evidence Producer` operation:
|
|
||||||
- `aios_baseline/operations.py` 是声明式(非执行)operation registry 框架,**不注册**该 producer。
|
|
||||||
- `trusted_builder_signer_provisioning.py` 是 builder 私钥/打包 **signer provisioning**,非 release closure producer。
|
|
||||||
|
|
||||||
**缺口 → 阻塞关系**:closure 的产生方缺失,阻塞 Gate R 的 "evidence closure" 冻结项。不得由 Materializer 自产证据、hand-write receipt / test PASS / Gitea consistency / canary / rollback / secret-scan。
|
|
||||||
|
|
||||||
**实现完成(本轮 wt-p0)**:
|
|
||||||
- `active-runtime/app/governed_release_evidence_producer.py` — operation `governed.release.evidence.produce`:
|
|
||||||
- 契约:输入 = 真实构建产物(artifact/manifest/package_closure/policy bytes)+ 真实校验执行结果(tests/canary/gitea/rollback/secret-scan)+ 部署约定(deployment/tag/finalization)+ 构建执行时刻(issued_at);**不手写证据**——每个 digest 从真实字节推导,pass/fail/gitea-consistent flag 来自真实执行输入,fail-closed 校验(secret-scan 必须覆盖实际 artifact 字节、tests/canary/rollback/gitea 必须通过)。
|
|
||||||
- 产出:写 `source_root/<version>/` 完整 closure(receipt.json + 全 evidence_layout 文件),幂等(相同闭包接受、冲突拒绝),O_BINARY 写入避免 CRLF。
|
|
||||||
- 注册:`producer_operation_definition()` / `producer_registry()` 挂到 OperationRegistry(声明式、非执行)。
|
|
||||||
- `tests/test_governed_release_evidence_producer.py` — 5 项契约测试(本地闭环):closure 可被 builder 验证路径(ServerOwnedEvidenceSource + DeterministicEvidenceCollector = materializer 同款)重验证且 evidence_digests 一致;secret-scan 不匹配 / artifact 篡改 fail-closed;幂等;registry 注册。**5 passed**。
|
|
||||||
- **待 Linux 实跑**:真实 canary / Gitea consistency / rollback / secret-scan 执行作为 producer 输入闭环;materializer 字节级投影在 Windows 受其 `_copy_exact` 缺 O_BINARY 的 CRLF 影响(Linux 无碍),亦待 Linux 实跑验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 现场未 Git 化配置收敛(对应执行要求 §7)
|
|
||||||
|
|
||||||
主 clone 根目录 11 个现场 untracked 文件,分类如下(**不擅自删除**,交由后续按此清单 DROP 或归档):
|
|
||||||
|
|
||||||
| 文件 | 类别 | 处置 |
|
|
||||||
|---|---|---|
|
|
||||||
| `aios-gitops-model-api-v2r2c2-baseline11-candidate.service` | 现场具体 OID(2852c2a) 部署实例 | `DROP_WITH_REASON: 现场部署实例;正式 unit 形态已由 deploy/systemd/ 收敛(含 aios-trusted-candidate-builder-ptrace.service)` |
|
|
||||||
| `governed-runtime-package-2852c2a.json` | 生成部署包 manifest | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
|
||||||
| `aios_2852c2a_app.tar` | generated tarball | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
|
||||||
| `aios_2852c2a_lf.tar` | generated tarball | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
|
||||||
| `inspect_db.py` / `inspect_db2.py` | local DB inspection artifact | `DROP_WITH_REASON: 一次性 debug probe` |
|
|
||||||
| `prove_prereq.py` | 一次性验证脚本(硬编码 /opt 路径) | `DROP_WITH_REASON: 一次性 debug probe` |
|
|
||||||
| `verify_after.py` / `verify_phaseD.py` | 针对特定 OID 的哈希验证脚本 | `DROP_WITH_REASON: 一次性验证脚本` |
|
|
||||||
| `verify_tar.py` | 硬编码 Windows 路径的 tar 校验 | `DROP_WITH_REASON: 一次性 debug probe` |
|
|
||||||
| `readiness_fix.patch` | temporary patch | `DROP_WITH_REASON: 一次性临时补丁,正式代码已含其意图` |
|
|
||||||
|
|
||||||
**应进入 Git / 已 Git 化**(本轮 wt-p0 已提交,Task 10 定稿):
|
|
||||||
- `deploy/systemd/aios-trusted-candidate-builder.service`(**tracked unit 已与 live 字节一致**:root User + `AmbientCapabilities=CAP_SYS_PTRACE` + `CapabilityBoundingSet=CAP_SYS_PTRACE`;LF 归一化 sha256=`9d194c23…`)
|
|
||||||
- `docs/security/trusted-builder-captrace.md`(安全契约,CAP_SYS_PTRACE 标注 **HIGH-SENSITIVITY**——"broadens process's observation and debugging authority",禁止回卷出非 root 分歧 unit)
|
|
||||||
- `deploy/trusted-builder/aios-trusted-builder-package-manifest.json`(部署 manifest 更新)
|
|
||||||
- `tests/test_trusted_candidate_builder_captrace.py`(可复用测试,EACCES monkeypatch 覆盖 cgroup/exe/cmdline 三路径)
|
|
||||||
- **`aios-trusted-candidate-builder-ptrace.service` 已于 Task 10 删除**——其自造 `User=aios-builder`/`ProtectProc=invisible`/`ProcSubset=pid` 违反 §2(禁止改 uid/group/ProtectProc/ProcSubset/credential/socket 权属);以 live unit 为唯一基线。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. P0 已完成(本地闭环、可验证)
|
|
||||||
|
|
||||||
完整 P0 收敛提交链(相对 serial head `49d30a2`,fast-forward,merge-base=`49d30a2`):
|
|
||||||
- `ae5aeea` — TTL 修复 + TTL 专项测试 + harness 修复 + bootstrap 全链路契约测试(4 files, +520/-5)
|
|
||||||
- `7f83845` — CAP_SYS_PTRACE Git 化(unit + 安全文档 + manifest + 测试,4 files, +240)
|
|
||||||
- `69fc61c` — 收敛状态与已确认缺口记录
|
|
||||||
- `18674d7` — governed release evidence producer 实现 + registry 注册 + 契约测试
|
|
||||||
- `6271bc8` — materializer `_copy_exact`/`_write_json_exclusive` 增加 `os.O_BINARY`(Windows LF→CRLF 字节一致);恢复 producer 真实完成判据(逐字节投影 + sha256 相等 + collector PASS)
|
|
||||||
- `d4f181f` — CAP_SYS_PTRACE 定稿:tracked unit 对齐 live 基线(root+CAP_SYS_PTRACE,LF sha256=`9d194c23…`),删除自造 `-ptrace.service`,安全文档标 HIGH-SENSITIVITY,测试改 EACCES monkeypatch 覆盖三路径
|
|
||||||
- `b519aa0` — Producer 变 trusted-provider-bound + runtime-registered operation;新增 `governed_evidence_runtime.py`(Inventory→Resolver→operation→Producer→closure → `PRODUCER_RUNTIME_REGISTERED_PASS`);10 producer + 5 runtime 测试
|
|
||||||
|
|
||||||
**Windows 侧实测(当前 HEAD `b519aa0`)**:
|
|
||||||
- 核心 P0 收敛套件(producer/runtime/local_writer/receipt_contract/captrace/trusted_builder/candidate_builder + artifact):**54 passed / 2 skipped**
|
|
||||||
- 2 skip = `SO_PEERCRED`(G9)、POSIX evidence-root mode(G5)——均为文档化 Linux 平台边界
|
|
||||||
- 完整可收集套件(排除 20 POSIX-only collection-error)见 §3b:P0 未引入任何新失败。
|
|
||||||
|
|
||||||
**状态标签(遵守命令纪律,不称 P0 PASS)**:`P0_LOCAL_IMPLEMENTATION_IN_PROGRESS`(本地核心闭环完成,Linux 实跑 + finalize 待办)。
|
|
||||||
|
|
||||||
## 3b. 完整风险驱动测试结论(Windows 差分)
|
|
||||||
|
|
||||||
在 P0 分支跑**完整可收集套件**(排除 20 个 POSIX-only collection-errors:fcntl 16 / grp 3 为 Windows 无标准等价物):**850 passed / 99 failed / 14 skipped**。
|
|
||||||
- **回归判定(对照 base=49d30a2,同 P0 相关既有文件)**:base 33 failed / 97 passed vs P0 32 failed / 103 passed → **P0 改动未引入任何新失败**(失败数不增反减)。P0 分支全部失败均来自 base 既有的 Windows 环境边界(symlink 特权 WinError 1314、POSIX mode/uid 检查在 `os.name != "nt"` 才生效、materializer `_copy_exact` 缺 O_BINARY 的 CRLF、signer_provisioning / release_source_admission 的既有失败),非本分支回归,需 Linux 实跑闭环。
|
|
||||||
- 我的 P0 覆盖区文件(chain_contract / captrace / producer)隔离跑全部通过。
|
|
||||||
- **状态标签**:`WINDOWS_DIFFERENTIAL_NO_NEW_P0_FAILURES`(不是 P0 PASS)。
|
|
||||||
|
|
||||||
## 3c. Linux immutable test package(Task #15,prep 完成,实跑待 Linux)
|
|
||||||
|
|
||||||
- 产物位于 `deliverables/v2r2c2-linux-track/`(**不在 authority repo/源码树**,隔离 staging):
|
|
||||||
- `P0_FINAL-LinuxTrack.manifest.json` + `P0_FINAL-LinuxTrack.sha256` — 钉住 15 个 P0 文件在 P0_HEAD `b519aa0` 的 LF 源字节(CRLF 归一化验证,Linux checkout 天然 LF 匹配)
|
|
||||||
- `run_linux_native_acceptance.sh` — Linux 运行器:不可变校验 → 全套件收集(fcntl/grp/SO_PEERCRED)→ POSIX spot checks(mode-bit/symlink/trusted-builder CAP 契约/禁 ProtectProc/ProcSubset 回卷)→ ledger ADJUDICATE=0 复核
|
|
||||||
- `LINUX_TRACK_ACCEPTANCE.md` — G1–G10 验收清单
|
|
||||||
- 本机 dry-run:15/15 文件 sha256 校验 OK;bash 语法 OK。
|
|
||||||
- **Linux 实跑**(G1–G10)待 Linux 主机/远端 `hw` 受控执行。
|
|
||||||
|
|
||||||
## 4. 待继续及 gate 依赖
|
|
||||||
|
|
||||||
| 项 | 状态 | gate 依赖 |
|
|
||||||
|---|---|---|
|
|
||||||
| Evidence Producer 真实执行闭环 | ✅ 代码+契约测试完成(trusted-provider receipt + runtime registration,10+5 passed);真实输入闭环 | **Linux/生产实跑** → Gate R evidence closure |
|
|
||||||
| materializer 字节投影 | ✅ `_copy_exact` 已加 `O_BINARY`(Task 12) | Linux 实跑复跑确认 |
|
|
||||||
| Merge ledger ADJUDICATE=0 | ✅ 完成(Task 13)——192 SUPERSEDED_BY(均 40hex)+ 20 DROP + 4 ARCHIVED,MERGE=0 | freeze/closure |
|
|
||||||
| CAP_SYS_PTRACE 材料 Git 化 | ✅ 完成(Task 10,`d4f181f`) | **Linux 主机实跑**(systemd/runtime 三态) |
|
|
||||||
| Linux immutable 测试包 prep | ✅ 完成(Task 15)——manifest+sha256+runner+清单 | **Linux 实跑** G1–G10 |
|
|
||||||
| POSIX mode-bit / fcntl / grp / symlink / SO_PEERCRED 测试 | Windows skipped / collection-error | **Linux 实跑 / CI** |
|
|
||||||
| **P0_FINAL_HEAD 生成 + `git diff 49d30a2..P0_FINAL_HEAD`** | 本文档更新后 freeze commit | §13 |
|
|
||||||
| P0 final gate(§14)全 TRUE → `INTEGRATION_DIFF_FROZEN` | Linux 实跑 + freeze 后 | §14 |
|
|
||||||
| Gate W(§15)freeze + 正式 integration task | 待 freeze | §15 |
|
|
||||||
| Gate W / Gate R finalize | — | **Owner 密码学签名** |
|
|
||||||
| main merge / tag / baseline / staging / production | — | Owner / protected PR / 远端 Linux |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Round-3 Provider Authentication(ProviderAuth,P0 续)
|
|
||||||
|
|
||||||
**范围**:落地 `AIOS_V2R2C2_Round3_ProviderAuth_CodeBundle.zip`(SHA256=082f220da7e5fd19204d3eac2377d4ea00751e0db9b23f6fa58e5cad381b4ae0,manifest 逐文件校验一致),不重新设计认证方案。
|
|
||||||
|
|
||||||
**冻结基线**:
|
|
||||||
```
|
|
||||||
PREVIOUS_FREEZE_HEAD = a6dc0c3a63836a3668d972f632131f772626c363
|
|
||||||
PROVIDER_AUTH_HEAD = f7f53d0568767d6deec2c47008a89f4d8413f93d
|
|
||||||
PROVIDER_EXECUTOR_HEAD = 2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d (tree 505a66a7)
|
|
||||||
```
|
|
||||||
|
|
||||||
**接线(新增独立 Round-3 路径,保留 Round-2 链)**:
|
|
||||||
- `trusted_provider_receipts.py` — fail-closed `TrustedProviderReceiptResolver`(root-owned-store-v1 / hmac-sha256-v1)硬安全门:absolute/traversal/symlink/owner-UID/mode/hardlink/max-size/identity/operation allowlist/canonical SHA256/source-tree OID/issued_at future-skew/max-age/HMAC compare_digest/authenticated provenance。
|
|
||||||
- `governed_provider_auth_adapter.py` — receipt_ref-only;拒绝 `CALLER_SUPPLIED_PROVIDER_RECEIPT_DENIED`;无 resolver `PROVIDER_AUTHENTICATION_REQUIRED`;Gate-W(tests/gitea/secret_scan) vs Gate-R(canary/rollback) 分离。
|
|
||||||
- `governed_authenticated_evidence_operation.py` — 生产 `produce_authenticated_evidence_closure` + `validate_gate_w_provider_auth`。
|
|
||||||
- producer 新增注册 `governed.authenticated.release.evidence.produce`,`handler_ref` → `governed_authenticated_evidence_operation.produce_authenticated_evidence_closure`(文本 + verify 校验均满足);round-2 `producer_operation_definition()` 保留。
|
|
||||||
- runtime 从 governed policy(`config/gitops/trusted-evidence-providers.json`)建 `TrustedProviderReceiptResolver`,新增 Gate-W / Gate-R 认证入口;禁止 request 提供 store_root/provider_identity/key_id/HMAC。
|
|
||||||
- 二层 receipt binding:legacy v1 `receipt` 保留 + resolver 认证 v2 `provider_receipt` provenance 作为第二层输入(schema `aios.trusted-evidence-provider-receipt.v2`);未放宽任何拒绝路径。
|
|
||||||
- `trusted_evidence_providers.py` — 正式 root trusted executor:`produce()` 逐 kind 走**真实执行路径**(tests→pytest+JUnit 聚合;secret_scan→gitleaks `--no-git` 文件系统扫描;gitea→需 API/token/repo 前提,缺则 fail-closed),`identity/operation` 来自 governed policy(绝不 acceptance 脚本伪造),receipt 由 `_write_receipt` root-owned 写入(nofollow/owner/mode 门,缺失 root 子目录自动创建),`content_sha256` 派生自真实工具输出。
|
|
||||||
|
|
||||||
**接受度实测**:
|
|
||||||
- Linux(cn-network-mkt `hw`,Python 3.12.3 + pytest 9.1.1)集成闭环:bundle 24 passed · producer 10 · runtime 5 · materializer 5 · captrace/trusted-builder 30 · task-admission 106 · `verify_round3_integration.py` = **PASS**(合计 180 passed / 0 failed)。
|
|
||||||
- Windows:round-2 producer+runtime 15 passed;bundle 23/1 failed 为**平台边界**(`os.getuid` absent、symlink 特权 WinError 1314、Python 3.13 `stat_result._fields` 移除、NTFS 无 POSIX mode/hardlink),非接口差异,Linux 已闭环。
|
|
||||||
- materializer symlink 平台测试在 Windows 1 failed(既有,非回归)、Linux 5 passed(同 convergence §3b 平台边界记录)。
|
|
||||||
|
|
||||||
**commit**:`PROVIDER_AUTH_HEAD=f7f53d0`(10 files,+1436/−27);tree=`a1430cd8…`;随后 `PROVIDER_EXECUTOR_HEAD=2d64d5a8`(executor 机制 + 真实执行正确性修复 + 可复现 Gate-W prove 工具 `scripts/round3_provider_gatew_prove.py`)。
|
|
||||||
|
|
||||||
**Real provider 执行(hw Linux,root,最终绑定 `2d64d5a8`/`505a66a7`,经 `scripts/round3_provider_gatew_prove.py`)**:
|
|
||||||
- `tests` → `aios.tests-runner`/`tests.run`:真实 pytest(1480 collected),JUnit 聚合 → `passed=false`(`failures=3`;`exit_code=1`;`ran_real_tests=true`)。3 个失败均为**既存环境性、非 Round-3 回归**:`test_artifact_digest_is_bound`(V2R1C6 artifact/version digest 绑定不一致)、`test_rollback_is_fail_closed_without_approval`(rollback 脚本依赖)、`test_real_signer_help_and_self_test_are_available`(缺部署 signer 二进制)。
|
|
||||||
- `secret_scan` → `aios.secret-scanner`/`secret.scan`:真实 gitleaks `--no-git` 扫描 checkout → `passed=true`(`clean_run=true`,0 findings)。
|
|
||||||
- `gitea` → `aios.gitea-machine-transport`:**`PROVIDER_MECHANISM_REQUIRED`**(API URL/token/repo identity 未 provisioned,fail-closed,未写任何失真 receipt)。
|
|
||||||
- 闭环:`TrustedProviderReceiptResolver`(由 policy 构建)resolve 上述真实 receipt → **`GATE_W_PROVE={"closed":["tests","secret_scan"],"mechanism_required":["gitea"],"resolver_closure":"PASS"}`**(tests + secret_scan 均通过全部安全门:owner/mode/nofollow/OID/time/content_sha/authentication kind+store_id/operation allowlist)。
|
|
||||||
- 过程修正:发现并修复 executor 的 vacuous-pass 缺陷(0-test JUnit 不再判 passed;gitleaks `--no-git` 文件系统扫描支持无 .git 导出树;secret_scan store_root 以 governed policy 为准,非 UI 拼接);全部经真实重跑验证。
|
|
||||||
- 全程未伪造 `{"passed":true}`、未由 acceptance 脚本自造 identity;tests receipt 如实记录 `passed=false`(真实状态)。
|
|
||||||
|
|
||||||
**Gate W**:真实 provider executor 与两枚真实 receipt 已就绪(tests/secret-scan);gitea 因无真实 API/token 前提保持 `PROVIDER_MECHANISM_REQUIRED`(未伪造)。Gate-R canary/rollback 保持为空(未提前制造 fake)。Linux immutable package 重建 + Gate-W producer smoke(仅 receipt_ref)+ 正式 integration task 待本轮 freeze 后执行。
|
|
||||||
|
|
||||||
**状态标签**:`ROUND3_PROVIDER_AUTH_SOURCE_COMMITTED` + `ROUND3_PROVIDER_EXECUTOR_REAL`(tests/secret-scan 真实 receipt + resolver 闭环验证通过;gitea `PROVIDER_MECHANISM_REQUIRED`;Package 重建 + Gate-W smoke 待续)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5b. Round-3 Gate-W Finalizer v2(Gate-W 三审计问题修复,P0 续)
|
|
||||||
|
|
||||||
**范围**:落地 `AIOS_V2R2C2_Round3_GateW_Finalizer_v2.zip`(SHA256=`c5f031967a7359b89398eccacb68c07162d92e1c5b2c5f4badf1a669c9f31ef6`,manifest 4 文件 sha256 逐文件一致)。三修复:
|
|
||||||
|
|
||||||
1. **Gitea 一致性用权威 Git transport**——不再依赖不存在的 REST API;用真实 `git remote get-url origin` / `git symbolic-ref --quiet HEAD` / `git ls-remote --exit-code origin <exact branch>` 证明。receipt 显式 `transport=git-ls-remote`、`api_verified=false`。
|
|
||||||
2. **Receipt append-only 不可变**——每次 run 独立 namespace `round3-gate-w/<source[:12]>/<run-id>/<kind>.json`,旧 receipt 永不删除/覆盖;相同 ref 已存在则 `GITEA_PROVIDER_RECEIPT_EXISTS` fail-closed(O_EXCL+O_NOFOLLOW root-owned 写入)。
|
|
||||||
3. **Gate 语义分离**——`provider_auth_closed` / `tests_result_passed` / `secret_scan_result_passed` / `gitea_consistent` / `gate_w_ready` 五元组独立;resolver/authentication PASS ≠ result PASS;`tests.result.passed=false` 保持 `gate_w_ready=false`。
|
|
||||||
|
|
||||||
**接线(新增/替换,均真实 git checkout,非 tar-export)**:
|
|
||||||
- `active-runtime/app/gitea_transport_consistency_provider.py`(新增)——`collect_gitea_transport_consistency()` 校验 local HEAD==source、HEAD^{tree}==tree、origin identity==`aios/aios`、symbolic branch 为 `refs/heads/...`、`git ls-remote --exit-code` 返回 source;`produce_gitea_consistency_receipt()` 从 governed policy(`config/gitops/trusted-evidence-providers.json` 的 `gitea`)读 identity/store_id/store_root/operation/modes,exclusive-create 写 root-owned append-only receipt。
|
|
||||||
- `scripts/round3_provider_gatew_prove_v2.py`(替换 `round3_provider_gatew_prove.py`,旧脚本已删)——前置强制真实 checkout(`HEAD^{commit}`==source、`{source}^{tree}`==tree、`status --porcelain` 空);append-only ref;gitea 走 `produce_gitea_consistency_receipt`,tests/secret_scan 走 `produce`;summary 五元组;RC 0/1/2/3(0 全绿 / 1 认证或一致性失败 / 2 机制缺失 / 3 认证 receipt 在而某 result gate 红)。
|
|
||||||
- `tests/test_gitea_transport_consistency_provider.py`(新增)——mock `GitRunner`:identity 解析、exact-ref pass、remote/local/repo mismatch denied、remote failure denied(6 用例,命名分支与 governed 分支一致)。
|
|
||||||
- `trusted_evidence_providers.py` / `trusted_provider_receipts.py`(既有,兼容保留)——gitea 分支抛 `PROVIDER_MECHANISM_REQUIRED` 路径仍在(prove v2 的 gitea 走独立路径不触发);resolver 安全门不变。
|
|
||||||
|
|
||||||
**§11/§12 三 tests 真实失败收敛(不改任何 expected assertion,未建 skip/allowlist,未用 known-baseline contract)**:
|
|
||||||
|
|
||||||
| 失败项 | 判定 | 证据 |
|
|
||||||
|---|---|---|
|
|
||||||
| `test_artifact_digest_is_bound`(V2R1C6 artifact/version digest) | **假失败**(CRLF 工作树污染) | worktree artifact CRLF → sha256=`a744fc5a`;LF 归一化后=`437b1f89`=metadata 精确一致;HEAD LF blob 自洽。干净 LF checkout 通过。 |
|
|
||||||
| `test_rollback_is_fail_closed_without_approval` | **假失败**(旧 tar-export 污染 checkout) | `sh scripts/rollback_v2r1c6_release.sh` 无 approval 本地实跑返回 `42` + stderr `BLOCKED: rollback approval required`=assertions 精确;hw 旧树 `set: Illegal option -` 系污染 checkout 残留 CRLF。 |
|
|
||||||
| `test_real_signer_help_and_self_test_are_available` | **真实部署配置 bug(已修复)** | `/usr/local/libexec/aios-grant-signer-adapter self-test` 返回 42:`allowed_signers_file`(`/etc/aios/trust/aios-task-grant-allowed-signers`) 内 `aios-task-grant-signer` 公钥 fp=`rPqpxf…` ≠ 真实凭据/`root_public_key_file` fp=`/nsGZD…`。已将 allowed_signers 条目就地同步为真实凭据公钥(备份 `.bak-20260909`),自测 `SIGNER_SELF_TEST=PASS`、rc=0。 |
|
|
||||||
|
|
||||||
**FINAL_SOURCE_HEAD / TREE(Gate-W prove 唯一绑定坐标)**:
|
|
||||||
```
|
|
||||||
FINAL_SOURCE_HEAD = 本最终源码 freeze commit 的 commit OID(push exact governed branch 后由 git rev-parse HEAD 定)
|
|
||||||
FINAL_SOURCE_TREE = FINAL_SOURCE_HEAD^{tree}
|
|
||||||
```
|
|
||||||
时序(对应执行要求 §5/§8):源码+docs 全部完成 → 最后源码 freeze commit 得 FINAL_SOURCE_HEAD/TREE → push exact governed branch → 之后 source branch **不再 commit** package/docs(package 进 artifact store / detached)。所有三类 provider receipt(tests/secret_scan/gitea)的 `source_oid`/`tree_oid` 都必须 == FINAL_SOURCE_HEAD/TREE。prove v2 以显式 `--source-oid FINAL_SOURCE_HEAD --tree-oid FINAL_SOURCE_TREE` 在与之逐字节一致的干净 git checkout 上生成/校验 receipt,杜绝 tar-export 假树。
|
|
||||||
|
|
||||||
**Gate-W 实跑(首轮,clean Linux checkout)与 harness 修复(re-freeze)**:
|
|
||||||
- 首轮 prove v2(绑定 c8ea953)结果 `GATE_W_PROVE={"authenticated_providers":["gitea","secret_scan","tests"],"provider_auth_closed":true,"gitea_consistent":true,"secret_scan_result_passed":true,"tests_result_passed":false,"mechanism_required":[],"gate_w_ready":false}`,RC=3。**重大进展**:Gitea 由原先 `PROVIDER_MECHANISM_REQUIRED` 变为真实 git-transport 认证(`transport=git-ls-remote`、`api_verified=false`、remote_ref_oid==FINAL_SOURCE_HEAD)。
|
|
||||||
- tests 唯一剩余失败 = `tests.baseline_v1_1.test_job_safety::test_subprocess_crash_after_durable_sink_success_recovers_without_repeat`(1486 tests,failures=1)。根因:`run_tests_provider` 调用 pytest 未置 `PYTHONPATH=active-runtime/app`,子进程式测试找不到仓库 runtime 模块(`ModuleNotFoundError: aios_gitops_job_executor`)→ 假失败。同测试置 PYTHONPATH 后 **1 passed**。仓库权威 runner `tools/baseline_v1_1/run_acceptance.py` 与 `docs/implementation-baseline-1.1/ACCEPTANCE_RESULTS.md` 均要求向子进程传递源码 PYTHONPATH。
|
|
||||||
- **修复(非 skip/allowlist,不改任何 assertion)**:`trusted_evidence_providers.py` 的 `_run` 增加 `extra_env`,新增 `_tests_env()` 转发 `PYTHONPATH=<repo_root>;<repo_root>/active-runtime/app` 至 pytest collect 与执行两个子进程;新增 `tests/test_trusted_evidence_providers_harness.py`(2 passed,hw 验证)锁定该 harness 行为。
|
|
||||||
- 因该源码修复,FINAL_SOURCE_HEAD **re-freeze** 至本 freeze commit(push 前次 c8ea953 现为中间态,最终以 re-freeze OID 为准),并重新完整 prove。
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Unified Workstream DAG & Acceptance Matrix V2
|
|
||||||
|
|
||||||
## Workstream DAG
|
|
||||||
|
|
||||||
```text
|
|
||||||
Baseline Reconciliation
|
|
||||||
├─ Track A Identity/Lineage existing mission
|
|
||||||
│ ├─ C01 Governed commit/readback
|
|
||||||
│ ├─ C02 Staging activation
|
|
||||||
│ ├─ C03 Root-action executor
|
|
||||||
│ ├─ C04 Legacy attach
|
|
||||||
│ ├─ C05 Live failure/replay/concurrency E2E
|
|
||||||
│ └─ C06 Production convergence
|
|
||||||
├─ Track B ANNEX-2.3 R2
|
|
||||||
│ ├─ B1 Schedulability proof
|
|
||||||
│ ├─ B2 Discovery diagnostics
|
|
||||||
│ ├─ B3 Claim-time read recheck
|
|
||||||
│ ├─ B4 Atomic TaskPlane claim/lease/fencing
|
|
||||||
│ ├─ B5 Execution/heartbeat
|
|
||||||
│ └─ B6 Evidence/readback A01–A23
|
|
||||||
└─ Track C Platform G0
|
|
||||||
├─ WP0a Version/Baseline discovery
|
|
||||||
├─ WP0b Git writer discovery
|
|
||||||
├─ WP0c Authorization discovery
|
|
||||||
├─ WP0d Canonical/Digest discovery
|
|
||||||
├─ WP0e caller_scan
|
|
||||||
├─ WP1 Environment Profile
|
|
||||||
└─ WP2 Retry/Toil Governor
|
|
||||||
|
|
||||||
A + B + C REVIEW_READY
|
|
||||||
↓
|
|
||||||
governed commit/readback
|
|
||||||
↓
|
|
||||||
STAGING_ACTIVATION_READY
|
|
||||||
↓
|
|
||||||
actual staging activation + A01–A23 + executor
|
|
||||||
↓
|
|
||||||
STAGING_ACCEPTED
|
|
||||||
↓
|
|
||||||
PRODUCTION_ELIGIBLE
|
|
||||||
↓
|
|
||||||
actual production deployment/readback
|
|
||||||
↓
|
|
||||||
PRODUCTION_ACCEPTED
|
|
||||||
↓
|
|
||||||
existing root C01–C06 + Program final review
|
|
||||||
↓
|
|
||||||
PROGRAM_COMPLETE
|
|
||||||
```
|
|
||||||
|
|
||||||
No gate depends on evidence generated only by its own downstream action.
|
|
||||||
|
|
||||||
## R2 matrix
|
|
||||||
|
|
||||||
| Gate/Area | R2 code/review status | Deployment evidence still required |
|
|
||||||
|---|---|---|
|
|
||||||
| caller_scan v2 | focused tests required | historical known-case + live runtime proof |
|
|
||||||
| schedulability proof | focused tests required | live adapter linearization/lease/fence |
|
|
||||||
| atomic claim contract | reference contract/tests only | TaskPlane adapter + race/revoke/freeze staging |
|
|
||||||
| Retry Governor v2 | focused tests required | real audit baseline |
|
|
||||||
| WP0a–d | PARTIAL_DISCOVERY by design | complete G0 mapping and independent acceptance |
|
|
||||||
| WP1 | capability/requirement probe | key-host requirement profiles |
|
|
||||||
| canonical reuse | ADR + guard test | producer/consumer compatibility in live rebase |
|
|
||||||
| governed commit | NOT RUN | writer receipt + authority readback |
|
|
||||||
| staging | NOT RUN | A01–A23, root executor, failure recovery |
|
|
||||||
| production | NOT RUN | release + business/runtime/external readback |
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# AIOS Unified Platform Convergence R1 — 架构变更说明
|
|
||||||
|
|
||||||
## 0. 决策摘要
|
|
||||||
|
|
||||||
本变更将 **Identity/Lineage S3、ANNEX-2.3 R2 Auto-Claim、平台化 Phase-0、
|
|
||||||
Governed Merge/Release** 收敛为一个新的上层 Program,而不是改写已存在
|
|
||||||
Identity/Lineage Mission 的原始验收合同。
|
|
||||||
|
|
||||||
核心原则:
|
|
||||||
|
|
||||||
1. **新 Program,不重写旧 root。** 已有 Mission `mission-f3af205b-7dd6-455f-a561-88f0294cc815` 和 root
|
|
||||||
`task-133ad96e-e0d7-4202-934f-80c75053b11f` 原样保留,C01–C06 的 acceptance 不变。
|
|
||||||
2. **唯一 Git authority 是 `beabd053961047f170e87e1110f2636dbd50f5fb`。** release-control runtime
|
|
||||||
`3c320aeb89d9ba8f0ed516abeb57252682b18983` 和 production `4642b74af62c89a505ae49241e805a5a88ca897f` 是不同平面,不得互相冒充。
|
|
||||||
3. **恢复语义,不恢复旧拓扑。** 从 `3c320ae…` 选择性移植 SystemTruth/Execution
|
|
||||||
Integrity 语义,但不把该后继分支整体推回 main。
|
|
||||||
4. **单一权威、只读聚合。** 新 inventory/caller_scan/Environment Profile/
|
|
||||||
Retry Governor 都是 evidence producer,不成为新持久化权威。
|
|
||||||
5. **先评审,后部署。** 本 R1 是 Review Candidate;禁止在 Review Gate 通过前
|
|
||||||
进行 staging/production activation。
|
|
||||||
|
|
||||||
## 1. 当前权威三平面
|
|
||||||
|
|
||||||
| 平面 | OID | 含义 |
|
|
||||||
|---|---|---|
|
|
||||||
| Git authority / main | `beabd053961047f170e87e1110f2636dbd50f5fb` | 新候选唯一 source base |
|
|
||||||
| release-control runtime | `3c320aeb89d9ba8f0ed516abeb57252682b18983` | 现有控制面运行字节,不是 main |
|
|
||||||
| production baseline | `4642b74af62c89a505ae49241e805a5a88ca897f` | V2R2C2 生产 evidence closure |
|
|
||||||
|
|
||||||
`beabd053961047f170e87e1110f2636dbd50f5fb -> 3c320aeb89d9ba8f0ed516abeb57252682b18983` 仅包含 10 个 SystemTruth/consistency 相关路径;
|
|
||||||
现有 Identity/Lineage 27 文件候选与这 10 个路径无交集。因此 27 文件候选可以
|
|
||||||
在 **重新读取 authority preimage 并通过 apply/check 后** 重新绑定
|
|
||||||
`beabd053961047f170e87e1110f2636dbd50f5fb`,但不能复用旧 `3c320ae…` approval/base 记录。
|
|
||||||
|
|
||||||
## 2. 新 Program 与既有 Mission 的关系
|
|
||||||
|
|
||||||
### 2.1 新上层 Program(尚未在 live TaskPlane 创建)
|
|
||||||
|
|
||||||
`goal_slug = AIOS-UNIFIED-PLATFORM-CONVERGENCE`
|
|
||||||
|
|
||||||
实际 `mission_id/root_task_id` 必须由 live TaskPlane authority 创建并返回,
|
|
||||||
本候选不预造 ID。
|
|
||||||
|
|
||||||
### 2.2 既有 Identity/Lineage Mission(保留)
|
|
||||||
|
|
||||||
- mission: `mission-f3af205b-7dd6-455f-a561-88f0294cc815`
|
|
||||||
- root: `task-133ad96e-e0d7-4202-934f-80c75053b11f`
|
|
||||||
- owner: `local-owner-authority`
|
|
||||||
- alias: `U9CA8400F7`
|
|
||||||
- legacy durable task: `TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-INTEGRATE-20260913T184412Z-19b7`
|
|
||||||
- legacy job: `job-7457a322d5f9d6931c7cd99ce3ec6c18`
|
|
||||||
|
|
||||||
该 Mission 作为新 Program 的 Track A 依赖/子目标;其历史 task/job/idempotency
|
|
||||||
身份不可为了新命名而重建。
|
|
||||||
|
|
||||||
## 3. 四条 Track
|
|
||||||
|
|
||||||
### Track A — Identity / Lineage S3
|
|
||||||
|
|
||||||
目标保持:
|
|
||||||
`root → child → terminal proof → ROOT_REVIEW → actual next action → execution → root re-review`
|
|
||||||
|
|
||||||
包含:
|
|
||||||
- C01 governed candidate commit/readback
|
|
||||||
- C02 staging lineage policy activation
|
|
||||||
- C03 root-action executor binding
|
|
||||||
- C04 legacy attach
|
|
||||||
- C05 live failure/replay/concurrency E2E
|
|
||||||
- C06 production convergence
|
|
||||||
|
|
||||||
### Track B — ANNEX-2.3 R2 Auto-Claim / Execution Integrity
|
|
||||||
|
|
||||||
新链:
|
|
||||||
`admission/preflight → SchedulabilityProof → discovery → claim-time authoritative recheck
|
|
||||||
→ lease/fencing/CAS → execution → heartbeat/progress → result/evidence → readback/acceptance`
|
|
||||||
|
|
||||||
R1 已实现 review-candidate 代码:
|
|
||||||
- `task_plane/schedulability.py`
|
|
||||||
- `task_plane/auto_claim.py`
|
|
||||||
- A01 风格多 blocker、scope/capability/heartbeat/auth/capacity/freeze/dependency/lease
|
|
||||||
检查
|
|
||||||
- proof 仅为诊断证据,不是 bearer credential
|
|
||||||
- claim 前完整 authority recheck
|
|
||||||
|
|
||||||
**R1 不直接替换现有 claim 路径**;必须在 staging 评审时通过 adapter 绑定后才可启用。
|
|
||||||
|
|
||||||
### Track C — Platform Phase-0 / Generation G0
|
|
||||||
|
|
||||||
仅包含批准的第一 generation:
|
|
||||||
|
|
||||||
- WP0a Version/Baseline inventory
|
|
||||||
- WP0b Git writer inventory
|
|
||||||
- WP0c Authorization inventory
|
|
||||||
- WP0d Canonical/Digest inventory
|
|
||||||
- WP0e caller_scan
|
|
||||||
- WP1 Environment Capability Profile
|
|
||||||
- WP2 Retry/Toil Governor
|
|
||||||
|
|
||||||
这些组件只读或纯决策:
|
|
||||||
- 不新增 version authority
|
|
||||||
- 不新增 canonical authority
|
|
||||||
- 不写业务状态
|
|
||||||
- `UNKNOWN` 永远不能被 caller_scan 降级为 orphan
|
|
||||||
- Retry Governor 只返回决策,不执行 retry
|
|
||||||
|
|
||||||
禁止提前引入 WP8 MissionRun store / WP9 新 Release Saga 控制面。
|
|
||||||
|
|
||||||
### Track D — Governed Merge / Staging / Release
|
|
||||||
|
|
||||||
顺序:
|
|
||||||
`Review Gate → governed child commit/readback → staging activation → integrated live E2E
|
|
||||||
→ release candidate/acceptance → production convergence`
|
|
||||||
|
|
||||||
部署不是本 Review Candidate 的交付内容。
|
|
||||||
|
|
||||||
## 4. 状态权威边界
|
|
||||||
|
|
||||||
| 维度 | 权威 |
|
|
||||||
|---|---|
|
|
||||||
| Mission/root/lineage | TaskPlane/Mission authority |
|
|
||||||
| claim/heartbeat/task revision | TaskPlane |
|
|
||||||
| Owner/TaskPrincipal authorization | existing auth/Admission authority |
|
|
||||||
| Writer lease / governed write | existing writer/lease authority |
|
|
||||||
| source bytes/ref | Git authority |
|
|
||||||
| deployed runtime | runtime readback |
|
|
||||||
| external projection | external projection readback |
|
|
||||||
| caller liveness evidence | caller_scan evidence only |
|
|
||||||
| retry decision | Retry/Toil Governor decision only |
|
|
||||||
|
|
||||||
新模块不得建立第三份真值数据库。
|
|
||||||
|
|
||||||
## 5. 关键架构优化
|
|
||||||
|
|
||||||
### 5.1 动态 readiness 与不可变身份分离
|
|
||||||
|
|
||||||
Planner 选择身份(mission/root/child/route/authority digest)不可变;
|
|
||||||
`readiness_class` 是动态观察,可在未激活、未终态时通过 revision/CAS 单调向
|
|
||||||
“更可执行”方向推进。active claim/READY/DONE 后冻结。
|
|
||||||
|
|
||||||
### 5.2 Legacy reader 无副作用
|
|
||||||
|
|
||||||
旧 `GET /v1/tasks/{id}` 可能触发全局 lease expiry。R1 改为:
|
|
||||||
1. 通过 `/v1/tasks/{id}/events` 做 Bearer 认证;
|
|
||||||
2. Task row 从 authority SQLite 以 `mode=ro` + `PRAGMA query_only=ON` 精确读取;
|
|
||||||
3. 只允许终态;
|
|
||||||
4. readback ref 同时绑定 events SHA 和 DB revision;
|
|
||||||
5. 不暴露 `lease_token_hash`。
|
|
||||||
|
|
||||||
### 5.3 SchedulabilityProof 是短寿命诊断证据
|
|
||||||
|
|
||||||
Proof 绑定 task/worker/actor、revision/generation、capabilities、scope digest、
|
|
||||||
authorization revision、capacity/dependency/freeze/lease 观察;TTL ≤ 300s。
|
|
||||||
真正 claim 必须重读 authority 并完全一致,proof 本身不能授权。
|
|
||||||
|
|
||||||
### 5.4 Root Action Provider / Executor 分离
|
|
||||||
|
|
||||||
Provider 只把 root review 映射到准备计划;Executor 使用现有 continuation/recovery
|
|
||||||
路径执行。不能让 planner 把“PENDING”当成“已执行”。
|
|
||||||
|
|
||||||
## 6. 数据迁移策略
|
|
||||||
|
|
||||||
- 既有 Mission/root/children:不重建。
|
|
||||||
- legacy `TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-INTEGRATE-20260913T184412Z-19b7` / `job-7457a322d5f9d6931c7cd99ce3ec6c18`:仅 attachment,不改 identity。
|
|
||||||
- 新 Platform inventory:无持久业务迁移。
|
|
||||||
- 新 schedulability proof:短寿命诊断记录;若持久化,必须复用现有 TaskPlane
|
|
||||||
evidence/journal,禁止新数据库。
|
|
||||||
- SystemTruth:只读派生,不能成为第三控制面。
|
|
||||||
|
|
||||||
## 7. Review Gate
|
|
||||||
|
|
||||||
### R0 — 架构与代码评审
|
|
||||||
必须同时满足:
|
|
||||||
- 新 Program 与旧 Mission 边界明确
|
|
||||||
- base 重新绑定 `beabd053961047f170e87e1110f2636dbd50f5fb`
|
|
||||||
- 变更路径均有 ChangeEnvelope
|
|
||||||
- 新代码定向测试 PASS
|
|
||||||
- 旧候选继承证据有效
|
|
||||||
- 无新增业务权威/数据库
|
|
||||||
- legacy reader 不再使用可变 GET
|
|
||||||
- planner readiness 动态规则经过测试
|
|
||||||
- R2 proof 不能直接授权 claim
|
|
||||||
|
|
||||||
### R1 — Governed Commit Gate
|
|
||||||
在 R0 GO 后:
|
|
||||||
- live reread authority HEAD
|
|
||||||
- 所有 preimage SHA 重新采集
|
|
||||||
- exact apply/diff
|
|
||||||
- governed writer commit
|
|
||||||
- authority branch/commit readback
|
|
||||||
|
|
||||||
### R2 — Staging Gate
|
|
||||||
- authenticated principal/tenant
|
|
||||||
- identity/lineage/read isolation
|
|
||||||
- auto-claim adapter
|
|
||||||
- root action real executor
|
|
||||||
- restart/response-loss/sibling race
|
|
||||||
- ANNEX A01–A23
|
|
||||||
|
|
||||||
### R3 — Production Gate
|
|
||||||
只有 original root acceptance + Program acceptance 全部证据闭环,
|
|
||||||
并完成 rollback/recovery 验证后才能生产收敛。
|
|
||||||
|
|
||||||
## 8. 回滚
|
|
||||||
|
|
||||||
- Review candidate:直接丢弃候选,无线上副作用。
|
|
||||||
- Governed commit 未发布:通过正式 successor/revert,不 reset/force push。
|
|
||||||
- Staging activation:关闭 feature/policy binding,保留 evidence。
|
|
||||||
- Production:只走既有 release Saga/rollback contract。
|
|
||||||
|
|
||||||
## 9. 明确不做
|
|
||||||
|
|
||||||
- 不把 `3c320aeb89d9ba8f0ed516abeb57252682b18983` 恢复为 main。
|
|
||||||
- 不新建任务数据库、第二 scheduler、第二 release authority。
|
|
||||||
- 不把 caller_scan/Retry Governor/SystemTruth 作为写权威。
|
|
||||||
- 不把局部测试 PASS 当 root/program 完成。
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Unified Workstream DAG & Acceptance Matrix V1
|
|
||||||
|
|
||||||
```text
|
|
||||||
U01 Baseline Reconciliation
|
|
||||||
├─ Track A Identity/Lineage existing mission
|
|
||||||
│ ├─ C01 Governed commit/readback
|
|
||||||
│ ├─ C02 Staging activation
|
|
||||||
│ ├─ C03 Root-action executor
|
|
||||||
│ ├─ C04 Legacy attach
|
|
||||||
│ ├─ C05 Live failure/replay/concurrency E2E
|
|
||||||
│ └─ C06 Production convergence
|
|
||||||
├─ Track B ANNEX-2.3 R2
|
|
||||||
│ ├─ B1 Schedulability proof
|
|
||||||
│ ├─ B2 Discovery
|
|
||||||
│ ├─ B3 Claim-time recheck
|
|
||||||
│ ├─ B4 Claim/lease/fencing
|
|
||||||
│ ├─ B5 Execution/heartbeat
|
|
||||||
│ └─ B6 Evidence/readback A01-A23
|
|
||||||
└─ Track C Platform G0
|
|
||||||
├─ WP0a Version/Baseline
|
|
||||||
├─ WP0b Git writers
|
|
||||||
├─ WP0c Authorization
|
|
||||||
├─ WP0d Canonical/Digest
|
|
||||||
├─ WP0e caller_scan
|
|
||||||
├─ WP1 Environment Profile
|
|
||||||
└─ WP2 Retry/Toil Governor
|
|
||||||
|
|
||||||
A + B + C review GO
|
|
||||||
↓
|
|
||||||
U05 Governed unified commit/readback
|
|
||||||
↓
|
|
||||||
U06 Staging integrated E2E
|
|
||||||
↓
|
|
||||||
U07/U08 Production + Recovery
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gate matrix
|
|
||||||
|
|
||||||
| Gate | Required evidence | Status in R1 |
|
|
||||||
|---|---|---|
|
|
||||||
| Baseline authority | main/runtime/prod current readback | PASS |
|
|
||||||
| Identity/Lineage candidate | frozen 27-file evidence + merge review | INHERITED/PENDING REBASE |
|
|
||||||
| Legacy reader | non-mutating authenticated read + terminal-only | LOCAL PASS |
|
|
||||||
| Planner dynamic readiness | CAS monotonic readiness, active/terminal freeze | CODED; repo-native rerun pending |
|
|
||||||
| R2 schedulability | blocker matrix + proof replay + claim recheck | LOCAL PASS |
|
|
||||||
| WP0a–d | read-only inventories | CODED; live scan pending |
|
|
||||||
| WP0e caller_scan | UNKNOWN safety/orphan rules | LOCAL PASS |
|
|
||||||
| WP1 env profile | host capability evidence | LOCAL PASS |
|
|
||||||
| WP2 retry governor | reconcile/no-op/block/execute | LOCAL PASS |
|
|
||||||
| Governed commit | writer receipt + branch readback | NOT RUN |
|
|
||||||
| Staging E2E | actual executor/readback | NOT RUN |
|
|
||||||
| Production | release acceptance/readback | NOT RUN |
|
|
||||||
|
|
||||||
## Deployment ordering
|
|
||||||
|
|
||||||
No deployment starts until all Review Gate rows required for R0 are PASS/GO.
|
|
||||||
@@ -12,11 +12,6 @@ components:
|
|||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
bearerFormat: opaque-token
|
bearerFormat: opaque-token
|
||||||
taskPrincipalAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
bearerFormat: task-principal-jwt
|
|
||||||
description: Server-side TaskPrincipal resolved by the Bridge from the authoritative admitted+claimed Task; caller-supplied tokens are never accepted.
|
|
||||||
schemas:
|
schemas:
|
||||||
PublicationState:
|
PublicationState:
|
||||||
type: string
|
type: string
|
||||||
@@ -787,47 +782,3 @@ paths:
|
|||||||
'200': {description: Safe GitOps plan}
|
'200': {description: Safe GitOps plan}
|
||||||
'401': {description: Unauthorized}
|
'401': {description: Unauthorized}
|
||||||
x-openai-isConsequential: true
|
x-openai-isConsequential: true
|
||||||
/reopenOwnerApproval:
|
|
||||||
post:
|
|
||||||
operationId: reopenOwnerApproval
|
|
||||||
summary: Reopen one expired Owner approval cycle without rebuilding release bytes
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- schema
|
|
||||||
- candidate_id
|
|
||||||
- expected_approval_fingerprint
|
|
||||||
- expected_source_commit
|
|
||||||
- expected_tree_oid
|
|
||||||
- expected_artifact_sha256
|
|
||||||
- expected_manifest_sha256
|
|
||||||
- expected_release_plan_sha256
|
|
||||||
- expected_policy_sha256
|
|
||||||
- reason
|
|
||||||
- idempotency_key
|
|
||||||
properties:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [aios.owner-approval-reopen-request.v1]
|
|
||||||
candidate_id: {type: string}
|
|
||||||
expected_approval_fingerprint: {type: string, pattern: "^[0-9a-f]{64}$"}
|
|
||||||
expected_source_commit: {type: string, pattern: "^[0-9a-f]{40}$"}
|
|
||||||
expected_tree_oid: {type: string, pattern: "^[0-9a-f]{40}$"}
|
|
||||||
expected_artifact_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
|
||||||
expected_manifest_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
|
||||||
expected_release_plan_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
|
||||||
expected_policy_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
|
||||||
reason: {type: string, minLength: 1, maxLength: 512}
|
|
||||||
idempotency_key: {type: string, minLength: 8, maxLength: 128}
|
|
||||||
responses:
|
|
||||||
"200": {description: Reopened or idempotent replay}
|
|
||||||
"403": {description: Missing release:approve capability}
|
|
||||||
"409": {description: Governed reopen gate rejected}
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# Review Gate V1
|
|
||||||
|
|
||||||
## Review disposition
|
|
||||||
|
|
||||||
This package is a **review candidate**, not a deployable release.
|
|
||||||
|
|
||||||
### Code review focus
|
|
||||||
- authority base = `beabd053961047f170e87e1110f2636dbd50f5fb`
|
|
||||||
- no direct DB mutation outside existing repository/service contracts
|
|
||||||
- no new control-plane database
|
|
||||||
- proof != authorization
|
|
||||||
- caller_scan cannot promote UNKNOWN to orphan
|
|
||||||
- retry governor cannot perform business effects
|
|
||||||
- legacy read path is non-mutating
|
|
||||||
- planner readiness is observation, not immutable identity
|
|
||||||
- existing mission/task/job IDs preserved
|
|
||||||
|
|
||||||
### Required test classes before GO
|
|
||||||
1. candidate syntax
|
|
||||||
2. new module focused unit tests
|
|
||||||
3. inherited Identity/Lineage regression suite
|
|
||||||
4. legacy core/runtime tests
|
|
||||||
5. ANNEX A01–A23 staging tests
|
|
||||||
6. root-action executor restart/response-loss tests
|
|
||||||
7. tenant isolation
|
|
||||||
8. governed Git apply/readback
|
|
||||||
|
|
||||||
### Current R1 decision rule
|
|
||||||
|
|
||||||
`ARCH_REVIEW_GO` may be issued if architecture/code review finds no blocker.
|
|
||||||
`DEPLOY_GO` is explicitly unavailable until governed commit + staging E2E pass.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Trusted Candidate Builder — CAP_SYS_PTRACE security contract
|
|
||||||
|
|
||||||
Status: formal (V2R2C2) · Applies to:
|
|
||||||
`deploy/systemd/aios-trusted-candidate-builder.service` (the live baseline unit)
|
|
||||||
|
|
||||||
## 0. Live baseline (rea truth, not nominal)
|
|
||||||
|
|
||||||
The authoritative deployed unit is the live systemd unit on the `hw` control
|
|
||||||
plane host:
|
|
||||||
|
|
||||||
- `User=root` / `Group=root` (the builder runs as root)
|
|
||||||
- socket: `aios-trusted-candidate-builder.socket`
|
|
||||||
`ListenStream=/run/aios-trusted-candidate-builder.sock`,
|
|
||||||
`SocketUser=root`, `SocketGroup=gitops`, `SocketMode=0660`
|
|
||||||
- credentials: `LoadCredentialEncrypted` for `signing_private_key` /
|
|
||||||
`builder_hmac` (under `/etc/credstore.encrypted/aios-trusted-candidate-builder/`)
|
|
||||||
and `source_admission_hmac` (under
|
|
||||||
`/etc/credstore.encrypted/aios-release-source-admission/`)
|
|
||||||
- hardening: `NoNewPrivileges`, `PrivateTmp`, `PrivateNetwork`,
|
|
||||||
`ProtectSystem=strict`, `ProtectHome`, `ProtectControlGroups`,
|
|
||||||
`ProtectKernelTunables/Modules/Logs`, `RestrictAddressFamilies=AF_UNIX`,
|
|
||||||
`LockPersonality`, `MemoryDenyWriteExecute`, `RestrictSUIDSGID`,
|
|
||||||
`SystemCallArchitectures=native`
|
|
||||||
- capability: `AmbientCapabilities=CAP_SYS_PTRACE` +
|
|
||||||
`CapabilityBoundingSet=CAP_SYS_PTRACE` (live current state)
|
|
||||||
|
|
||||||
The tracked `deploy/systemd/aios-trusted-candidate-builder.service` is kept
|
|
||||||
byte-identical (LF-normalized) to the live unit so sha256 drift is detectable:
|
|
||||||
live sha256 = `9d194c23ec981528d29d55f601d1be858635ac6229c7e25c10736df4e44042d3`.
|
|
||||||
|
|
||||||
> This contract deliberately does NOT introduce a separate non-root
|
|
||||||
> `-ptrace` unit. §2 of the V2R2C2 instruction forbids inventing a distinct
|
|
||||||
> service uid/group, `ProtectProc`/`ProcSubset`, credential paths or socket
|
|
||||||
> ownership that diverge from the live baseline. The live baseline is root +
|
|
||||||
> the single minimal capability, and that is the only variant tracked.
|
|
||||||
|
|
||||||
## 1. Why a capability is declared at all
|
|
||||||
|
|
||||||
`TrustedCandidateBuilder._verify_peer` validates the connecting peer before
|
|
||||||
signing any candidate. It reads three `/proc/<pid>` views of the peer:
|
|
||||||
|
|
||||||
| view | function | failure → error |
|
|
||||||
|---|---|---|
|
|
||||||
| `/proc/<pid>/cgroup` | `_resolve_cgroups` | `PEER_CGROUP_UNAVAILABLE` |
|
|
||||||
| `/proc/<pid>/exe` | `_resolve_process_identity` | `PEER_PROCESS_UNAVAILABLE` |
|
|
||||||
| `/proc/<pid>/cmdline` | `_resolve_process_identity` | `PEER_PROCESS_UNAVAILABLE` |
|
|
||||||
|
|
||||||
A cross-uid `readlink("/proc/<pid>/exe")` for a peer of a **different uid** is
|
|
||||||
subject to the Yama `ptrace_scope` sysctl and returns `EACCES` for a process
|
|
||||||
without the minimal `CAP_SYS_PTRACE`. Result: the builder fails closed
|
|
||||||
(`PEER_PROCESS_UNAVAILABLE`), which is safe but loses availability for
|
|
||||||
legitimate cross-uid peers.
|
|
||||||
|
|
||||||
The minimal `CAP_SYS_PTRACE` restores the ability to *inspect* the peer's
|
|
||||||
`/proc` view across uids.
|
|
||||||
|
|
||||||
## 2. Capability sensitivity — MANDATORY reading
|
|
||||||
|
|
||||||
`CAP_SYS_PTRACE` is a **high-sensitivity capability**. It is not a read-only
|
|
||||||
helper: it broadens the process's observation and debugging authority across
|
|
||||||
uids, including `ptrace`, reading `/proc/<pid>/mem`, and inspecting another
|
|
||||||
user's process state. It is therefore:
|
|
||||||
|
|
||||||
- limited to the **single capability** `CAP_SYS_PTRACE` (both
|
|
||||||
`AmbientCapabilities` and `CapabilityBoundingSet`), and
|
|
||||||
- scoped to the dedicated builder service unit only.
|
|
||||||
|
|
||||||
It **must never** be used to imply "read-only" or "no control authority." It
|
|
||||||
carries real inspection/debug power and is declared for one concrete purpose:
|
|
||||||
restoring cross-uid `/proc/<pid>/exe` identity resolution for the builder's
|
|
||||||
peer validation. Nothing more.
|
|
||||||
|
|
||||||
### Three-state verification contract
|
|
||||||
|
|
||||||
- **A. No CAP_SYS_PTRACE (fail-closed):** cross-uid `/proc/<pid>/exe` raises
|
|
||||||
`PermissionError(EACCES)` → resolvers fail closed →
|
|
||||||
`PEER_PROCESS_UNAVAILABLE` (and `PEER_CGROUP_UNAVAILABLE` for cgroup).
|
|
||||||
Never degrade to allow.
|
|
||||||
- **B. Minimal CAP_SYS_PTRACE + legitimate peer:** peer uid in
|
|
||||||
`allowed_peer_uid`, cgroup in `allowed_peer_cgroups`, executable matches
|
|
||||||
`allowed_peer_executable_prefix`, argv matches the runtime argument
|
|
||||||
contract → readiness `READY`.
|
|
||||||
- **C. CAP present but peer illegitimate:** any identity check fails →
|
|
||||||
`PEER_IDENTITY_DENIED` / `PEER_CGROUP_DENIED` / `PEER_PROCESS_DENIED`.
|
|
||||||
The capability **must never bypass the identity policy** — it restores the
|
|
||||||
ability to perform identity validation, not the authorization itself.
|
|
||||||
|
|
||||||
## 3. Operational rules
|
|
||||||
|
|
||||||
- Keep `CapabilityBoundingSet` and `AmbientCapabilities` pinned to exactly
|
|
||||||
`CAP_SYS_PTRACE`. Never use capability wildcards (e.g. `CAP_*` applying to
|
|
||||||
every capability).
|
|
||||||
- Keep `NoNewPrivileges=true`; ambient capabilities are the only inheritance
|
|
||||||
path into the exec'd Python process.
|
|
||||||
- Do not re-introduce a separate non-root unit with a different
|
|
||||||
uid/group/`ProtectProc`/`ProcSubset`/credential paths/socket ownership
|
|
||||||
(see §0). The root + single-capability unit is the live baseline.
|
|
||||||
- Runtime verification (three-state) MUST be executed on the real Linux host
|
|
||||||
that runs systemd; the adversarial states require real uids/cgroups/argv.
|
|
||||||
|
|
||||||
## 4. Testing
|
|
||||||
|
|
||||||
- Local (no `/proc` peer needed): the resolvers are exercised with
|
|
||||||
monkeypatched `PermissionError(EACCES)` — mirroring the exact bytes a
|
|
||||||
missing `CAP_SYS_PTRACE` / cross-uid read produces — covering
|
|
||||||
`cgroup → PEER_CGROUP_UNAVAILABLE`, `exe → PEER_PROCESS_UNAVAILABLE`,
|
|
||||||
`cmdline → PEER_PROCESS_UNAVAILABLE` (see
|
|
||||||
`tests/test_trusted_candidate_builder_captrace.py`).
|
|
||||||
- Host (Linux, required for final acceptance): run `systemd-analyze verify`
|
|
||||||
on the tracked unit; confirm the unit matches the live sha256; launch
|
|
||||||
with/without the capability; confirm A → `PEER_PROCESS_UNAVAILABLE`,
|
|
||||||
B → `READY`, C → `*_DENIED`.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# TaskPlane task identity and lineage
|
|
||||||
|
|
||||||
TaskPlane can bind admitted tasks to an authenticated owner principal and mission, assign stable root/parent lineage, and persist a root review after a child reaches a proof-bearing terminal state. The integration is opt-in through `TaskPlaneService(..., lineage_policy=...)`; existing services without a lineage policy retain their existing behavior.
|
|
||||||
|
|
||||||
## Authority boundary
|
|
||||||
|
|
||||||
`PrincipalLineageBinding` is service configuration. It maps the identity returned by the HTTP authenticator to `owner_principal_id`, `mission_id`, a safe display alias, and a registered root-acceptance policy reference. HTTP admission overwrites `issuer` with that authenticated identity. Direct in-process callers must pass an identity already verified by their boundary through `actor_identity`.
|
|
||||||
|
|
||||||
Client task metadata is never an identity or terminal-proof source. `aios_task_lineage`, the derived display name, and the lineage request digest are server-owned and rejected on new lineage admission. Similar-looking client metadata such as `owner_principal_id`, `mission_id`, or `terminal_evidence_proven` remains ordinary opaque metadata and cannot override the dedicated TaskPlane authority columns or the validated terminal-proof tables.
|
|
||||||
|
|
||||||
Root and child creation use the existing TaskPlane SQLite connection and transaction fence. The store adds authority columns and lineage sequence, terminal proof, root review, and root action tables to that database. It does not create a second TaskPlane database. The original task ID, claim ID, idempotency key, budget transaction, event journal, and revision CAS remain authoritative.
|
|
||||||
|
|
||||||
## Admission and delegation
|
|
||||||
|
|
||||||
A root admission requires:
|
|
||||||
|
|
||||||
- an authenticated identity with both TaskPlane capability and a `PrincipalLineageBinding`;
|
|
||||||
- a registered acceptance evaluator for the configured reference;
|
|
||||||
- no client-supplied reserved lineage metadata.
|
|
||||||
|
|
||||||
The request digest is computed before derived metadata is added. An identical idempotency replay returns the original task; a changed request, principal, mission, or parent conflicts. The persisted TaskPlane `created_at` is the same timestamp serialized into lineage metadata.
|
|
||||||
|
|
||||||
Task detail and list responses expose the server-bound name as `display_name`, including list responses that omit the full plan. Durable task and job identifiers remain unchanged.
|
|
||||||
|
|
||||||
Delegation currently creates only `CHILD` relations. It preserves the root owner, mission, acceptance reference, budget constraints, capability/risk monotonicity, and existing delegation transaction. The caller must resolve to the same server-owned root authority. Concurrent sibling admission uses the same SQLite write transaction to reserve unique sibling sequences.
|
|
||||||
|
|
||||||
`SUCCESSOR` and `RECOVERY` are intentionally not admitted by this binding because the current TaskPlane service does not expose a formal continuation/supersession authority contract. Adding those relations requires a separate source-of-authority design; callers must not encode them in metadata.
|
|
||||||
|
|
||||||
## Proven terminal review
|
|
||||||
|
|
||||||
The proof-bearing result path first applies the existing claim identity, lease, operation digest, evidence, result digest, event-chain, and evidence-chain checks. In the same transaction, a final child result records a terminal proof and triggers one `ROOT_REVIEW`. The review CAS advances the root revision and persists the exact trigger child and terminal revision.
|
|
||||||
|
|
||||||
The resulting next legal action is stored idempotently with status `PENDING`. `PENDING` means a durable action request exists. It does not mean a planner completed the root, created another child, recovered work, or crossed an owner boundary.
|
|
||||||
|
|
||||||
The optional `TaskPlaneService.consume_one_root_action()` consumes one persisted action through a root claim and a durable effect ledger. Mission configuration must provide an authoritative plan builder and acceptance verifier, and the service must receive a real `root_action_executor`. Missing execution wiring returns `BLOCKED`; the loopback Mission bootstrap adapter alone does not execute root actions. Plans survive process restart, and an unknown effect must be reconciled against its existing authority Job before execution can proceed. Worker metadata cannot supply that Job identity or certify a completed effect.
|
|
||||||
|
|
||||||
`PublicBridgeRootActionExecutor` is an adapter for authenticated public Bridge operations, not a deployed worker. Runtime integration must supply its trusted authentication and transport, then prove the actual Job result and repository readback. After an execution receipt, the consumer rechecks the stored original root acceptance and descendants. Root completion and child admission share the store's transaction boundary; a child result alone cannot close the root.
|
|
||||||
|
|
||||||
Direct worker completion of a lineage root is rejected. Root completion is legal only when the registered evaluator returns satisfied conditions backed by durable evidence references and the root review selects `ROOT_COMPLETE`. This integration persists that legal decision and request; it does not itself change the root task to `COMPLETED`.
|
|
||||||
|
|
||||||
After restart, `resume_terminal_review(child_task_id=...)` reads the durable terminal proof. If the trigger already has a review, it validates and returns that exact review and action receipt without running the evaluator again. This freezes the decision against evaluator drift and prevents a lost response from producing a second review. Tests close and reopen the SQLite database before treating this state as durable; visibility through the writing connection inside its transaction is only transaction-local.
|
|
||||||
|
|
||||||
The legacy `/fail` and `/cancel` paths do not construct a validated `ResultEnvelope` and therefore do not create this terminal proof or claim root-review completion. Use the proof-bearing result endpoint when terminal lineage review is required.
|
|
||||||
|
|
||||||
## Minimal service configuration
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.aios_task_lineage import AcceptanceCondition, RootAcceptanceSnapshot
|
|
||||||
from app.task_plane.api import TaskPlaneService
|
|
||||||
from app.task_plane.lineage_store import PrincipalLineageBinding, TaskLineagePolicy
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_root(root, descendants):
|
|
||||||
refs = tuple(
|
|
||||||
ref
|
|
||||||
for child in descendants
|
|
||||||
if child.terminal_evidence_proven
|
|
||||||
for ref in child.evidence_refs
|
|
||||||
)
|
|
||||||
return RootAcceptanceSnapshot(
|
|
||||||
(
|
|
||||||
AcceptanceCondition(
|
|
||||||
key="original-root-acceptance-not-yet-evaluated",
|
|
||||||
# Replace with the original mission's authoritative acceptance
|
|
||||||
# checks. A completed child alone cannot prove root completion.
|
|
||||||
satisfied=False,
|
|
||||||
evidence_refs=refs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
lineage_policy = TaskLineagePolicy(
|
|
||||||
principal_bindings=(
|
|
||||||
PrincipalLineageBinding(
|
|
||||||
authenticated_identity="spiffe://aios/owner/example",
|
|
||||||
owner_principal_id="principal-example",
|
|
||||||
mission_id="mission-example",
|
|
||||||
preferred_owner_alias="OWNER",
|
|
||||||
root_acceptance_ref="policy://root/example-v1",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
acceptance_evaluators={"policy://root/example-v1": evaluate_root},
|
|
||||||
)
|
|
||||||
service = TaskPlaneService(store, policy=policy, lineage_policy=lineage_policy)
|
|
||||||
```
|
|
||||||
|
|
||||||
An evaluator that is missing, returns no conditions, or marks a condition satisfied without evidence fails closed. It cannot be supplied by task metadata.
|
|
||||||
|
|
||||||
Existing tasks receive empty authority-column defaults during additive schema
|
|
||||||
initialization. This does not infer ownership from their metadata or attach old
|
|
||||||
jobs to a new root. Migrating an actual legacy task requires an authoritative
|
|
||||||
owner/mission binding; this change does not fabricate one or alter a Job DB.
|
|
||||||
|
|
||||||
## Canonical Mission issuance
|
|
||||||
|
|
||||||
When discovery establishes that no root exists, the optional `MissionAuthority`
|
|
||||||
can allocate the first root. `POST /missions` accepts only `goal_slug` and
|
|
||||||
`original_acceptance`, with an `Idempotency-Key`. The authenticated principal
|
|
||||||
must have a server-configured `MissionPrincipalBinding`. Mission/root IDs,
|
|
||||||
owner binding, safe alias, UTC creation time and acceptance reference are
|
|
||||||
allocated and persisted by the authority in the existing TaskPlane database,
|
|
||||||
in the same transaction as root creation. They are never request identity
|
|
||||||
fields. An exact retry returns the original identity; a changed contract with
|
|
||||||
the same principal/idempotency key conflicts. `GET /missions/{mission_id}`
|
|
||||||
requires the same owner binding and returns the persisted acceptance and root.
|
|
||||||
|
|
||||||
Acceptance JSON is data. A service-owned verifier must independently establish
|
|
||||||
its conditions; absence of a registered verifier keeps acceptance unmet.
|
|
||||||
Creating the root or attaching an arbitrary evidence string cannot satisfy it.
|
|
||||||
The Mission authority and ordinary worker execution are separate: enabling
|
|
||||||
Mission admission does not grant worker claim or result permissions.
|
|
||||||
|
|
||||||
`app.aios_task_mission_authority_server` is a narrow loopback staging adapter
|
|
||||||
for environments with the existing registered Owner signature verifier. It
|
|
||||||
accepts `POST /v1/signed-request` containing exactly `request`, `signature` and
|
|
||||||
`key_id`. The request schema is `aios.signed-mission-request.v1`, audience is
|
|
||||||
`aios.task-mission-authority.staging.v1`, and the signed fields bind method,
|
|
||||||
path, body, idempotency key, issued/expiry timestamps and nonce. The maximum
|
|
||||||
validity is 300 seconds. The existing `aios-owner-approval-v1` verifier checks
|
|
||||||
the signature, and its registered signer identity is the service actor.
|
|
||||||
Neither a bearer belonging to Shadow nor an unsigned owner name establishes
|
|
||||||
that identity. The adapter does not load private keys or sign requests.
|
|
||||||
|
|
||||||
Allowed signed operations are Mission create/read and
|
|
||||||
`POST /tasks/{authority-returned-parent}/delegate`. Mission delegation issues
|
|
||||||
child IDs on the server and uses a durable, principal-scoped request mapping
|
|
||||||
for retries. The adapter exposes no Git writer, worker claim, terminal result,
|
|
||||||
legacy recovery, production deployment or arbitrary execution route. Deployment
|
|
||||||
of this bootstrap adapter is staging evidence, not a governed commit receipt
|
|
||||||
or proof that `PENDING` root actions execute.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"baseline_mutation":false,"bootstrap_recovery_mode":false,"git_ls_tree_sha256":"58689cd365bec5795c19c2e1e03fc8ae774f768ed03a42d501b4430c95f71a8b","production_mutation":false,"schema":"aios.governed-runtime-package.v1","source_commit":"2852c2a40394195192c6c02a215ba31764e2cdda","source_file_count":965,"tag_mutation":false,"tree_oid":"ccd67c2357e7a4cd74e06be61ab81bba820d7671"}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sqlite3, sys
|
|
||||||
|
|
||||||
dbs = [
|
|
||||||
("scoped-leases", "/var/lib/aios-gitops/bridge-v2-staging/scoped-leases.sqlite3"),
|
|
||||||
("task-admission", "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3"),
|
|
||||||
]
|
|
||||||
for label, db in dbs:
|
|
||||||
print(f"=== {label}: {db} ===")
|
|
||||||
try:
|
|
||||||
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
tabs = [r[0] for r in c.execute("select name from sqlite_master where type='table'")]
|
|
||||||
print("tables:", tabs)
|
|
||||||
for t in tabs:
|
|
||||||
try:
|
|
||||||
n = c.execute(f"select count(*) from \"{t}\"").fetchone()[0]
|
|
||||||
print(f" {t}: rows={n}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" {t}: count-err {e}")
|
|
||||||
c.close()
|
|
||||||
except Exception as e:
|
|
||||||
print("ERR", e)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# scoped leases detail
|
|
||||||
db = "/var/lib/aios-gitops/bridge-v2-staging/scoped-leases.sqlite3"
|
|
||||||
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
||||||
cols = [r[1] for r in c.execute("PRAGMA table_info(scoped_leases)")]
|
|
||||||
print("scoped_leases cols:", cols)
|
|
||||||
rows = c.execute("select * from scoped_leases").fetchall()
|
|
||||||
print("scoped_leases rows:", len(rows))
|
|
||||||
for r in rows[:12]:
|
|
||||||
print(" ", dict(zip(cols, r)))
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
# task-admission envelopes status
|
|
||||||
db2 = "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3"
|
|
||||||
c2 = sqlite3.connect(f"file:{db2}?mode=ro", uri=True)
|
|
||||||
for t in ["task_admission_envelopes","task_admission_grants","task_admission_receipts"]:
|
|
||||||
cols2 = [r[1] for r in c2.execute(f"PRAGMA table_info({t})")]
|
|
||||||
print(f"\n=== {t} cols: {cols2}")
|
|
||||||
rows2 = c2.execute(f"select * from {t}").fetchall()
|
|
||||||
for r in rows2[:6]:
|
|
||||||
print(" ", dict(zip(cols2, r)))
|
|
||||||
c2.close()
|
|
||||||
@@ -24,14 +24,12 @@ if run_gitea admin user list 2>/dev/null | awk '{print $2}' | grep -Fxq "$OWNER_
|
|||||||
else
|
else
|
||||||
run_gitea admin user create --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --email "$OWNER_EMAIL" --admin --must-change-password=false >/dev/null
|
run_gitea admin user create --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --email "$OWNER_EMAIL" --admin --must-change-password=false >/dev/null
|
||||||
fi
|
fi
|
||||||
python3 - "$DB" "$OWNER_USERNAME" <<'PY'
|
python3 - <<PY
|
||||||
import sqlite3, sys, time
|
import sqlite3, time
|
||||||
db = sys.argv[1]
|
con=sqlite3.connect('$DB')
|
||||||
username = sys.argv[2]
|
cur=con.cursor()
|
||||||
con = sqlite3.connect(db)
|
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||||
cur = con.cursor()
|
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), username))
|
|
||||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), username))
|
|
||||||
con.commit()
|
con.commit()
|
||||||
PY
|
PY
|
||||||
printf 'GITEA_OWNER_SYNC_OK user=%s default_local_login_disabled=1\n' "$OWNER_USERNAME"
|
printf 'GITEA_OWNER_SYNC_OK user=%s default_local_login_disabled=1\n' "$OWNER_USERNAME"
|
||||||
|
|||||||
@@ -219,81 +219,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<!-- SEC-P1 WEB CONTROL SURFACE hardening: CSRF token propagation + server-authoritative input pre-validation.
|
|
||||||
Runs before app.js so every state-change fetch issued by the control plane carries the CSRF token,
|
|
||||||
and invalid branch/worktree identifiers are blocked client-side (server remains authoritative). -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Double-submit cookie pattern: server sets a signed `aios_csrf` cookie; the client echoes its
|
|
||||||
// value in X-CSRF-Token on every state-changing (non-safe) request. A server-issued
|
|
||||||
// <meta name="csrf-token"> takes precedence when present.
|
|
||||||
function csrfToken() {
|
|
||||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (meta && meta.content) return meta.content;
|
|
||||||
var m = document.cookie.match(/(?:^|;\\s*)aios_csrf=([^;]+)/);
|
|
||||||
return m ? decodeURIComponent(m[1]) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-authoritative identifier rules for git refs / worktrees. Kept in sync with the backend
|
|
||||||
// validator so the client gives fast feedback, but the REAL enforcement happens server-side.
|
|
||||||
var ID_CHARS = /^[A-Za-z0-9._/-]+$/;
|
|
||||||
var ID_FORBIDDEN = ['..', '//', '@{', '.lock', ' ', '\\t', ';', '&', '|', '$', '(', ')', '<', '>', '"', "'", '`', '\\\\'];
|
|
||||||
|
|
||||||
function validateBranch(name) {
|
|
||||||
if (typeof name !== 'string' || !name.trim()) return 'branch name is required';
|
|
||||||
if (name.trim().length > 255) return 'branch name too long';
|
|
||||||
if (name !== name.trim()) return 'branch name must not have leading/trailing whitespace';
|
|
||||||
for (var i = 0; i < ID_FORBIDDEN.length; i++) {
|
|
||||||
if (name.indexOf(ID_FORBIDDEN[i]) !== -1) return 'branch name contains forbidden characters: ' + ID_FORBIDDEN[i];
|
|
||||||
}
|
|
||||||
if (name.indexOf('..') !== -1) return 'branch name must not contain ".."';
|
|
||||||
if (name.indexOf('@{') !== -1) return 'branch name must not contain "@{"';
|
|
||||||
if (!ID_CHARS.test(name)) return 'branch name contains invalid characters';
|
|
||||||
if (name.indexOf('/') === 0 || name[name.length - 1] === '/') return 'branch name must not start/end with "/"';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function validateWorktree(name) {
|
|
||||||
if (typeof name !== 'string' || !name.trim()) return 'worktree id is required';
|
|
||||||
var e = validateBranch(name);
|
|
||||||
return e ? 'worktree id invalid: ' + e : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateHeaders() {
|
|
||||||
return { 'X-CSRF-Token': csrfToken() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap fetch: inject CSRF header on any non-safe method (state change).
|
|
||||||
var nativeFetch = window.fetch;
|
|
||||||
window.fetch = function (url, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var method = String(opts.method || 'GET').toUpperCase();
|
|
||||||
var safe = (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
|
|
||||||
if (!safe) {
|
|
||||||
opts.headers = Object.assign({}, opts.headers);
|
|
||||||
opts.headers['X-CSRF-Token'] = csrfToken();
|
|
||||||
}
|
|
||||||
return nativeFetch.call(this, url, opts);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Client-side pre-validation for state-change forms (server re-validates authoritatively).
|
|
||||||
document.addEventListener('submit', function (e) {
|
|
||||||
var form = e.target;
|
|
||||||
if (!form || (form.id !== 'branch-form' && form.id !== 'plan-form')) return;
|
|
||||||
var fd = new FormData(form);
|
|
||||||
var branch = String(fd.get('branch') || '').trim();
|
|
||||||
var err = validateBranch(branch);
|
|
||||||
if (err) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
window.alert('Blocked: ' + err);
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
window.__gitopsSurface = { csrfToken: csrfToken, validateBranch: validateBranch, validateWorktree: validateWorktree, stateHeaders: stateHeaders };
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import sys, importlib.util
|
|
||||||
|
|
||||||
# Load the installed migration planner runtime module
|
|
||||||
app = "/opt/aios/release-migration/current/app"
|
|
||||||
sys.path.insert(0, app)
|
|
||||||
|
|
||||||
import release_migration_plan as rm
|
|
||||||
|
|
||||||
VERSION = "V2R2C2-baseline11-principal-recovery"
|
|
||||||
CANDIDATE = "rc-v2r2c2-baseline11-principal-recovery-2852c2a40394"
|
|
||||||
SOURCE = "2852c2a40394195192c6c02a215ba31764e2cdda"
|
|
||||||
|
|
||||||
resolver = rm.FileMigrationPlanResolver(
|
|
||||||
"/var/lib/aios-release-migration",
|
|
||||||
profile_root="/etc/aios-gitops/release-migration-profiles",
|
|
||||||
expected_owner_uid=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = resolver.resolve(VERSION, CANDIDATE, SOURCE)
|
|
||||||
if plan is None:
|
|
||||||
print("RESOLVE_RESULT = NONE (not required?)")
|
|
||||||
sys.exit(3)
|
|
||||||
print("RESOLVE_RESULT = MIGRATION_PLAN_READY")
|
|
||||||
d = plan.to_dict()
|
|
||||||
print("migration_id =", d.get("migration_id"))
|
|
||||||
print("migration_plan_sha =", d.get("sha256"))
|
|
||||||
print("source_commit =", d.get("source_commit"))
|
|
||||||
print("tree_oid =", d.get("tree_oid"))
|
|
||||||
# confirm the migration sub-contract binds snapshot
|
|
||||||
for sc in d.get("state_contracts", []):
|
|
||||||
print("state_contract snapshot_name =", sc.get("snapshot_name"))
|
|
||||||
print("state_contract snapshot_sha =", sc.get("snapshot_sha256"))
|
|
||||||
print("DURABLE_PREREQUISITE_STATE = SATISFIED")
|
|
||||||
sys.exit(0)
|
|
||||||
except rm.MigrationPlanRequired as e:
|
|
||||||
print("RESOLVE_RESULT =", e)
|
|
||||||
print("DURABLE_PREREQUISITE_STATE = NOT_SATISFIED")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print("RESOLVE_ERROR =", type(e).__name__, e)
|
|
||||||
sys.exit(2)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
--- a/active-runtime/app/aios_trusted_candidate_builder.py
|
|
||||||
+++ b/active-runtime/app/aios_trusted_candidate_builder.py
|
|
||||||
@@ -367,15 +367,15 @@
|
|
||||||
raise BuilderError(code) from exc
|
|
||||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
|
||||||
raise BuilderError(code)
|
|
||||||
- if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
- raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
|
||||||
- if (
|
|
||||||
- path == self._evidence_root
|
|
||||||
- and os.name != "nt"
|
|
||||||
- and self._expected_owner_uid is not None
|
|
||||||
- and metadata.st_uid != self._expected_owner_uid
|
|
||||||
- ):
|
|
||||||
- raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
|
||||||
+ if path == self._evidence_root:
|
|
||||||
+ if os.name != "nt" and metadata.st_mode & 0o022:
|
|
||||||
+ raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
|
||||||
+ if (
|
|
||||||
+ os.name != "nt"
|
|
||||||
+ and self._expected_owner_uid is not None
|
|
||||||
+ and metadata.st_uid != self._expected_owner_uid
|
|
||||||
+ ):
|
|
||||||
+ raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
|
||||||
|
|
||||||
def _digest(self, path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
--- a/tests/test_trusted_candidate_builder.py
|
|
||||||
+++ b/tests/test_trusted_candidate_builder.py
|
|
||||||
@@ -323,9 +323,11 @@
|
|
||||||
policy = load_builder_policy(POLICY_PATH)
|
|
||||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
|
||||||
evidence_root = tmp_path / "evidence"
|
|
||||||
- evidence_root.mkdir(mode=0o700)
|
|
||||||
+ evidence_root.mkdir()
|
|
||||||
+ evidence_root.chmod(0o700)
|
|
||||||
authority_git = tmp_path / "repo.git"
|
|
||||||
- authority_git.mkdir(mode=0o700)
|
|
||||||
+ authority_git.mkdir()
|
|
||||||
+ authority_git.chmod(0o2775)
|
|
||||||
|
|
||||||
source = ServerOwnedEvidenceSource(
|
|
||||||
config,
|
|
||||||
@@ -356,6 +358,29 @@
|
|
||||||
)
|
|
||||||
with pytest.raises(BuilderError, match="COLLECTOR_EVIDENCE_UNAVAILABLE"):
|
|
||||||
server.handle_request(build, connection=object())
|
|
||||||
+
|
|
||||||
+
|
|
||||||
+def test_readiness_keeps_evidence_root_permissions_strict_with_group_shared_authority(tmp_path) -> None:
|
|
||||||
+ config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
|
||||||
+ evidence_root = tmp_path / "evidence"
|
|
||||||
+ evidence_root.mkdir()
|
|
||||||
+ evidence_root.chmod(0o720)
|
|
||||||
+ authority_git = tmp_path / "repo.git"
|
|
||||||
+ authority_git.mkdir()
|
|
||||||
+ authority_git.chmod(0o2775)
|
|
||||||
+
|
|
||||||
+ source = ServerOwnedEvidenceSource(
|
|
||||||
+ config,
|
|
||||||
+ authority_git,
|
|
||||||
+ evidence_root,
|
|
||||||
+ expected_owner_uid=None,
|
|
||||||
+ git_runner=lambda *_args, **_kwargs: SimpleNamespace(
|
|
||||||
+ stdout="a" * 40 + "\n", returncode=0
|
|
||||||
+ ),
|
|
||||||
+ )
|
|
||||||
+
|
|
||||||
+ with pytest.raises(BuilderError, match="COLLECTOR_EVIDENCE_ROOT_MODE_DENIED"):
|
|
||||||
+ source.assert_ready()
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_client_applies_bounded_timeout_and_fails_closed(monkeypatch) -> None:
|
|
||||||
@@ -19,17 +19,10 @@ usage() {
|
|||||||
|
|
||||||
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
|
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
|
||||||
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
|
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
|
||||||
[[ "$TASK_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || {
|
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
|
||||||
echo "ERROR: invalid task id" >&2
|
echo "ERROR: invalid task id" >&2
|
||||||
exit 65
|
exit 65
|
||||||
}
|
}
|
||||||
# Reject path traversal: no dot-dot, no leading dot, no trailing dot.
|
|
||||||
case "$TASK_ID" in
|
|
||||||
*".."*|.*|*.)
|
|
||||||
echo "ERROR: invalid task id (path traversal rejected)" >&2
|
|
||||||
exit 65
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
|
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
|
||||||
echo "ERROR: must run as $RUNTIME_USER" >&2
|
echo "ERROR: must run as $RUNTIME_USER" >&2
|
||||||
exit 66
|
exit 66
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ ZIP_PATH="$(find /data /opt /root /home -name "$ZIP_NAME" 2>/dev/null | head -1
|
|||||||
if [ -z "$ZIP_PATH" ]; then echo "BLOCKED: missing $ZIP_NAME"; exit 10; fi
|
if [ -z "$ZIP_PATH" ]; then echo "BLOCKED: missing $ZIP_NAME"; exit 10; fi
|
||||||
cp "$ZIP_PATH" "$ROOT/imports/"
|
cp "$ZIP_PATH" "$ROOT/imports/"
|
||||||
cd "$WORKSPACE"
|
cd "$WORKSPACE"
|
||||||
# zip-slip guard: reject any entry that would escape the workspace on extract.
|
|
||||||
command -v unzip >/dev/null 2>&1 || { echo "BLOCKED: unzip unavailable"; exit 11; }
|
|
||||||
while IFS= read -r entry; do
|
|
||||||
case "$entry" in
|
|
||||||
*/../*|../*|*/..|..|/*|*\\*|*:* )
|
|
||||||
echo "BLOCKED: unsafe zip entry: $entry"
|
|
||||||
exit 11
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done < <(unzip -Z1 "$ROOT/imports/$ZIP_NAME" 2>/dev/null || true)
|
|
||||||
unzip -o "$ROOT/imports/$ZIP_NAME"
|
unzip -o "$ROOT/imports/$ZIP_NAME"
|
||||||
git init
|
git init
|
||||||
git config user.name "AIOS GitOps Bot"
|
git config user.name "AIOS GitOps Bot"
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Read-only WP0c authorization inventory."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse, json, re
|
|
||||||
|
|
||||||
TERMS={
|
|
||||||
"task_principal":re.compile(r"\bTaskPrincipal\b|\btask[_ -]?principal\b",re.I),
|
|
||||||
"owner_approval":re.compile(r"\bowner[_ -]?approval\b|\bOWNER_SIGNATURE_REQUIRED\b",re.I),
|
|
||||||
"capability":re.compile(r"\bcapabilit(?:y|ies)\b",re.I),
|
|
||||||
"signer":re.compile(r"\bsigner\b|\bsshsig\b",re.I),
|
|
||||||
"lease":re.compile(r"\b(?:ClaimLease|WriterLease|writer[_ -]?lease)\b",re.I),
|
|
||||||
"authorization":re.compile(r"\bauthori[sz]ation\b",re.I),
|
|
||||||
}
|
|
||||||
|
|
||||||
def scan(repo:Path)->dict:
|
|
||||||
repo=repo.resolve(); refs=[]
|
|
||||||
for p in sorted(repo.rglob("*")):
|
|
||||||
if not p.is_file() or p.suffix.lower() not in {".py",".json",".yaml",".yml",".md"}: continue
|
|
||||||
if any(x in p.parts for x in {".git",".venv","node_modules","__pycache__"}): continue
|
|
||||||
try: lines=p.read_text(encoding="utf-8",errors="strict").splitlines()
|
|
||||||
except (UnicodeDecodeError,OSError): continue
|
|
||||||
for i,line in enumerate(lines,1):
|
|
||||||
kinds=sorted(k for k,pat in TERMS.items() if pat.search(line))
|
|
||||||
if kinds:
|
|
||||||
refs.append({"path":p.relative_to(repo).as_posix(),"line":i,"kinds":kinds,"excerpt":line.strip()[:240]})
|
|
||||||
return {
|
|
||||||
"schema_version":"aios.authorization-inventory.v1",
|
|
||||||
"references":refs,
|
|
||||||
"reference_count":len(refs),
|
|
||||||
"classification":"EVIDENCE_ONLY_NOT_AUTHORITY",
|
|
||||||
"completeness":"PARTIAL_DISCOVERY",
|
|
||||||
"covered_scope":["lexical_authorization_references"],
|
|
||||||
"missing_scope":[
|
|
||||||
"owner_broker_chain",
|
|
||||||
"taskprincipal_admission_chain",
|
|
||||||
"claim_and_task_lease_chain",
|
|
||||||
"writerlease_chain",
|
|
||||||
"release_recovery_capability_chain",
|
|
||||||
"signer_credential_provisioning_chain",
|
|
||||||
"live_caller_and_revocation_evidence",
|
|
||||||
],
|
|
||||||
"g0_exit_eligible":False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def main()->int:
|
|
||||||
ap=argparse.ArgumentParser(); ap.add_argument("--repo",type=Path,required=True); ap.add_argument("--out",type=Path)
|
|
||||||
ns=ap.parse_args(); result=scan(ns.repo); text=json.dumps(result,indent=2,sort_keys=True)+"\n"
|
|
||||||
if ns.out: ns.out.write_text(text,encoding="utf-8")
|
|
||||||
else: print(text,end="")
|
|
||||||
return 0
|
|
||||||
if __name__=="__main__": raise SystemExit(main())
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
"""Read-only caller scanner for AIOS platform convergence.
|
|
||||||
|
|
||||||
Key safety rules:
|
|
||||||
- static source references are evidence of source callers, not runtime liveness;
|
|
||||||
- runtime liveness requires active, artifact-bound readback evidence;
|
|
||||||
- PROVEN_ORPHAN requires a complete negative-evidence chain and independent GO;
|
|
||||||
- parse/runtime uncertainty is fail-closed to UNKNOWN;
|
|
||||||
- this module never imports scanned business modules and never mutates authority.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable, Mapping, Any, Sequence
|
|
||||||
import argparse
|
|
||||||
import ast
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import keyword
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Caller:
|
|
||||||
kind: str
|
|
||||||
identity: str
|
|
||||||
evidence_ref: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScanResult:
|
|
||||||
schema_version: str
|
|
||||||
subject: dict[str, str]
|
|
||||||
callers: tuple[Caller, ...]
|
|
||||||
runtime_loaded: tuple[dict[str, str], ...]
|
|
||||||
unknown_dynamic_refs: tuple[str, ...]
|
|
||||||
test_only_callers: tuple[Caller, ...]
|
|
||||||
caller_count: int
|
|
||||||
assessment: str
|
|
||||||
coverage: dict[str, object]
|
|
||||||
evidence_refs: tuple[str, ...]
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": self.schema_version,
|
|
||||||
"subject": self.subject,
|
|
||||||
"callers": [asdict(x) for x in self.callers],
|
|
||||||
"runtime_loaded": list(self.runtime_loaded),
|
|
||||||
"unknown_dynamic_refs": list(self.unknown_dynamic_refs),
|
|
||||||
"test_only_callers": [asdict(x) for x in self.test_only_callers],
|
|
||||||
"caller_count": self.caller_count,
|
|
||||||
"assessment": self.assessment,
|
|
||||||
"coverage": self.coverage,
|
|
||||||
"evidence_refs": list(self.evidence_refs),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(path: Path) -> str:
|
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_identifier_path(parts: Sequence[str]) -> bool:
|
|
||||||
return bool(parts) and all(p.isidentifier() and not keyword.iskeyword(p) for p in parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_rel_path(value: object) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return ""
|
|
||||||
return value.replace("\\", "/").lstrip("./")
|
|
||||||
|
|
||||||
|
|
||||||
def _default_import_roots(repo: Path, profile: Mapping[str, Any]) -> tuple[Path, ...]:
|
|
||||||
roots: list[Path] = [repo]
|
|
||||||
explicit = profile.get("import_roots", ()) or ()
|
|
||||||
if isinstance(explicit, (list, tuple)):
|
|
||||||
for item in explicit:
|
|
||||||
if not isinstance(item, str) or not item.strip():
|
|
||||||
continue
|
|
||||||
path = (repo / item).resolve()
|
|
||||||
try:
|
|
||||||
path.relative_to(repo)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if path.exists():
|
|
||||||
roots.append(path)
|
|
||||||
for rel in ("active-runtime", "active-runtime/app", "src", "app"):
|
|
||||||
path = (repo / rel).resolve()
|
|
||||||
if path.exists():
|
|
||||||
roots.append(path)
|
|
||||||
unique: list[Path] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for root in roots:
|
|
||||||
key = str(root)
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
unique.append(root)
|
|
||||||
return tuple(unique)
|
|
||||||
|
|
||||||
|
|
||||||
def _module_candidates(path: Path, roots: Sequence[Path]) -> tuple[str, ...]:
|
|
||||||
out: set[str] = set()
|
|
||||||
resolved = path.resolve()
|
|
||||||
for root in roots:
|
|
||||||
try:
|
|
||||||
rel = resolved.relative_to(root.resolve())
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
parts = list(rel.with_suffix("").parts)
|
|
||||||
if parts and parts[-1] == "__init__":
|
|
||||||
parts = parts[:-1]
|
|
||||||
if _is_identifier_path(parts):
|
|
||||||
out.add(".".join(parts))
|
|
||||||
return tuple(sorted(out))
|
|
||||||
|
|
||||||
|
|
||||||
def _is_test_path(path: Path) -> bool:
|
|
||||||
parts = {p.lower() for p in path.parts}
|
|
||||||
return "tests" in parts or "test" in parts or path.name.startswith("test_")
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_python(repo: Path) -> Iterable[Path]:
|
|
||||||
for path in repo.rglob("*.py"):
|
|
||||||
if ".git" in path.parts:
|
|
||||||
continue
|
|
||||||
if path.is_file():
|
|
||||||
yield path
|
|
||||||
|
|
||||||
|
|
||||||
def _relative_imports(
|
|
||||||
*,
|
|
||||||
importer_modules: Sequence[str],
|
|
||||||
is_package_init: bool,
|
|
||||||
level: int,
|
|
||||||
module: str | None,
|
|
||||||
aliases: Sequence[str],
|
|
||||||
) -> set[str]:
|
|
||||||
resolved: set[str] = set()
|
|
||||||
for importer in importer_modules:
|
|
||||||
parts = importer.split(".") if importer else []
|
|
||||||
package = parts if is_package_init else parts[:-1]
|
|
||||||
ascend = max(level - 1, 0)
|
|
||||||
if ascend > len(package):
|
|
||||||
continue
|
|
||||||
base = package[: len(package) - ascend] if ascend else package
|
|
||||||
if module:
|
|
||||||
base = [*base, *module.split(".")]
|
|
||||||
if base:
|
|
||||||
resolved.add(".".join(base))
|
|
||||||
for alias in aliases:
|
|
||||||
if alias == "*":
|
|
||||||
continue
|
|
||||||
candidate = [*base, alias]
|
|
||||||
if candidate:
|
|
||||||
resolved.add(".".join(candidate))
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _ast_imports(path: Path, importer_modules: Sequence[str]) -> tuple[set[str], bool]:
|
|
||||||
try:
|
|
||||||
source = path.read_text(encoding="utf-8", errors="strict")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
except (SyntaxError, UnicodeError, OSError):
|
|
||||||
return set(), True
|
|
||||||
|
|
||||||
imports: set[str] = set()
|
|
||||||
dynamic = False
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, ast.Import):
|
|
||||||
imports.update(alias.name for alias in node.names)
|
|
||||||
elif isinstance(node, ast.ImportFrom):
|
|
||||||
alias_names = [alias.name for alias in node.names]
|
|
||||||
if node.level:
|
|
||||||
imports.update(
|
|
||||||
_relative_imports(
|
|
||||||
importer_modules=importer_modules,
|
|
||||||
is_package_init=path.name == "__init__.py",
|
|
||||||
level=node.level,
|
|
||||||
module=node.module,
|
|
||||||
aliases=alias_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if node.module:
|
|
||||||
imports.add(node.module)
|
|
||||||
imports.update(
|
|
||||||
f"{node.module}.{name}" for name in alias_names if name != "*"
|
|
||||||
)
|
|
||||||
elif alias_names:
|
|
||||||
# "from <unknown> import x" should not happen for absolute syntax;
|
|
||||||
# keep fail-closed if the AST is not informative.
|
|
||||||
dynamic = True
|
|
||||||
elif isinstance(node, ast.Call):
|
|
||||||
fn = node.func
|
|
||||||
name = None
|
|
||||||
if isinstance(fn, ast.Name):
|
|
||||||
name = fn.id
|
|
||||||
elif isinstance(fn, ast.Attribute) and isinstance(fn.value, ast.Name):
|
|
||||||
name = f"{fn.value.id}.{fn.attr}"
|
|
||||||
if name in {"__import__", "importlib.import_module"}:
|
|
||||||
if (
|
|
||||||
node.args
|
|
||||||
and isinstance(node.args[0], ast.Constant)
|
|
||||||
and isinstance(node.args[0].value, str)
|
|
||||||
and not node.args[0].value.startswith(".")
|
|
||||||
):
|
|
||||||
imports.add(node.args[0].value)
|
|
||||||
else:
|
|
||||||
dynamic = True
|
|
||||||
return imports, dynamic
|
|
||||||
|
|
||||||
|
|
||||||
def _match_module(imported: str, subject_modules: Sequence[str]) -> bool:
|
|
||||||
return any(
|
|
||||||
imported == subject
|
|
||||||
or imported.startswith(subject + ".")
|
|
||||||
or subject.startswith(imported + ".")
|
|
||||||
for subject in subject_modules
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _active(item: Mapping[str, Any]) -> bool:
|
|
||||||
if item.get("active") is True:
|
|
||||||
return True
|
|
||||||
state = str(item.get("state") or item.get("status") or "").upper()
|
|
||||||
return state in {"ACTIVE", "RUNNING", "LOADED", "READY"}
|
|
||||||
|
|
||||||
|
|
||||||
def _artifact_bound_match(
|
|
||||||
item: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
subject_rel: str,
|
|
||||||
subject_sha: str,
|
|
||||||
) -> bool:
|
|
||||||
if not _active(item):
|
|
||||||
return False
|
|
||||||
source_path = _normalize_rel_path(
|
|
||||||
item.get("source_path") or item.get("repo_path") or item.get("path")
|
|
||||||
)
|
|
||||||
artifact_sha = str(
|
|
||||||
item.get("source_sha256")
|
|
||||||
or item.get("artifact_sha256")
|
|
||||||
or item.get("sha256")
|
|
||||||
or ""
|
|
||||||
).lower()
|
|
||||||
return bool(source_path == subject_rel and artifact_sha == subject_sha)
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_complete(profile: Mapping[str, Any]) -> tuple[bool, dict[str, object]]:
|
|
||||||
runtime_hosts = list(profile.get("runtime_hosts", ()) or ())
|
|
||||||
observation_seconds = profile.get("observation_window_seconds")
|
|
||||||
try:
|
|
||||||
observation_seconds = float(observation_seconds)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
observation_seconds = 0.0
|
|
||||||
coverage = {
|
|
||||||
"repo_static": True,
|
|
||||||
"systemd": bool(profile.get("systemd_scanned", False)),
|
|
||||||
"runtime_hosts": runtime_hosts,
|
|
||||||
"runtime_hosts_complete": bool(profile.get("runtime_hosts_complete", False)),
|
|
||||||
"loaded_bytes": bool(profile.get("loaded_bytes_scanned", False)),
|
|
||||||
"api_routes": bool(profile.get("api_routes_scanned", False)),
|
|
||||||
"scheduler": bool(profile.get("scheduler_scanned", False)),
|
|
||||||
"observation_window_complete": bool(profile.get("observation_window_complete", False)),
|
|
||||||
"observation_window_seconds": observation_seconds,
|
|
||||||
"rollback_ref": str(profile.get("rollback_ref") or ""),
|
|
||||||
"independent_acceptance": str(profile.get("independent_acceptance") or ""),
|
|
||||||
"probe_failures": list(profile.get("probe_failures", ()) or ()),
|
|
||||||
}
|
|
||||||
complete = all(
|
|
||||||
(
|
|
||||||
coverage["systemd"],
|
|
||||||
coverage["runtime_hosts_complete"],
|
|
||||||
bool(runtime_hosts), # empty relevant-host set never proves absence
|
|
||||||
coverage["loaded_bytes"],
|
|
||||||
coverage["api_routes"],
|
|
||||||
coverage["scheduler"],
|
|
||||||
coverage["observation_window_complete"],
|
|
||||||
observation_seconds > 0,
|
|
||||||
bool(coverage["rollback_ref"]),
|
|
||||||
coverage["independent_acceptance"] == "GO",
|
|
||||||
not coverage["probe_failures"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return bool(complete), coverage
|
|
||||||
|
|
||||||
|
|
||||||
def scan(
|
|
||||||
*,
|
|
||||||
repo: Path,
|
|
||||||
subject: Path,
|
|
||||||
runtime_profile: Mapping[str, Any] | None = None,
|
|
||||||
authority_ref: str | None = None,
|
|
||||||
) -> ScanResult:
|
|
||||||
repo = repo.resolve()
|
|
||||||
profile = dict(runtime_profile or {})
|
|
||||||
subject = (repo / subject).resolve() if not subject.is_absolute() else subject.resolve()
|
|
||||||
if not subject.is_file():
|
|
||||||
raise FileNotFoundError(subject)
|
|
||||||
|
|
||||||
roots = _default_import_roots(repo, profile)
|
|
||||||
subject_modules = _module_candidates(subject, roots)
|
|
||||||
production_static: list[Caller] = []
|
|
||||||
runtime_callers: list[Caller] = []
|
|
||||||
tests: list[Caller] = []
|
|
||||||
unknown_dynamic: list[str] = []
|
|
||||||
subject_sha = _sha(subject)
|
|
||||||
subject_rel = subject.relative_to(repo).as_posix()
|
|
||||||
evidence = [f"sha256:{subject_sha}"]
|
|
||||||
|
|
||||||
for path in _iter_python(repo):
|
|
||||||
if path.resolve() == subject:
|
|
||||||
continue
|
|
||||||
importer_modules = _module_candidates(path, roots)
|
|
||||||
imports, dynamic = _ast_imports(path, importer_modules)
|
|
||||||
matched = bool(subject_modules and any(_match_module(x, subject_modules) for x in imports))
|
|
||||||
rel = path.relative_to(repo).as_posix()
|
|
||||||
if matched:
|
|
||||||
caller = Caller(
|
|
||||||
"STATIC_TEST_ONLY" if _is_test_path(path) else "STATIC_PRODUCTION",
|
|
||||||
rel,
|
|
||||||
f"repo:{rel}",
|
|
||||||
)
|
|
||||||
(tests if _is_test_path(path) else production_static).append(caller)
|
|
||||||
if dynamic:
|
|
||||||
unknown_dynamic.append(rel)
|
|
||||||
|
|
||||||
runtime_loaded: list[dict[str, str]] = []
|
|
||||||
for item in profile.get("runtime_loaded", ()) or ():
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
continue
|
|
||||||
if _artifact_bound_match(item, subject_rel=subject_rel, subject_sha=subject_sha):
|
|
||||||
loaded_path = _normalize_rel_path(
|
|
||||||
item.get("source_path") or item.get("repo_path") or item.get("path")
|
|
||||||
)
|
|
||||||
runtime_loaded.append({"path": loaded_path, "sha256": subject_sha})
|
|
||||||
runtime_callers.append(
|
|
||||||
Caller("RUNTIME_LOADED", loaded_path, str(item.get("evidence_ref") or "runtime_profile"))
|
|
||||||
)
|
|
||||||
|
|
||||||
for key, kind, identity_key in (
|
|
||||||
("systemd", "SYSTEMD", "unit"),
|
|
||||||
("api_routes", "API_ROUTE", "route"),
|
|
||||||
("scheduler", "SCHEDULER", "identity"),
|
|
||||||
):
|
|
||||||
for item in profile.get(key, ()) or ():
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
continue
|
|
||||||
if _artifact_bound_match(item, subject_rel=subject_rel, subject_sha=subject_sha):
|
|
||||||
runtime_callers.append(
|
|
||||||
Caller(
|
|
||||||
kind,
|
|
||||||
str(item.get(identity_key) or ""),
|
|
||||||
str(item.get("evidence_ref") or f"runtime_profile:{key}"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _dedup(items: Sequence[Caller]) -> list[Caller]:
|
|
||||||
unique = {(c.kind, c.identity, c.evidence_ref): c for c in items}
|
|
||||||
return sorted(unique.values(), key=lambda c: (c.kind, c.identity, c.evidence_ref))
|
|
||||||
|
|
||||||
production_static = _dedup(production_static)
|
|
||||||
runtime_callers = _dedup(runtime_callers)
|
|
||||||
tests = _dedup(tests)
|
|
||||||
production = _dedup([*production_static, *runtime_callers])
|
|
||||||
|
|
||||||
negative_complete, coverage = _probe_complete(profile)
|
|
||||||
opaque = bool(unknown_dynamic) or bool(coverage["probe_failures"])
|
|
||||||
|
|
||||||
if runtime_callers:
|
|
||||||
assessment = "LIVE"
|
|
||||||
elif production_static or opaque:
|
|
||||||
# Source caller != runtime liveness; opaque/probe failures are also unknown.
|
|
||||||
assessment = "UNKNOWN"
|
|
||||||
elif negative_complete:
|
|
||||||
assessment = "PROVEN_ORPHAN"
|
|
||||||
else:
|
|
||||||
assessment = "LIKELY_ORPHAN"
|
|
||||||
|
|
||||||
coverage["import_roots"] = [
|
|
||||||
root.relative_to(repo).as_posix() if root != repo else "."
|
|
||||||
for root in roots
|
|
||||||
]
|
|
||||||
coverage["subject_modules"] = list(subject_modules)
|
|
||||||
coverage["negative_evidence_complete"] = negative_complete
|
|
||||||
coverage["static_production_callers"] = len(production_static)
|
|
||||||
coverage["runtime_callers"] = len(runtime_callers)
|
|
||||||
|
|
||||||
return ScanResult(
|
|
||||||
schema_version="aios.caller-scan.v2",
|
|
||||||
subject={
|
|
||||||
"path": subject_rel,
|
|
||||||
"source_commit": authority_ref or "",
|
|
||||||
"sha256": subject_sha,
|
|
||||||
},
|
|
||||||
callers=tuple(production),
|
|
||||||
runtime_loaded=tuple(runtime_loaded),
|
|
||||||
unknown_dynamic_refs=tuple(sorted(set(unknown_dynamic))),
|
|
||||||
test_only_callers=tuple(tests),
|
|
||||||
caller_count=len(production),
|
|
||||||
assessment=assessment,
|
|
||||||
coverage=coverage,
|
|
||||||
evidence_refs=tuple(evidence),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--repo", type=Path, required=True)
|
|
||||||
ap.add_argument("--subject", type=Path, required=True)
|
|
||||||
ap.add_argument("--runtime-host-profile", type=Path)
|
|
||||||
ap.add_argument("--authority-ref")
|
|
||||||
ap.add_argument("--output", type=Path, required=True)
|
|
||||||
ns = ap.parse_args()
|
|
||||||
profile: dict[str, Any] = {}
|
|
||||||
if ns.runtime_host_profile:
|
|
||||||
profile = json.loads(ns.runtime_host_profile.read_text(encoding="utf-8"))
|
|
||||||
result = scan(
|
|
||||||
repo=ns.repo,
|
|
||||||
subject=ns.subject,
|
|
||||||
runtime_profile=profile,
|
|
||||||
authority_ref=ns.authority_ref,
|
|
||||||
)
|
|
||||||
ns.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ns.output.write_text(
|
|
||||||
json.dumps(result.to_dict(), ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
print(json.dumps({"assessment": result.assessment, "caller_count": result.caller_count}, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Read-only WP0d canonical/digest inventory."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse, ast, json
|
|
||||||
|
|
||||||
CANON_NAMES=("canonical","digest","sha","fingerprint","execution_digest","policy_digest")
|
|
||||||
|
|
||||||
|
|
||||||
def _functions(path:Path):
|
|
||||||
try: tree=ast.parse(path.read_text(encoding="utf-8"))
|
|
||||||
except (SyntaxError,UnicodeDecodeError,OSError): return []
|
|
||||||
out=[]
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
|
|
||||||
name=node.name.lower()
|
|
||||||
source=ast.get_source_segment(path.read_text(encoding="utf-8"),node) or ""
|
|
||||||
signals=[]
|
|
||||||
if any(k in name for k in CANON_NAMES): signals.append("NAME")
|
|
||||||
if "sort_keys=True" in source and "json.dumps" in source: signals.append("SORTED_JSON")
|
|
||||||
if "hashlib.sha256" in source or "sha256(" in source: signals.append("SHA256")
|
|
||||||
if signals:
|
|
||||||
out.append({"name":node.name,"line":node.lineno,"signals":sorted(set(signals))})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def scan(repo:Path)->dict:
|
|
||||||
repo=repo.resolve(); defs=[]
|
|
||||||
for p in sorted(repo.rglob("*.py")):
|
|
||||||
if any(x in p.parts for x in {".git",".venv","node_modules","__pycache__"}): continue
|
|
||||||
funcs=_functions(p)
|
|
||||||
for item in funcs:
|
|
||||||
defs.append({"path":p.relative_to(repo).as_posix(),**item})
|
|
||||||
groups={}
|
|
||||||
for item in defs:
|
|
||||||
key="+".join(item["signals"])
|
|
||||||
groups.setdefault(key,[]).append(f'{item["path"]}:{item["line"]}:{item["name"]}')
|
|
||||||
return {
|
|
||||||
"schema_version":"aios.canonical-digest-inventory.v1",
|
|
||||||
"definitions":defs,
|
|
||||||
"duplicate_groups":groups,
|
|
||||||
"definition_count":len(defs),
|
|
||||||
"classification":"EVIDENCE_ONLY_NOT_CANONICAL_AUTHORITY",
|
|
||||||
"completeness":"PARTIAL_DISCOVERY",
|
|
||||||
"covered_scope":["function_level_name_and_syntax_signals"],
|
|
||||||
"missing_scope":[
|
|
||||||
"producer_consumer_mapping",
|
|
||||||
"input_schema",
|
|
||||||
"bytes_rule",
|
|
||||||
"digest_algorithm_contract",
|
|
||||||
"duplicate_semantics_review",
|
|
||||||
"live_caller_evidence",
|
|
||||||
],
|
|
||||||
"g0_exit_eligible":False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main()->int:
|
|
||||||
ap=argparse.ArgumentParser(); ap.add_argument("--repo",type=Path,required=True); ap.add_argument("--out",type=Path)
|
|
||||||
ns=ap.parse_args(); result=scan(ns.repo); text=json.dumps(result,indent=2,sort_keys=True)+"\n"
|
|
||||||
if ns.out: ns.out.write_text(text,encoding="utf-8")
|
|
||||||
else: print(text,end="")
|
|
||||||
return 0
|
|
||||||
if __name__=="__main__": raise SystemExit(main())
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""ChangeEnvelope v1 generator.
|
|
||||||
|
|
||||||
The envelope records a proposed change; it neither authorizes nor executes it.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable, Mapping
|
|
||||||
import argparse, hashlib, json, time
|
|
||||||
|
|
||||||
|
|
||||||
def sha256(path:Path)->str:
|
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def build_change_envelope(
|
|
||||||
*,
|
|
||||||
change_id:str,
|
|
||||||
work_package:str,
|
|
||||||
source_commit:str,
|
|
||||||
candidate_root:Path,
|
|
||||||
target_paths:Iterable[str],
|
|
||||||
preimage_sha256:Mapping[str,str|None],
|
|
||||||
contract_versions:Mapping[str,str],
|
|
||||||
live_caller_evidence_ref:str|None,
|
|
||||||
tests:list[dict],
|
|
||||||
rollback_ref:str|None,
|
|
||||||
independent_review_ref:str|None,
|
|
||||||
)->dict:
|
|
||||||
paths=sorted(dict.fromkeys(str(p) for p in target_paths))
|
|
||||||
post={}
|
|
||||||
for rel in paths:
|
|
||||||
path=candidate_root/rel
|
|
||||||
if not path.is_file(): raise FileNotFoundError(rel)
|
|
||||||
post[rel]=sha256(path)
|
|
||||||
return {
|
|
||||||
"schema_version":"aios.change-envelope.v1",
|
|
||||||
"change_id":change_id,
|
|
||||||
"work_package":work_package,
|
|
||||||
"source_commit":source_commit,
|
|
||||||
"target_paths":paths,
|
|
||||||
"preimage_sha256":{p:preimage_sha256.get(p) for p in paths},
|
|
||||||
"postimage_sha256":post,
|
|
||||||
"contract_versions":dict(contract_versions),
|
|
||||||
"live_caller_evidence_ref":live_caller_evidence_ref,
|
|
||||||
"tests":tests,
|
|
||||||
"rollback_ref":rollback_ref,
|
|
||||||
"independent_review_ref":independent_review_ref,
|
|
||||||
"created_at_epoch":int(time.time()),
|
|
||||||
"authorization_effect":"NONE",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main()->int:
|
|
||||||
ap=argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--spec",type=Path,required=True); ap.add_argument("--candidate-root",type=Path,required=True); ap.add_argument("--out",type=Path,required=True)
|
|
||||||
ns=ap.parse_args(); spec=json.loads(ns.spec.read_text(encoding="utf-8"))
|
|
||||||
result=build_change_envelope(candidate_root=ns.candidate_root,**spec)
|
|
||||||
ns.out.write_text(json.dumps(result,indent=2,sort_keys=True)+"\n",encoding="utf-8")
|
|
||||||
return 0
|
|
||||||
if __name__=="__main__": raise SystemExit(main())
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Environment capability profile with secret-safe, read-only probes.
|
|
||||||
|
|
||||||
The profile distinguishes "tool present" from "capability proven". Missing
|
|
||||||
requirements are computed from an explicit requirement set; an empty
|
|
||||||
requirement set never implies Generation-G0 completeness.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from typing import Iterable
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EnvironmentProfile:
|
|
||||||
schema_version: str
|
|
||||||
host_id: str
|
|
||||||
os_info: dict[str, str]
|
|
||||||
shells: tuple[str, ...]
|
|
||||||
tools: dict[str, str | None]
|
|
||||||
capabilities: dict[str, bool]
|
|
||||||
credential_mechanisms: tuple[str, ...]
|
|
||||||
filesystem: dict[str, object]
|
|
||||||
requirements: tuple[str, ...]
|
|
||||||
missing_requirements: tuple[str, ...]
|
|
||||||
completeness: str
|
|
||||||
g0_exit_eligible: bool
|
|
||||||
|
|
||||||
def to_dict(self): return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _version(command: str) -> str | None:
|
|
||||||
exe = shutil.which(command)
|
|
||||||
if not exe:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
cp = subprocess.run(
|
|
||||||
[exe, "--version"], text=True, capture_output=True, timeout=3
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
first = (cp.stdout or cp.stderr).splitlines()
|
|
||||||
return first[0][:200] if first else None
|
|
||||||
|
|
||||||
|
|
||||||
def _systemd_running() -> bool:
|
|
||||||
if os.name != "posix" or not shutil.which("systemctl"):
|
|
||||||
return False
|
|
||||||
# Presence of systemctl alone is not evidence that systemd is PID1/runtime.
|
|
||||||
return Path("/run/systemd/system").is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
def collect(host_id: str, requirements: Iterable[str] = ()) -> EnvironmentProfile:
|
|
||||||
requirements = tuple(dict.fromkeys(str(x) for x in requirements if str(x)))
|
|
||||||
shells = tuple(x for x in ("bash", "sh", "pwsh", "powershell", "cmd") if shutil.which(x))
|
|
||||||
tools = {name: _version(name) for name in ("git", "python3", "python", "node")}
|
|
||||||
posix = os.name == "posix"
|
|
||||||
systemd = _systemd_running()
|
|
||||||
sudo_noninteractive = False
|
|
||||||
if posix and shutil.which("sudo"):
|
|
||||||
try:
|
|
||||||
sudo_noninteractive = (
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "-n", "true"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
timeout=2,
|
|
||||||
).returncode
|
|
||||||
== 0
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
sudo_noninteractive = False
|
|
||||||
|
|
||||||
load_credential = bool(systemd and shutil.which("systemd-creds"))
|
|
||||||
capabilities = {
|
|
||||||
"posix": posix,
|
|
||||||
"systemd": systemd,
|
|
||||||
"systemd_load_credential": load_credential,
|
|
||||||
"sudo_noninteractive": sudo_noninteractive,
|
|
||||||
"git": bool(tools["git"]),
|
|
||||||
"python": bool(tools["python3"] or tools["python"]),
|
|
||||||
"node": bool(tools["node"]),
|
|
||||||
}
|
|
||||||
credential = ("systemd.LoadCredential",) if load_credential else ()
|
|
||||||
|
|
||||||
missing = tuple(sorted(req for req in requirements if not capabilities.get(req, False)))
|
|
||||||
completeness = "REQUIREMENTS_EVALUATED" if requirements else "CAPABILITY_DISCOVERY_ONLY"
|
|
||||||
return EnvironmentProfile(
|
|
||||||
schema_version="aios.environment-profile.v2",
|
|
||||||
host_id=host_id,
|
|
||||||
os_info={"name": platform.system(), "version": platform.version()},
|
|
||||||
shells=shells,
|
|
||||||
tools=tools,
|
|
||||||
capabilities=capabilities,
|
|
||||||
credential_mechanisms=credential,
|
|
||||||
filesystem={
|
|
||||||
"case_sensitive": os.path.normcase("A") != os.path.normcase("a"),
|
|
||||||
"line_ending_default": "LF" if os.linesep == "\n" else "CRLF",
|
|
||||||
},
|
|
||||||
requirements=requirements,
|
|
||||||
missing_requirements=missing,
|
|
||||||
completeness=completeness,
|
|
||||||
g0_exit_eligible=bool(requirements and not missing),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--host-id", required=True)
|
|
||||||
ap.add_argument("--require", action="append", default=[])
|
|
||||||
ap.add_argument("--out", type=Path)
|
|
||||||
ns = ap.parse_args()
|
|
||||||
result = collect(ns.host_id, ns.require)
|
|
||||||
import json
|
|
||||||
text = json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n"
|
|
||||||
if ns.out:
|
|
||||||
ns.out.write_text(text, encoding="utf-8")
|
|
||||||
else:
|
|
||||||
print(text, end="")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Read-only WP0b Git writer inventory."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse, json, re
|
|
||||||
|
|
||||||
PATTERNS={
|
|
||||||
"git_push":re.compile(r"\bgit\s+push\b"),
|
|
||||||
"git_commit":re.compile(r"\bgit\s+commit\b"),
|
|
||||||
"git_update_ref":re.compile(r"\bgit\s+update-ref\b"),
|
|
||||||
"governed_commit":re.compile(r"\b(?:commitFilesGoverned|commit_files_governed|commit-files)\b"),
|
|
||||||
"tag_publish":re.compile(r"\b(?:requestOrCreateTag|createReleaseCandidate|tag_publish)\b"),
|
|
||||||
"ref_mutation_api":re.compile(r"\b(?:update_ref|create_commit|push_branch|receive-pack)\b"),
|
|
||||||
}
|
|
||||||
SKIP_PARTS={".git",".venv","venv","node_modules","__pycache__"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class WriterEvidence:
|
|
||||||
path:str; line:int; kind:str; excerpt:str
|
|
||||||
|
|
||||||
|
|
||||||
def scan(repo:Path)->dict:
|
|
||||||
repo=repo.resolve()
|
|
||||||
evidence=[]
|
|
||||||
for p in sorted(repo.rglob("*")):
|
|
||||||
if not p.is_file() or any(x in SKIP_PARTS for x in p.parts):
|
|
||||||
continue
|
|
||||||
if p.suffix.lower() not in {".py",".sh",".bash",".service",".json",".yaml",".yml",".md"}:
|
|
||||||
continue
|
|
||||||
try: lines=p.read_text(encoding="utf-8",errors="strict").splitlines()
|
|
||||||
except (UnicodeDecodeError,OSError): continue
|
|
||||||
for number,line in enumerate(lines,1):
|
|
||||||
for kind,pattern in PATTERNS.items():
|
|
||||||
if pattern.search(line):
|
|
||||||
evidence.append(WriterEvidence(p.relative_to(repo).as_posix(),number,kind,line.strip()[:240]))
|
|
||||||
unique={(e.path,e.line,e.kind,e.excerpt):e for e in evidence}
|
|
||||||
ordered=sorted(unique.values(),key=lambda e:(e.path,e.line,e.kind))
|
|
||||||
return {
|
|
||||||
"schema_version":"aios.git-writer-inventory.v1",
|
|
||||||
"evidence":[asdict(e) for e in ordered],
|
|
||||||
"writer_candidate_count":len(ordered),
|
|
||||||
"classification":"EVIDENCE_ONLY_NOT_AUTHORITY",
|
|
||||||
"completeness":"PARTIAL_DISCOVERY",
|
|
||||||
"covered_scope":["lexical_writer_candidates"],
|
|
||||||
"missing_scope":[
|
|
||||||
"writer_to_authorization_mapping",
|
|
||||||
"repository_and_ref_resolution",
|
|
||||||
"cas_semantics",
|
|
||||||
"receipt_contract",
|
|
||||||
"live_caller_and_runtime_identity",
|
|
||||||
],
|
|
||||||
"g0_exit_eligible":False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main()->int:
|
|
||||||
ap=argparse.ArgumentParser(); ap.add_argument("--repo",type=Path,required=True); ap.add_argument("--out",type=Path)
|
|
||||||
ns=ap.parse_args(); result=scan(ns.repo); text=json.dumps(result,indent=2,sort_keys=True)+"\n"
|
|
||||||
if ns.out: ns.out.write_text(text,encoding="utf-8")
|
|
||||||
else: print(text,end="")
|
|
||||||
return 0
|
|
||||||
if __name__=="__main__": raise SystemExit(main())
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Retry / Toil Governor V2.
|
|
||||||
|
|
||||||
Pure decision layer. It never performs the business operation.
|
|
||||||
|
|
||||||
The governor is deliberately conservative:
|
|
||||||
- unknown side effects reconcile;
|
|
||||||
- proven success is reused/suppressed, never executed again;
|
|
||||||
- unrecognized states block;
|
|
||||||
- only explicitly executable states may return EXECUTE.
|
|
||||||
|
|
||||||
Fingerprint bytes reuse the existing TaskPlane ``canonical_json`` contract.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Reuse the existing in-repo canonical JSON contract. The platform tool is
|
|
||||||
# intentionally not a second canonical implementation.
|
|
||||||
try:
|
|
||||||
from app.task_plane.models import canonical_json
|
|
||||||
except ModuleNotFoundError: # standalone in-repo CLI execution
|
|
||||||
_repo_root = Path(__file__).resolve().parents[2]
|
|
||||||
_runtime = _repo_root / "active-runtime"
|
|
||||||
if str(_runtime) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_runtime))
|
|
||||||
from app.task_plane.models import canonical_json
|
|
||||||
|
|
||||||
|
|
||||||
RETRY_FINGERPRINT_SCHEMA = "aios.retry-toil-fingerprint.v1"
|
|
||||||
|
|
||||||
UNKNOWN_STATES = frozenset(
|
|
||||||
{"UNKNOWN", "RECONCILIATION_REQUIRED", "EXECUTION_OUTCOME_UNKNOWN", "REMOTE_SYNCING"}
|
|
||||||
)
|
|
||||||
BLOCKED_STATES = frozenset({"BLOCKED", "OWNER_BOUNDARY", "NO_GO"})
|
|
||||||
SUCCESS_STATES = frozenset(
|
|
||||||
{"SUCCEEDED", "REMOTE_VERIFIED", "COMPLETED", "APPLIED", "ACCEPTED"}
|
|
||||||
)
|
|
||||||
EXECUTABLE_STATES = frozenset(
|
|
||||||
{"NOT_APPLIED", "READY", "FAILED_RETRYABLE", "RETRYABLE"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Decision(StrEnum):
|
|
||||||
EXECUTE = "EXECUTE"
|
|
||||||
RECONCILE = "RECONCILE"
|
|
||||||
SUPPRESS_NOOP = "SUPPRESS_NOOP"
|
|
||||||
BLOCK = "BLOCK"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RetryObservation:
|
|
||||||
operation_fingerprint: str
|
|
||||||
blocker_fingerprint: str
|
|
||||||
evidence_revision: str
|
|
||||||
side_effect_state: str
|
|
||||||
attempt: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RetryDecision:
|
|
||||||
decision: Decision
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(value: object) -> str:
|
|
||||||
payload = {
|
|
||||||
"schema": RETRY_FINGERPRINT_SCHEMA,
|
|
||||||
"value": value,
|
|
||||||
}
|
|
||||||
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def decide(previous: RetryObservation | None, current: RetryObservation) -> RetryDecision:
|
|
||||||
state = str(current.side_effect_state or "").upper()
|
|
||||||
|
|
||||||
if state in UNKNOWN_STATES:
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.RECONCILE,
|
|
||||||
"unknown/indeterminate side effect must reconcile before retry",
|
|
||||||
)
|
|
||||||
if state in BLOCKED_STATES:
|
|
||||||
return RetryDecision(Decision.BLOCK, "governance or dependency boundary")
|
|
||||||
if state in SUCCESS_STATES:
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.SUPPRESS_NOOP,
|
|
||||||
"current effect is already proven successful; reuse receipt/readback",
|
|
||||||
)
|
|
||||||
if state not in EXECUTABLE_STATES:
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.BLOCK,
|
|
||||||
f"unrecognized or non-executable side effect state: {state or '<empty>'}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous is not None:
|
|
||||||
prev_state = str(previous.side_effect_state or "").upper()
|
|
||||||
same_effect = previous.operation_fingerprint == current.operation_fingerprint
|
|
||||||
|
|
||||||
if same_effect and prev_state in SUCCESS_STATES:
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.RECONCILE,
|
|
||||||
"current executable observation conflicts with prior proven success",
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
same_effect
|
|
||||||
and previous.blocker_fingerprint == current.blocker_fingerprint
|
|
||||||
and previous.evidence_revision == current.evidence_revision
|
|
||||||
):
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.SUPPRESS_NOOP,
|
|
||||||
"no state/evidence change since prior attempt",
|
|
||||||
)
|
|
||||||
|
|
||||||
return RetryDecision(
|
|
||||||
Decision.EXECUTE,
|
|
||||||
"explicitly executable state with new evidence/effect context",
|
|
||||||
)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""Read-only WP0a version/baseline discovery.
|
|
||||||
|
|
||||||
This is a *partial discovery producer*, not a version authority. It compares
|
|
||||||
already-discovered Git/runtime/production OIDs and records which Generation-G0
|
|
||||||
inventory dimensions are still missing.
|
|
||||||
|
|
||||||
Generation G0 must not be declared complete from this result alone.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse, json, re, subprocess
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
|
|
||||||
REQUIRED_SCOPE = (
|
|
||||||
"git_authority_ref",
|
|
||||||
"runtime_source_identity",
|
|
||||||
"production_release_identity",
|
|
||||||
"architecture_current_projection",
|
|
||||||
"baseline_current_projection",
|
|
||||||
"VERSION_projection",
|
|
||||||
"README_projection",
|
|
||||||
"versions_directory",
|
|
||||||
"release_metadata_readers_writers_callers",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VersionInventory:
|
|
||||||
schema_version: str
|
|
||||||
authority_oid: str
|
|
||||||
runtime_oid: str
|
|
||||||
production_oid: str
|
|
||||||
runtime_descends_from_authority: bool | None
|
|
||||||
production_descends_from_authority: bool | None
|
|
||||||
authority_runtime_equal: bool
|
|
||||||
authority_production_equal: bool
|
|
||||||
conflicts: tuple[str, ...]
|
|
||||||
classification: str
|
|
||||||
completeness: str
|
|
||||||
covered_scope: tuple[str, ...]
|
|
||||||
missing_scope: tuple[str, ...]
|
|
||||||
g0_exit_eligible: bool
|
|
||||||
|
|
||||||
def to_dict(self): return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _oid(value: str, name: str) -> str:
|
|
||||||
value = str(value or "").strip().lower()
|
|
||||||
if not OID_RE.fullmatch(value):
|
|
||||||
raise ValueError(f"invalid {name} oid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _ancestor(repo: Path, left: str, right: str) -> bool | None:
|
|
||||||
cp = subprocess.run(
|
|
||||||
["git", "-C", str(repo), "merge-base", "--is-ancestor", left, right],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
if cp.returncode == 0:
|
|
||||||
return True
|
|
||||||
if cp.returncode == 1:
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def collect(*, repo: Path, authority_oid: str, runtime_oid: str, production_oid: str) -> VersionInventory:
|
|
||||||
repo = repo.resolve()
|
|
||||||
authority = _oid(authority_oid, "authority")
|
|
||||||
runtime = _oid(runtime_oid, "runtime")
|
|
||||||
production = _oid(production_oid, "production")
|
|
||||||
rd = _ancestor(repo, authority, runtime) if repo.exists() else None
|
|
||||||
pd = _ancestor(repo, authority, production) if repo.exists() else None
|
|
||||||
conflicts = []
|
|
||||||
if authority != runtime:
|
|
||||||
conflicts.append("AUTHORITY_RUNTIME_DIVERGENCE")
|
|
||||||
if authority != production:
|
|
||||||
conflicts.append("AUTHORITY_PRODUCTION_DIVERGENCE")
|
|
||||||
if rd is False:
|
|
||||||
conflicts.append("RUNTIME_NOT_DESCENDANT_OF_AUTHORITY")
|
|
||||||
if pd is False:
|
|
||||||
conflicts.append("PRODUCTION_NOT_DESCENDANT_OF_AUTHORITY")
|
|
||||||
|
|
||||||
covered = (
|
|
||||||
"git_authority_ref",
|
|
||||||
"runtime_source_identity",
|
|
||||||
"production_release_identity",
|
|
||||||
)
|
|
||||||
missing = tuple(item for item in REQUIRED_SCOPE if item not in covered)
|
|
||||||
return VersionInventory(
|
|
||||||
"aios.version-baseline-inventory.v2",
|
|
||||||
authority,
|
|
||||||
runtime,
|
|
||||||
production,
|
|
||||||
rd,
|
|
||||||
pd,
|
|
||||||
authority == runtime,
|
|
||||||
authority == production,
|
|
||||||
tuple(conflicts),
|
|
||||||
"EVIDENCE_ONLY_NOT_AUTHORITY",
|
|
||||||
"PARTIAL_DISCOVERY",
|
|
||||||
covered,
|
|
||||||
missing,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--repo", type=Path, required=True)
|
|
||||||
ap.add_argument("--authority-oid", required=True)
|
|
||||||
ap.add_argument("--runtime-oid", required=True)
|
|
||||||
ap.add_argument("--production-oid", required=True)
|
|
||||||
ap.add_argument("--out", type=Path)
|
|
||||||
ns = ap.parse_args()
|
|
||||||
result = collect(
|
|
||||||
repo=ns.repo,
|
|
||||||
authority_oid=ns.authority_oid,
|
|
||||||
runtime_oid=ns.runtime_oid,
|
|
||||||
production_oid=ns.production_oid,
|
|
||||||
)
|
|
||||||
text = json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n"
|
|
||||||
if ns.out:
|
|
||||||
ns.out.write_text(text, encoding="utf-8")
|
|
||||||
else:
|
|
||||||
print(text, end="")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user