Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d332fd8818 | ||
|
|
d957a216a6 | ||
|
|
b4b4416c93 | ||
|
|
17deb91ca7 | ||
|
|
71e736b05f | ||
|
|
5703ed7ae0 | ||
|
|
57218959b0 | ||
|
|
867d4a5173 | ||
|
|
3a02a25d79 | ||
|
|
69a51d50c7 | ||
|
|
7f12094e58 | ||
|
|
2ae50a2a3d | ||
|
|
e9426dc0b8 | ||
|
|
d31267e89c | ||
|
|
f4022d3d1e |
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical AIOS all-modified-files evidence packager.
|
||||
|
||||
The bundle contract is intentionally compatible with the historical
|
||||
aios_package_all_modified_files_v1.sh output while keeping secret scanning in
|
||||
version-controlled, tested code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Iterable
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
|
||||
# A PEM private-key finding requires the complete PEM header. Source code,
|
||||
# tests, docs, or error text containing only "BEGIN PRIVATE KEY" are not
|
||||
# credentials and must not be rejected.
|
||||
PEM_PRIVATE_KEY_RE = re.compile(
|
||||
rb"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
BEARER_RE = re.compile(
|
||||
rb"authorization\s*:\s*bearer\s+[A-Za-z0-9._~+/=-]{20,}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
KEY_VALUE_SECRET_RE = re.compile(
|
||||
rb"(?:password|passwd|secret|api[_-]?key)\s*[:=]\s*[^\s,;]{12,}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
DOCUMENT_SUFFIXES = {".md", ".rst", ".adoc", ".txt"}
|
||||
DOCUMENT_PREFIXES = (
|
||||
"README",
|
||||
"CHANGELOG",
|
||||
"RUNBOOK",
|
||||
"ADR",
|
||||
"ARCHITECTURE",
|
||||
"DEPLOYMENT",
|
||||
"TROUBLESHOOTING",
|
||||
)
|
||||
|
||||
|
||||
class PackagerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def secret_finding(data: bytes) -> str | None:
|
||||
if PEM_PRIVATE_KEY_RE.search(data):
|
||||
return "PEM_PRIVATE_KEY"
|
||||
if BEARER_RE.search(data):
|
||||
return "AUTHORIZATION_BEARER"
|
||||
if KEY_VALUE_SECRET_RE.search(data):
|
||||
return "KEY_VALUE_SECRET"
|
||||
return None
|
||||
|
||||
|
||||
def scan_secret(data: bytes, label: str) -> None:
|
||||
finding = secret_finding(data)
|
||||
if finding is not None:
|
||||
raise PackagerError(
|
||||
f"MODIFIED_FILES_SECRET_SCAN_MATCH:{finding}:{label}"
|
||||
)
|
||||
|
||||
|
||||
def git_bytes(git_dir: Path, oid: str, path: str) -> bytes:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
[
|
||||
"/usr/bin/git",
|
||||
f"--git-dir={git_dir}",
|
||||
"show",
|
||||
f"{oid}:{path}",
|
||||
],
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise PackagerError(
|
||||
"MODIFIED_FILES_GIT_SHOW_FAILED:" + path
|
||||
) from exc
|
||||
|
||||
|
||||
def git_text(git_dir: Path, *args: str) -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["/usr/bin/git", f"--git-dir={git_dir}", *args],
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise PackagerError(
|
||||
"MODIFIED_FILES_GIT_COMMAND_FAILED:" + " ".join(args)
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_lineage(git_dir: Path, base: str, candidate: str) -> None:
|
||||
if OID_RE.fullmatch(base) is None or OID_RE.fullmatch(candidate) is None:
|
||||
raise PackagerError("MODIFIED_FILES_OID_INVALID")
|
||||
for oid in (base, candidate):
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/git",
|
||||
f"--git-dir={git_dir}",
|
||||
"cat-file",
|
||||
"-e",
|
||||
f"{oid}^{{commit}}",
|
||||
],
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise PackagerError("MODIFIED_FILES_COMMIT_MISSING:" + oid) from exc
|
||||
if subprocess.run(
|
||||
[
|
||||
"/usr/bin/git",
|
||||
f"--git-dir={git_dir}",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
base,
|
||||
candidate,
|
||||
],
|
||||
check=False,
|
||||
timeout=30,
|
||||
).returncode != 0:
|
||||
raise PackagerError("MODIFIED_FILES_LINEAGE_INVALID")
|
||||
|
||||
|
||||
def safe_destination(root: Path, relative: str) -> Path:
|
||||
path = Path(relative)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise PackagerError("MODIFIED_FILE_PATH_INVALID:" + relative)
|
||||
destination = root / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
return destination
|
||||
|
||||
|
||||
def is_document_path(path: str) -> bool:
|
||||
value = Path(path)
|
||||
if path.startswith("docs/"):
|
||||
return True
|
||||
if value.suffix.lower() in DOCUMENT_SUFFIXES:
|
||||
return True
|
||||
upper = value.name.upper()
|
||||
return any(upper.startswith(prefix) for prefix in DOCUMENT_PREFIXES)
|
||||
|
||||
|
||||
def diff_entries(
|
||||
git_dir: Path,
|
||||
base: str,
|
||||
candidate: str,
|
||||
before_root: Path,
|
||||
after_root: Path,
|
||||
) -> list[dict[str, object]]:
|
||||
raw = git_text(
|
||||
git_dir,
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-C",
|
||||
base,
|
||||
candidate,
|
||||
)
|
||||
entries: list[dict[str, object]] = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
status = parts[0]
|
||||
code = status[0]
|
||||
old_path: str | None = None
|
||||
new_path: str | None = None
|
||||
if code in {"R", "C"}:
|
||||
if len(parts) != 3:
|
||||
raise PackagerError(
|
||||
"MODIFIED_FILES_DIFF_PARSE_INVALID:" + line
|
||||
)
|
||||
old_path, new_path = parts[1], parts[2]
|
||||
else:
|
||||
if len(parts) != 2:
|
||||
raise PackagerError(
|
||||
"MODIFIED_FILES_DIFF_PARSE_INVALID:" + line
|
||||
)
|
||||
new_path = parts[1] if code != "D" else None
|
||||
old_path = parts[1] if code != "A" else None
|
||||
|
||||
entry: dict[str, object] = {"status": status}
|
||||
if old_path is not None:
|
||||
entry["previous_path"] = old_path
|
||||
before = git_bytes(git_dir, base, old_path)
|
||||
scan_secret(before, "before:" + old_path)
|
||||
safe_destination(before_root, old_path).write_bytes(before)
|
||||
entry["before_sha256"] = sha256(before)
|
||||
entry["before_bytes"] = len(before)
|
||||
if new_path is not None:
|
||||
entry["path"] = new_path
|
||||
after = git_bytes(git_dir, candidate, new_path)
|
||||
scan_secret(after, "after:" + new_path)
|
||||
safe_destination(after_root, new_path).write_bytes(after)
|
||||
entry["after_sha256"] = sha256(after)
|
||||
entry["after_bytes"] = len(after)
|
||||
entry["deleted"] = code == "D"
|
||||
entry["added"] = code == "A"
|
||||
entry["renamed"] = code == "R"
|
||||
entry["copied"] = code == "C"
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def execution_entries(
|
||||
extra_root: Path | None,
|
||||
execution_root: Path,
|
||||
) -> list[dict[str, object]]:
|
||||
if extra_root is None or not extra_root.exists():
|
||||
return []
|
||||
if extra_root.is_symlink() or not extra_root.is_dir():
|
||||
raise PackagerError("EXTRA_FILES_DIR_INVALID")
|
||||
|
||||
entries: list[dict[str, object]] = []
|
||||
for path in sorted(extra_root.rglob("*")):
|
||||
if path.is_symlink():
|
||||
raise PackagerError(
|
||||
"EXTRA_FILE_SYMLINK_REJECTED:" + str(path)
|
||||
)
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(extra_root).as_posix()
|
||||
data = path.read_bytes()
|
||||
scan_secret(data, "execution:" + relative)
|
||||
safe_destination(execution_root, relative).write_bytes(data)
|
||||
entries.append(
|
||||
{
|
||||
"path": relative,
|
||||
"sha256": sha256(data),
|
||||
"bytes": len(data),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def write_checksums(bundle: Path) -> None:
|
||||
checks: list[tuple[str, str]] = []
|
||||
for path in sorted(bundle.rglob("*")):
|
||||
if path.is_file() and path.name != "checksums.sha256":
|
||||
checks.append(
|
||||
(
|
||||
sha256(path.read_bytes()),
|
||||
path.relative_to(bundle).as_posix(),
|
||||
)
|
||||
)
|
||||
(bundle / "checksums.sha256").write_text(
|
||||
"".join(f"{digest} {name}\n" for digest, name in checks),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def verify_checksums(bundle: Path) -> None:
|
||||
checksum_path = bundle / "checksums.sha256"
|
||||
for line in checksum_path.read_text(encoding="utf-8").splitlines():
|
||||
digest, relative = line.split(" ", 1)
|
||||
path = bundle / relative
|
||||
if not path.is_file() or sha256(path.read_bytes()) != digest:
|
||||
raise PackagerError(
|
||||
"MODIFIED_FILES_CHECKSUM_CLOSURE_INVALID:" + relative
|
||||
)
|
||||
|
||||
|
||||
def build_bundle(
|
||||
*,
|
||||
git_dir: Path,
|
||||
base: str,
|
||||
candidate: str,
|
||||
task_id: str,
|
||||
extra_dir: Path | None = None,
|
||||
output_root: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
if TASK_RE.fullmatch(task_id) is None:
|
||||
raise PackagerError("TASK_ID_INVALID")
|
||||
validate_lineage(git_dir, base, candidate)
|
||||
|
||||
timestamp = (now or datetime.now(timezone.utc)).strftime(
|
||||
"%Y%m%dT%H%M%SZ"
|
||||
)
|
||||
safe_task = re.sub(r"[^A-Za-z0-9._-]+", "-", task_id).strip("-")
|
||||
if output_root is None:
|
||||
output_root = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"aios-modified-files-bundle-{safe_task}-{timestamp}-",
|
||||
dir="/var/tmp",
|
||||
)
|
||||
)
|
||||
else:
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bundle = output_root / "bundle"
|
||||
before_root = bundle / "repo" / "before"
|
||||
after_root = bundle / "repo" / "after"
|
||||
execution_root = bundle / "execution"
|
||||
before_root.mkdir(parents=True)
|
||||
after_root.mkdir(parents=True)
|
||||
execution_root.mkdir(parents=True)
|
||||
|
||||
changes = diff_entries(
|
||||
git_dir,
|
||||
base,
|
||||
candidate,
|
||||
before_root,
|
||||
after_root,
|
||||
)
|
||||
execution_files = execution_entries(extra_dir, execution_root)
|
||||
|
||||
document_changes = [
|
||||
entry
|
||||
for entry in changes
|
||||
if is_document_path(
|
||||
str(entry.get("path") or entry.get("previous_path") or "")
|
||||
)
|
||||
]
|
||||
execution_documents = [
|
||||
entry
|
||||
for entry in execution_files
|
||||
if is_document_path(str(entry["path"]))
|
||||
]
|
||||
|
||||
documents_index = {
|
||||
"schema": "aios.modified-documents.index.v1",
|
||||
"task_id": task_id,
|
||||
"base_oid": base,
|
||||
"candidate_oid": candidate,
|
||||
"repository_documents": document_changes,
|
||||
"execution_documents": execution_documents,
|
||||
"repository_document_count": len(document_changes),
|
||||
"execution_document_count": len(execution_documents),
|
||||
}
|
||||
(bundle / "documents-index.json").write_text(
|
||||
json.dumps(documents_index, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schema": "aios.all-modified-files.bundle.v1",
|
||||
"task_id": task_id,
|
||||
"base_oid": base,
|
||||
"candidate_oid": candidate,
|
||||
"generated_at": (
|
||||
now or datetime.now(timezone.utc)
|
||||
).isoformat(),
|
||||
"changed_file_count": len(changes),
|
||||
"execution_file_count": len(execution_files),
|
||||
"repository_document_count": len(document_changes),
|
||||
"execution_document_count": len(execution_documents),
|
||||
"changes": changes,
|
||||
"execution_files": execution_files,
|
||||
}
|
||||
(bundle / "all-modified-files-manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_checksums(bundle)
|
||||
verify_checksums(bundle)
|
||||
|
||||
zip_name = (
|
||||
f"AIOS_{task_id}_ALL_MODIFIED_FILES_{candidate[:12]}.zip"
|
||||
.replace("/", "-")
|
||||
)
|
||||
zip_path = output_root / zip_name
|
||||
with zipfile.ZipFile(
|
||||
zip_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
for path in sorted(bundle.rglob("*")):
|
||||
if path.is_symlink():
|
||||
raise PackagerError(
|
||||
"BUNDLE_SYMLINK_REJECTED:" + str(path)
|
||||
)
|
||||
if path.is_file():
|
||||
archive.write(
|
||||
path,
|
||||
arcname=path.relative_to(bundle).as_posix(),
|
||||
)
|
||||
|
||||
zip_sha = sha256(zip_path.read_bytes())
|
||||
summary = {
|
||||
"schema": "aios.all-modified-files.bundle-summary.v1",
|
||||
"task_id": task_id,
|
||||
"base_oid": base,
|
||||
"candidate_oid": candidate,
|
||||
"bundle_path": str(zip_path),
|
||||
"bundle_sha256": zip_sha,
|
||||
"changed_file_count": len(changes),
|
||||
"execution_file_count": len(execution_files),
|
||||
"repository_document_count": len(document_changes),
|
||||
"execution_document_count": len(execution_documents),
|
||||
}
|
||||
(output_root / "ALL_MODIFIED_FILES_BUNDLE.json").write_text(
|
||||
json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base")
|
||||
parser.add_argument("candidate")
|
||||
parser.add_argument("task_id")
|
||||
parser.add_argument("extra_dir", nargs="?")
|
||||
parser.add_argument(
|
||||
"--authority-git-dir",
|
||||
default=os.environ.get(
|
||||
"AIOS_AUTHORITY_GIT_DIR",
|
||||
"/data/aios-gitops/repo.git",
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output-root")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
summary = build_bundle(
|
||||
git_dir=Path(args.authority_git_dir),
|
||||
base=args.base,
|
||||
candidate=args.candidate,
|
||||
task_id=args.task_id,
|
||||
extra_dir=Path(args.extra_dir) if args.extra_dir else None,
|
||||
output_root=(
|
||||
Path(args.output_root) if args.output_root else None
|
||||
),
|
||||
)
|
||||
except PackagerError as exc:
|
||||
print("FATAL:" + str(exc))
|
||||
return 20
|
||||
|
||||
print("ALL_MODIFIED_FILES_BUNDLE=PASS")
|
||||
print("ALL_MODIFIED_FILES_BUNDLE_PATH=" + str(summary["bundle_path"]))
|
||||
print("ALL_MODIFIED_FILES_BUNDLE_SHA256=" + str(summary["bundle_sha256"]))
|
||||
print("CHANGED_FILE_COUNT=" + str(summary["changed_file_count"]))
|
||||
print("EXECUTION_FILE_COUNT=" + str(summary["execution_file_count"]))
|
||||
print(
|
||||
"REPOSITORY_DOCUMENT_COUNT="
|
||||
+ str(summary["repository_document_count"])
|
||||
)
|
||||
print(
|
||||
"EXECUTION_DOCUMENT_COUNT="
|
||||
+ str(summary["execution_document_count"])
|
||||
)
|
||||
print(
|
||||
"ALL_MODIFIED_FILES_EVIDENCE="
|
||||
+ str(Path(str(summary["bundle_path"])).parent)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Unified autonomous control-plane primitives.
|
||||
|
||||
This is deliberately a library, not another daemon. Task API, workers and host
|
||||
recovery code consume the same Admission -> Invariant -> Repair semantics.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Callable, Iterable, Mapping, Sequence
|
||||
|
||||
from aios_capability_contract import (
|
||||
capability_contract,
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
normalize_worker_capabilities,
|
||||
registered_capability,
|
||||
validate_capability_contract_registry,
|
||||
)
|
||||
|
||||
|
||||
class FailureClass(str, Enum):
|
||||
CAPABILITY_CONTRACT_ERROR = "CAPABILITY_CONTRACT_ERROR"
|
||||
CREDENTIAL_PROJECTION_ERROR = "CREDENTIAL_PROJECTION_ERROR"
|
||||
DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"
|
||||
POLICY_DENIED = "POLICY_DENIED"
|
||||
RESOURCE_UNAVAILABLE = "RESOURCE_UNAVAILABLE"
|
||||
WORKER_CRASH = "WORKER_CRASH"
|
||||
LEASE_LOST = "LEASE_LOST"
|
||||
BUSINESS_PERMANENT = "BUSINESS_PERMANENT"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class RepairLevel(int, Enum):
|
||||
AUTO_LOCAL = 0
|
||||
AUTO_REPAIR_TASK = 1
|
||||
OWNER_APPROVAL = 2
|
||||
MANUAL = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InvariantResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionDecision:
|
||||
admitted: bool
|
||||
state: str
|
||||
invariants: tuple[InvariantResult, ...]
|
||||
failure_class: FailureClass | None = None
|
||||
repair_level: RepairLevel | None = None
|
||||
repair_action: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema": "aios.runtime-admission.decision.v1",
|
||||
"admitted": self.admitted,
|
||||
"state": self.state,
|
||||
"invariants": [item.as_dict() for item in self.invariants],
|
||||
"failure_class": None if self.failure_class is None else self.failure_class.value,
|
||||
"repair_level": None if self.repair_level is None else int(self.repair_level),
|
||||
"repair_action": self.repair_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairDecision:
|
||||
failure_class: FailureClass
|
||||
level: RepairLevel
|
||||
action: str
|
||||
consumes_business_attempt: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"failure_class": self.failure_class.value,
|
||||
"level": int(self.level),
|
||||
"action": self.action,
|
||||
"consumes_business_attempt": self.consumes_business_attempt,
|
||||
}
|
||||
|
||||
|
||||
def classify_failure(error: object, *, phase: str = "") -> FailureClass:
|
||||
text = (str(error) + " " + str(phase)).lower()
|
||||
if any(token in text for token in (
|
||||
"credential", "permission denied", "token registry", "secret", "unauthorized"
|
||||
)):
|
||||
return FailureClass.CREDENTIAL_PROJECTION_ERROR
|
||||
if any(token in text for token in (
|
||||
"transport", "unavailable", "timeout", "connection", "bad gateway", "http_5"
|
||||
)):
|
||||
return FailureClass.DEPENDENCY_UNAVAILABLE
|
||||
if any(token in text for token in (
|
||||
"capability", "eligible", "matcher", "contract"
|
||||
)):
|
||||
return FailureClass.CAPABILITY_CONTRACT_ERROR
|
||||
if any(token in text for token in (
|
||||
"policy", "forbidden", "approval required", "not authorized"
|
||||
)):
|
||||
return FailureClass.POLICY_DENIED
|
||||
if any(token in text for token in ("lease lost", "lease_expired", "heartbeat_failed")):
|
||||
return FailureClass.LEASE_LOST
|
||||
if any(token in text for token in ("out of memory", "disk full", "resource")):
|
||||
return FailureClass.RESOURCE_UNAVAILABLE
|
||||
if any(token in text for token in ("worker crash", "process exited", "signal")):
|
||||
return FailureClass.WORKER_CRASH
|
||||
if any(token in text for token in ("invalid release", "business permanent")):
|
||||
return FailureClass.BUSINESS_PERMANENT
|
||||
return FailureClass.UNKNOWN
|
||||
|
||||
|
||||
def repair_for_failure(failure: FailureClass) -> RepairDecision:
|
||||
table = {
|
||||
FailureClass.CAPABILITY_CONTRACT_ERROR: RepairDecision(
|
||||
failure, RepairLevel.AUTO_REPAIR_TASK,
|
||||
"create_same_lineage_capability_contract_repair", False,
|
||||
),
|
||||
FailureClass.CREDENTIAL_PROJECTION_ERROR: RepairDecision(
|
||||
failure, RepairLevel.AUTO_REPAIR_TASK,
|
||||
"restore_service_private_credential_projection", False,
|
||||
),
|
||||
FailureClass.DEPENDENCY_UNAVAILABLE: RepairDecision(
|
||||
failure, RepairLevel.AUTO_LOCAL,
|
||||
"retry_dependency_health_and_resume", False,
|
||||
),
|
||||
FailureClass.RESOURCE_UNAVAILABLE: RepairDecision(
|
||||
failure, RepairLevel.AUTO_LOCAL,
|
||||
"heal_runtime_resource_and_resume", False,
|
||||
),
|
||||
FailureClass.WORKER_CRASH: RepairDecision(
|
||||
failure, RepairLevel.AUTO_LOCAL,
|
||||
"restart_worker_and_resume_same_lineage", False,
|
||||
),
|
||||
FailureClass.LEASE_LOST: RepairDecision(
|
||||
failure, RepairLevel.AUTO_LOCAL,
|
||||
"recover_lease_and_resume_same_lineage", False,
|
||||
),
|
||||
FailureClass.POLICY_DENIED: RepairDecision(
|
||||
failure, RepairLevel.OWNER_APPROVAL,
|
||||
"request_bound_owner_authorization", False,
|
||||
),
|
||||
FailureClass.BUSINESS_PERMANENT: RepairDecision(
|
||||
failure, RepairLevel.MANUAL,
|
||||
"terminal_business_failure", True,
|
||||
),
|
||||
FailureClass.UNKNOWN: RepairDecision(
|
||||
failure, RepairLevel.AUTO_REPAIR_TASK,
|
||||
"create_diagnostic_repair_task", False,
|
||||
),
|
||||
}
|
||||
return table[failure]
|
||||
|
||||
|
||||
def _probe(
|
||||
name: str,
|
||||
probes: Mapping[str, Callable[[], bool]],
|
||||
) -> InvariantResult:
|
||||
fn = probes.get(name)
|
||||
if fn is None:
|
||||
return InvariantResult(name, False, "PROBE_MISSING")
|
||||
try:
|
||||
passed = bool(fn())
|
||||
except Exception as exc:
|
||||
return InvariantResult(name, False, "PROBE_ERROR:" + type(exc).__name__)
|
||||
return InvariantResult(name, passed, "PASS" if passed else "NOT_READY")
|
||||
|
||||
|
||||
def runtime_admission(
|
||||
*,
|
||||
worker_capabilities: Iterable[object],
|
||||
required_capabilities: Iterable[object],
|
||||
requested_operations: Sequence[str] = (),
|
||||
probes: Mapping[str, Callable[[], bool]] | None = None,
|
||||
) -> AdmissionDecision:
|
||||
"""Admission is evaluated before claim; failures do not consume attempts."""
|
||||
|
||||
worker_values = tuple(worker_capabilities)
|
||||
required_values = tuple(required_capabilities)
|
||||
probe_map = probes or {}
|
||||
invariants: list[InvariantResult] = []
|
||||
|
||||
registry = validate_capability_contract_registry()
|
||||
invariants.append(InvariantResult(
|
||||
"capability_contract_registry",
|
||||
bool(registry.get("valid")),
|
||||
"PASS" if registry.get("valid") else "REGISTRY_INVALID",
|
||||
))
|
||||
|
||||
task_known = all(registered_capability(item) for item in required_values)
|
||||
worker_known = all(registered_capability(item, worker=True) for item in worker_values)
|
||||
invariants.extend((
|
||||
InvariantResult("task_capabilities_registered", task_known, "PASS" if task_known else "UNKNOWN_TASK_CAPABILITY"),
|
||||
InvariantResult("worker_capabilities_registered", worker_known, "PASS" if worker_known else "UNKNOWN_WORKER_CAPABILITY"),
|
||||
))
|
||||
|
||||
capability_match = (
|
||||
task_known
|
||||
and worker_known
|
||||
and has_capabilities(worker_values, required_values)
|
||||
)
|
||||
invariants.append(InvariantResult(
|
||||
"capability_match",
|
||||
capability_match,
|
||||
"PASS" if capability_match else "WORKER_NOT_ELIGIBLE",
|
||||
))
|
||||
|
||||
normalized = normalize_required_capabilities(required_values)
|
||||
contracts = [
|
||||
capability_contract(item)
|
||||
for item in normalized
|
||||
if not str(item).startswith("exact:")
|
||||
]
|
||||
contracts = [item for item in contracts if item is not None]
|
||||
|
||||
forbidden: set[str] = set()
|
||||
dependencies: set[str] = set()
|
||||
health: set[str] = set()
|
||||
for contract in contracts:
|
||||
forbidden.update(contract.forbidden)
|
||||
dependencies.update(contract.dependencies)
|
||||
health.update(contract.health_requirements)
|
||||
|
||||
requested = {str(item).strip().lower() for item in requested_operations if str(item).strip()}
|
||||
forbidden_hit = sorted(requested & {item.lower() for item in forbidden})
|
||||
invariants.append(InvariantResult(
|
||||
"forbidden_operations_absent",
|
||||
not forbidden_hit,
|
||||
"PASS" if not forbidden_hit else "FORBIDDEN:" + ",".join(forbidden_hit),
|
||||
))
|
||||
|
||||
for name in sorted(dependencies | health):
|
||||
invariants.append(_probe(name, probe_map))
|
||||
|
||||
failed = [item for item in invariants if not item.passed]
|
||||
if not failed:
|
||||
return AdmissionDecision(
|
||||
True,
|
||||
"EXECUTION_READY",
|
||||
tuple(invariants),
|
||||
)
|
||||
|
||||
details = " ".join(item.name + ":" + item.detail for item in failed)
|
||||
failure = classify_failure(details, phase="admission")
|
||||
if any(item.name == "forbidden_operations_absent" and not item.passed for item in failed):
|
||||
failure = FailureClass.POLICY_DENIED
|
||||
elif any("credential" in item.name for item in failed):
|
||||
failure = FailureClass.CREDENTIAL_PROJECTION_ERROR
|
||||
elif any(item.name in {"capability_contract_registry", "task_capabilities_registered", "worker_capabilities_registered", "capability_match"} for item in failed):
|
||||
failure = FailureClass.CAPABILITY_CONTRACT_ERROR
|
||||
elif any(item.name in dependencies or item.name in health for item in failed):
|
||||
failure = FailureClass.DEPENDENCY_UNAVAILABLE
|
||||
|
||||
repair = repair_for_failure(failure)
|
||||
return AdmissionDecision(
|
||||
False,
|
||||
"EXECUTION_READY_FAILED",
|
||||
tuple(invariants),
|
||||
failure,
|
||||
repair.level,
|
||||
repair.action,
|
||||
)
|
||||
|
||||
|
||||
def system_invariant_snapshot(
|
||||
*,
|
||||
worker_capabilities: Iterable[object],
|
||||
required_capabilities: Iterable[object],
|
||||
requested_operations: Sequence[str] = (),
|
||||
probes: Mapping[str, Callable[[], bool]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
decision = runtime_admission(
|
||||
worker_capabilities=worker_capabilities,
|
||||
required_capabilities=required_capabilities,
|
||||
requested_operations=requested_operations,
|
||||
probes=probes,
|
||||
)
|
||||
return {
|
||||
"schema": "aios.system-invariants.v1",
|
||||
"status": "HEALTHY" if decision.admitted else "DEGRADED",
|
||||
"execution_ready": decision.admitted,
|
||||
"admission": decision.as_dict(),
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
@@ -37,6 +39,7 @@ _TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
}
|
||||
|
||||
_WORKER_ALIASES: dict[str, frozenset[str]] = {
|
||||
@@ -71,8 +74,172 @@ _WORKER_ALIASES: dict[str, frozenset[str]] = {
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
"agent.runtime.hermes": frozenset({"agent.runtime.hermes"}),
|
||||
"agent.runtime.openclaw": frozenset({"agent.runtime.openclaw"}),
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityContract:
|
||||
"""Versioned runtime contract attached to one canonical semantic capability."""
|
||||
|
||||
name: str
|
||||
min_version: int = 1
|
||||
max_version: int = 1
|
||||
permissions: tuple[str, ...] = ()
|
||||
forbidden: tuple[str, ...] = ()
|
||||
dependencies: tuple[str, ...] = ()
|
||||
health_requirements: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# This registry is the semantic source of truth. Alias tables below are retained
|
||||
# as compatibility adapters; startup/tests validate every alias target against
|
||||
# this registry so a name cannot be accepted in one layer and be unknown in
|
||||
# another.
|
||||
_CAPABILITY_CONTRACTS: dict[str, CapabilityContract] = {
|
||||
"repo.read": CapabilityContract("repo.read", permissions=("repository_read",)),
|
||||
"test.execute": CapabilityContract("test.execute", permissions=("test_execution",)),
|
||||
"governed.git.write.branch": CapabilityContract(
|
||||
"governed.git.write.branch",
|
||||
permissions=("governed_branch_write",),
|
||||
dependencies=("writer_lease", "governed_gitops"),
|
||||
),
|
||||
"governed.git.write.file": CapabilityContract(
|
||||
"governed.git.write.file",
|
||||
permissions=("governed_file_write",),
|
||||
dependencies=("writer_lease", "governed_gitops"),
|
||||
),
|
||||
"governed.git.write": CapabilityContract(
|
||||
"governed.git.write",
|
||||
permissions=("governed_git_write",),
|
||||
dependencies=("writer_lease", "governed_gitops"),
|
||||
),
|
||||
"runtime.shell": CapabilityContract("runtime.shell", permissions=("runtime_shell",)),
|
||||
"runtime.git": CapabilityContract("runtime.git", permissions=("runtime_git",)),
|
||||
"runtime.python": CapabilityContract("runtime.python", permissions=("runtime_python",)),
|
||||
"legacy.code.read": CapabilityContract("legacy.code.read"),
|
||||
"legacy.code.review": CapabilityContract("legacy.code.review"),
|
||||
"legacy.code.write": CapabilityContract("legacy.code.write"),
|
||||
"legacy.code.write.patch": CapabilityContract("legacy.code.write.patch"),
|
||||
"legacy.test.python": CapabilityContract("legacy.test.python"),
|
||||
"legacy.test.shell.allowlisted": CapabilityContract("legacy.test.shell.allowlisted"),
|
||||
"legacy.git.commit": CapabilityContract("legacy.git.commit"),
|
||||
"legacy.git.diff.read": CapabilityContract("legacy.git.diff.read"),
|
||||
"legacy.git.force_push": CapabilityContract(
|
||||
"legacy.git.force_push",
|
||||
forbidden=("protected_ref_bypass",),
|
||||
),
|
||||
"legacy.deploy.production": CapabilityContract(
|
||||
"legacy.deploy.production",
|
||||
permissions=("production_deploy",),
|
||||
dependencies=("owner_approval", "deployment_controller"),
|
||||
health_requirements=("approval_envelope_verified", "runtime_acceptance_available"),
|
||||
),
|
||||
"legacy.secret.transform": CapabilityContract("legacy.secret.transform"),
|
||||
"legacy.secure_file_transformer": CapabilityContract("legacy.secure_file_transformer"),
|
||||
"legacy.runtime.shadow": CapabilityContract("legacy.runtime.shadow"),
|
||||
"legacy.review.security": CapabilityContract("legacy.review.security"),
|
||||
"legacy.review.api-contract": CapabilityContract("legacy.review.api-contract"),
|
||||
"legacy.gpu.train": CapabilityContract("legacy.gpu.train"),
|
||||
"agent.runtime.hermes": CapabilityContract(
|
||||
"agent.runtime.hermes",
|
||||
dependencies=("hermes_runtime",),
|
||||
health_requirements=("worker_liveness",),
|
||||
),
|
||||
"agent.runtime.openclaw": CapabilityContract(
|
||||
"agent.runtime.openclaw",
|
||||
dependencies=("openclaw_runtime",),
|
||||
health_requirements=("worker_liveness",),
|
||||
),
|
||||
"release:candidate": CapabilityContract(
|
||||
"release:candidate",
|
||||
min_version=1,
|
||||
max_version=1,
|
||||
permissions=("prepare_release",),
|
||||
forbidden=(
|
||||
"production_deploy",
|
||||
"submit_owner_approval",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize",
|
||||
"runtime_switch",
|
||||
),
|
||||
dependencies=(
|
||||
"credential_projection",
|
||||
"trusted_builder",
|
||||
),
|
||||
health_requirements=(
|
||||
"authenticated_probe",
|
||||
"release_api_health",
|
||||
"worker_liveness",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def capability_contract(name: object) -> CapabilityContract | None:
|
||||
"""Return the contract for one canonical or registered capability name."""
|
||||
|
||||
text = _clean(name)
|
||||
direct = _CAPABILITY_CONTRACTS.get(text)
|
||||
if direct is not None:
|
||||
return direct
|
||||
mapped = _TASK_ALIASES.get(text) or _WORKER_ALIASES.get(text)
|
||||
if mapped is None or len(mapped) != 1:
|
||||
return None
|
||||
return _CAPABILITY_CONTRACTS.get(next(iter(mapped)))
|
||||
|
||||
|
||||
def registered_capability(name: object, *, worker: bool = False) -> bool:
|
||||
"""Fail-closed validation used by registration/task admission."""
|
||||
|
||||
text = _clean(name)
|
||||
if "*" in text:
|
||||
return False
|
||||
aliases = _WORKER_ALIASES if worker else _TASK_ALIASES
|
||||
mapped = aliases.get(text)
|
||||
if mapped is None:
|
||||
return text in _CAPABILITY_CONTRACTS
|
||||
return all(item in _CAPABILITY_CONTRACTS for item in mapped)
|
||||
|
||||
|
||||
def validate_capability_contract_registry() -> dict[str, object]:
|
||||
"""Prove alias/contract closure and contract safety invariants."""
|
||||
|
||||
alias_targets: set[str] = set()
|
||||
for mapping in (_TASK_ALIASES, _WORKER_ALIASES):
|
||||
for values in mapping.values():
|
||||
alias_targets.update(values)
|
||||
missing = sorted(alias_targets - set(_CAPABILITY_CONTRACTS))
|
||||
invalid_versions = sorted(
|
||||
name
|
||||
for name, contract in _CAPABILITY_CONTRACTS.items()
|
||||
if contract.min_version < 1 or contract.max_version < contract.min_version
|
||||
)
|
||||
permission_forbidden_overlap = sorted(
|
||||
name
|
||||
for name, contract in _CAPABILITY_CONTRACTS.items()
|
||||
if set(contract.permissions) & set(contract.forbidden)
|
||||
)
|
||||
return {
|
||||
"schema": "aios.capability-contract-registry.v1",
|
||||
"contract_count": len(_CAPABILITY_CONTRACTS),
|
||||
"missing_alias_targets": missing,
|
||||
"invalid_versions": invalid_versions,
|
||||
"permission_forbidden_overlap": permission_forbidden_overlap,
|
||||
"valid": not missing and not invalid_versions and not permission_forbidden_overlap,
|
||||
}
|
||||
|
||||
|
||||
def capability_contract_snapshot() -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
name: contract.as_dict()
|
||||
for name, contract in sorted(_CAPABILITY_CONTRACTS.items())
|
||||
}
|
||||
|
||||
|
||||
_UNKNOWN_PREFIX = "exact:"
|
||||
|
||||
|
||||
@@ -98,7 +265,11 @@ def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
if "*" in text:
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||
mapped = _TASK_ALIASES.get(text)
|
||||
if mapped is None or any(item not in _CAPABILITY_CONTRACTS for item in mapped):
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(mapped)
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
@@ -111,7 +282,7 @@ def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
if "*" in text:
|
||||
continue
|
||||
mapped = _WORKER_ALIASES.get(text)
|
||||
if mapped is not None:
|
||||
if mapped is not None and all(item in _CAPABILITY_CONTRACTS for item in mapped):
|
||||
result.update(mapped)
|
||||
if {
|
||||
"governed.git.write.branch",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
from aios_model_governance import (
|
||||
ModelGovernanceError,
|
||||
ModelGovernanceStore,
|
||||
RouteRequest,
|
||||
)
|
||||
|
||||
REQUEST_SCHEMA = "aios.external-agent-runtime.request.v1"
|
||||
RESULT_SCHEMA = "aios.external-agent-runtime.result.v1"
|
||||
@@ -62,6 +67,15 @@ MAX_PROFILE_BYTES = 64 * 1024
|
||||
MAX_CREDENTIAL_BYTES = 64 * 1024
|
||||
DEFAULT_PROFILE_ROOT = Path("/etc/aios/external-agent-runtime.d")
|
||||
DEFAULT_EXCHANGE_ROOT = Path("/var/lib/aios-external-agent-executor")
|
||||
DEFAULT_MODEL_GOVERNANCE_DB = Path(
|
||||
os.environ.get(
|
||||
"AIOS_MODEL_GOVERNANCE_DB",
|
||||
"/var/lib/aios-model-governance/state.sqlite3",
|
||||
)
|
||||
)
|
||||
MODEL_GOVERNANCE_ENABLED = os.environ.get(
|
||||
"AIOS_MODEL_GOVERNANCE_ENABLED", "0"
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
ALLOWED_EXECUTABLE_ROOTS = (
|
||||
Path("/usr/bin"),
|
||||
Path("/usr/local/bin"),
|
||||
@@ -208,6 +222,172 @@ def _profile_credential_environment(
|
||||
return tuple(sorted(result))
|
||||
|
||||
|
||||
def _profile_model_governance(
|
||||
value: object,
|
||||
) -> tuple[bool, tuple[str, ...], tuple[str, ...], int, int]:
|
||||
if value is None:
|
||||
return False, (), (), 0, 0
|
||||
if not isinstance(value, Mapping):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_GOVERNANCE_PROFILE_INVALID")
|
||||
allowed = {
|
||||
"required",
|
||||
"provider_ids",
|
||||
"model_capabilities",
|
||||
"max_output_tokens",
|
||||
"input_reservation_floor_tokens",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_GOVERNANCE_PROFILE_FIELDS_INVALID")
|
||||
required = bool(value.get("required", False))
|
||||
providers = value.get("provider_ids") or []
|
||||
capabilities = value.get("model_capabilities") or []
|
||||
if (
|
||||
not isinstance(providers, Sequence)
|
||||
or isinstance(providers, (str, bytes))
|
||||
or not all(isinstance(item, str) and item.strip() for item in providers)
|
||||
):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_PROVIDER_IDS_INVALID")
|
||||
if (
|
||||
not isinstance(capabilities, Sequence)
|
||||
or isinstance(capabilities, (str, bytes))
|
||||
or not all(isinstance(item, str) and item.strip() for item in capabilities)
|
||||
):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_CAPABILITIES_INVALID")
|
||||
max_output_tokens = int(value.get("max_output_tokens") or 0)
|
||||
input_reservation_floor_tokens = int(
|
||||
value.get("input_reservation_floor_tokens") or 0
|
||||
)
|
||||
if max_output_tokens < 0 or max_output_tokens > 262144:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_OUTPUT_LIMIT_INVALID")
|
||||
if (
|
||||
input_reservation_floor_tokens < 0
|
||||
or input_reservation_floor_tokens > 262144
|
||||
):
|
||||
raise ExternalAgentRuntimeError(
|
||||
"EXTERNAL_AGENT_MODEL_INPUT_RESERVATION_FLOOR_INVALID"
|
||||
)
|
||||
if required and not providers:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_PROVIDER_IDS_REQUIRED")
|
||||
if required and max_output_tokens < 1:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_OUTPUT_LIMIT_REQUIRED")
|
||||
return (
|
||||
required,
|
||||
tuple(sorted({item.strip() for item in providers})),
|
||||
tuple(sorted({item.strip() for item in capabilities})),
|
||||
max_output_tokens,
|
||||
input_reservation_floor_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
# Conservative byte-based estimate; runtime-reported usage supersedes this at settlement.
|
||||
data = text.encode("utf-8", "replace")
|
||||
return max(1, (len(data) + 3) // 4)
|
||||
|
||||
|
||||
def _extract_runtime_usage(
|
||||
output_json: object,
|
||||
) -> tuple[int | None, int | None, int | None]:
|
||||
if not isinstance(output_json, Mapping):
|
||||
return None, None, None
|
||||
|
||||
candidates: list[Mapping[str, Any]] = []
|
||||
direct = output_json.get("usage")
|
||||
if isinstance(direct, Mapping):
|
||||
candidates.append(direct)
|
||||
|
||||
meta = output_json.get("meta")
|
||||
if isinstance(meta, Mapping):
|
||||
agent_meta = meta.get("agentMeta")
|
||||
if isinstance(agent_meta, Mapping):
|
||||
nested = agent_meta.get("usage")
|
||||
if isinstance(nested, Mapping):
|
||||
candidates.append(nested)
|
||||
last_call = agent_meta.get("lastCallUsage")
|
||||
if isinstance(last_call, Mapping):
|
||||
candidates.append(last_call)
|
||||
|
||||
def number(mapping: Mapping[str, Any], *names: str) -> int | None:
|
||||
for name in names:
|
||||
value = mapping.get(name)
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
||||
return value
|
||||
return None
|
||||
|
||||
input_tokens = None
|
||||
output_tokens = None
|
||||
cost_micros = None
|
||||
for usage in candidates:
|
||||
if input_tokens is None:
|
||||
input_tokens = number(usage, "input_tokens", "prompt_tokens", "input")
|
||||
if output_tokens is None:
|
||||
output_tokens = number(
|
||||
usage, "output_tokens", "completion_tokens", "output"
|
||||
)
|
||||
if cost_micros is None:
|
||||
cost_micros = number(usage, "cost_micros", "total_cost_micros")
|
||||
|
||||
if cost_micros is None and isinstance(meta, Mapping):
|
||||
agent_meta = meta.get("agentMeta")
|
||||
if isinstance(agent_meta, Mapping):
|
||||
cost_usd = agent_meta.get("costUsd")
|
||||
if (
|
||||
isinstance(cost_usd, (int, float))
|
||||
and not isinstance(cost_usd, bool)
|
||||
and cost_usd >= 0
|
||||
):
|
||||
cost_micros = int(round(float(cost_usd) * 1_000_000))
|
||||
|
||||
return input_tokens, output_tokens, cost_micros
|
||||
|
||||
|
||||
def _scope_from_payload(payload: Mapping[str, Any]) -> tuple[str, str | None, str | None, str]:
|
||||
scope = payload.get("scope")
|
||||
if not isinstance(scope, Mapping):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_SCOPE_MISSING")
|
||||
scope_type = str(scope.get("scope_type") or "legacy")
|
||||
tenant_id = scope.get("tenant_id")
|
||||
project_id = scope.get("project_id")
|
||||
trace_id = str(payload.get("trace_id") or "").strip()
|
||||
if not trace_id:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_TRACE_ID_MISSING")
|
||||
return (
|
||||
scope_type,
|
||||
str(tenant_id) if tenant_id not in (None, "") else None,
|
||||
str(project_id) if project_id not in (None, "") else None,
|
||||
trace_id,
|
||||
)
|
||||
|
||||
|
||||
def _model_replacements(decision: object | None) -> dict[str, str]:
|
||||
if decision is None:
|
||||
return {}
|
||||
return {
|
||||
"{provider_id}": str(getattr(decision, "provider_id")),
|
||||
"{model_id}": str(getattr(decision, "model_id")),
|
||||
"{model_version}": str(getattr(decision, "model_version")),
|
||||
}
|
||||
|
||||
|
||||
def _bind_model_values(
|
||||
values: Sequence[tuple[str, str]],
|
||||
decision: object | None,
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
replacements = _model_replacements(decision)
|
||||
result: list[tuple[str, str]] = []
|
||||
for key, value in values:
|
||||
bound = value
|
||||
for marker, replacement in replacements.items():
|
||||
bound = bound.replace(marker, replacement)
|
||||
result.append((key, bound))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _runtime_model_binding_present(profile: "RuntimeProfile") -> bool:
|
||||
markers = ("{model_id}", "{model_version}", "{provider_id}")
|
||||
return any(marker in item for item in (*profile.argv, *(value for _, value in profile.environment)) for marker in markers)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeProfile:
|
||||
runtime: str
|
||||
@@ -218,6 +398,11 @@ class RuntimeProfile:
|
||||
max_output_bytes: int
|
||||
environment: tuple[tuple[str, str], ...]
|
||||
credential_env: tuple[tuple[str, str, str, str], ...]
|
||||
model_governance_required: bool
|
||||
model_provider_ids: tuple[str, ...]
|
||||
model_capabilities: tuple[str, ...]
|
||||
model_max_output_tokens: int
|
||||
model_input_reservation_floor_tokens: int
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
@@ -275,7 +460,14 @@ class RuntimeProfile:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_OUTPUT_LIMIT_INVALID")
|
||||
environment = _profile_environment(value.get("environment"))
|
||||
credential_env = _profile_credential_environment(value.get("credential_env"))
|
||||
return cls(
|
||||
(
|
||||
model_governance_required,
|
||||
model_provider_ids,
|
||||
model_capabilities,
|
||||
model_max_output_tokens,
|
||||
model_input_reservation_floor_tokens,
|
||||
) = _profile_model_governance(value.get("model_governance"))
|
||||
profile = cls(
|
||||
runtime,
|
||||
tuple(normalized),
|
||||
input_mode,
|
||||
@@ -284,7 +476,15 @@ class RuntimeProfile:
|
||||
max_output,
|
||||
environment,
|
||||
credential_env,
|
||||
model_governance_required,
|
||||
model_provider_ids,
|
||||
model_capabilities,
|
||||
model_max_output_tokens,
|
||||
model_input_reservation_floor_tokens,
|
||||
)
|
||||
if profile.model_governance_required and not _runtime_model_binding_present(profile):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_BINDING_REQUIRED")
|
||||
return profile
|
||||
|
||||
|
||||
def validate_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
@@ -295,9 +495,16 @@ def validate_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_ID_INVALID")
|
||||
runtime = _safe_runtime(value.get("runtime"))
|
||||
task_id = str(value.get("task_id") or "")
|
||||
attempt = value.get("attempt")
|
||||
execution_digest = str(value.get("execution_digest") or "")
|
||||
if not task_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{2,179}", task_id):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TASK_ID_INVALID")
|
||||
if (
|
||||
not isinstance(attempt, int)
|
||||
or isinstance(attempt, bool)
|
||||
or not 1 <= attempt <= 1000
|
||||
):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_ATTEMPT_INVALID")
|
||||
if re.fullmatch(r"[0-9a-f]{64}", execution_digest) is None:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_EXECUTION_DIGEST_INVALID")
|
||||
payload = value.get("payload")
|
||||
@@ -317,6 +524,7 @@ def validate_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"request_id": request_id,
|
||||
"runtime": runtime,
|
||||
"task_id": task_id,
|
||||
"attempt": attempt,
|
||||
"execution_digest": execution_digest,
|
||||
"payload": dict(payload),
|
||||
}
|
||||
@@ -351,11 +559,13 @@ def _argv_with_files(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
prompt: str,
|
||||
model_decision: object | None = None,
|
||||
) -> list[str]:
|
||||
replacements = {
|
||||
"{input_json}": str(input_path),
|
||||
"{output_json}": str(output_path),
|
||||
"{prompt}": prompt,
|
||||
**_model_replacements(model_decision),
|
||||
}
|
||||
result = []
|
||||
for item in profile.argv:
|
||||
@@ -471,7 +681,12 @@ def execute_request(
|
||||
request = validate_request(json.loads(raw_request.decode("utf-8")))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_REQUEST_JSON_INVALID") from exc
|
||||
|
||||
profile = RuntimeProfile.load(request["runtime"], profile_root)
|
||||
governance_store: ModelGovernanceStore | None = None
|
||||
route_request: RouteRequest | None = None
|
||||
decision = None
|
||||
governance_attempts: list[dict[str, Any]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"aios-{request['runtime']}-") as temporary:
|
||||
temp_root = Path(temporary)
|
||||
@@ -480,78 +695,203 @@ def execute_request(
|
||||
output_path = temp_root / "output.json"
|
||||
input_path.write_bytes(canonical(request["payload"]))
|
||||
prompt = render_prompt(request["payload"])
|
||||
argv = _argv_with_files(profile, input_path, output_path, prompt)
|
||||
|
||||
stdin_data = None
|
||||
if profile.input_mode == "stdin_json":
|
||||
stdin_data = canonical(request["payload"]).decode("utf-8")
|
||||
elif profile.input_mode == "stdin_text":
|
||||
stdin_data = prompt
|
||||
|
||||
child_env = {
|
||||
"PATH": "/usr/bin:/usr/local/bin:/bin",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"LANG": "C.UTF-8",
|
||||
"HOME": str(temp_root / "home"),
|
||||
"NO_COLOR": "1",
|
||||
}
|
||||
child_env.update(dict(profile.environment))
|
||||
child_env.update(_credential_environment(profile))
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
input=stdin_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
shell=False,
|
||||
timeout=profile.timeout_seconds,
|
||||
cwd=str(temp_root),
|
||||
env=child_env,
|
||||
if profile.model_governance_required:
|
||||
if not MODEL_GOVERNANCE_ENABLED:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_GOVERNANCE_DISABLED")
|
||||
scope_type, tenant_id, project_id, trace_id = _scope_from_payload(request["payload"])
|
||||
governance_store = ModelGovernanceStore(DEFAULT_MODEL_GOVERNANCE_DB)
|
||||
prompt_estimated_input_tokens = _estimate_tokens(prompt)
|
||||
reserved_estimated_input_tokens = max(
|
||||
prompt_estimated_input_tokens,
|
||||
profile.model_input_reservation_floor_tokens,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TIMEOUT") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise ExternalAgentRuntimeError(
|
||||
f"EXTERNAL_AGENT_PROCESS_FAILED:{completed.returncode}:"
|
||||
+ _bounded_text(completed.stderr, 2048)
|
||||
route_request = RouteRequest(
|
||||
request_id=request["request_id"] + ":model",
|
||||
task_id=request["task_id"],
|
||||
trace_id=trace_id,
|
||||
scope_type=scope_type,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
required_capabilities=tuple(
|
||||
sorted(
|
||||
{
|
||||
f"agent.runtime.{request['runtime']}",
|
||||
*profile.model_capabilities,
|
||||
}
|
||||
)
|
||||
),
|
||||
estimated_input_tokens=reserved_estimated_input_tokens,
|
||||
max_output_tokens=profile.model_max_output_tokens,
|
||||
)
|
||||
try:
|
||||
decision = governance_store.route(route_request)
|
||||
except ModelGovernanceError as exc:
|
||||
raise ExternalAgentRuntimeError(exc.code) from exc
|
||||
if profile.model_provider_ids and decision.provider_id not in profile.model_provider_ids:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_PROVIDER_PROFILE_DENY")
|
||||
governance_attempts.append(
|
||||
{
|
||||
"request_id": decision.request_id,
|
||||
"provider_id": decision.provider_id,
|
||||
"model_id": decision.model_id,
|
||||
"model_version": decision.model_version,
|
||||
"fallback_index": decision.fallback_index,
|
||||
}
|
||||
)
|
||||
|
||||
output_text, output_json = _parse_output(profile, completed.stdout, output_path)
|
||||
result: dict[str, Any] = {
|
||||
"schema": RESULT_SCHEMA,
|
||||
"request_id": request["request_id"],
|
||||
"request_sha256": sha256(canonical(request)),
|
||||
"runtime": request["runtime"],
|
||||
"task_id": request["task_id"],
|
||||
"execution_digest": request["execution_digest"],
|
||||
"exit_code": completed.returncode,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"output_text": output_text,
|
||||
"output_sha256": sha256(output_text.encode("utf-8", "replace")),
|
||||
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||
"credential_material_exposed": False,
|
||||
"control_plane_access_granted": False,
|
||||
}
|
||||
if output_json is not None:
|
||||
result["output_json"] = output_json
|
||||
result["result_sha256"] = sha256(canonical(result))
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{result_path.name}.", dir=result_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(result, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, result_path)
|
||||
while True:
|
||||
argv = _argv_with_files(
|
||||
profile,
|
||||
input_path,
|
||||
output_path,
|
||||
prompt,
|
||||
decision,
|
||||
)
|
||||
stdin_data = None
|
||||
if profile.input_mode == "stdin_json":
|
||||
stdin_data = canonical(request["payload"]).decode("utf-8")
|
||||
elif profile.input_mode == "stdin_text":
|
||||
stdin_data = prompt
|
||||
|
||||
child_env = {
|
||||
"PATH": "/usr/bin:/usr/local/bin:/bin",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"LANG": "C.UTF-8",
|
||||
"HOME": str(temp_root / "home"),
|
||||
"NO_COLOR": "1",
|
||||
}
|
||||
child_env.update(dict(_bind_model_values(profile.environment, decision)))
|
||||
child_env.update(_credential_environment(profile))
|
||||
|
||||
started = time.monotonic()
|
||||
failure_class = None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
input=stdin_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
shell=False,
|
||||
timeout=profile.timeout_seconds,
|
||||
cwd=str(temp_root),
|
||||
env=child_env,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
failure_class = f"process_exit_{completed.returncode}"
|
||||
except subprocess.TimeoutExpired:
|
||||
completed = None
|
||||
failure_class = "timeout"
|
||||
|
||||
if failure_class is None and completed is not None:
|
||||
break
|
||||
|
||||
if governance_store is None or decision is None or route_request is None:
|
||||
if failure_class == "timeout":
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_TIMEOUT")
|
||||
raise ExternalAgentRuntimeError(
|
||||
f"EXTERNAL_AGENT_PROCESS_FAILED:{completed.returncode}:"
|
||||
+ _bounded_text(completed.stderr, 2048)
|
||||
)
|
||||
|
||||
try:
|
||||
governance_store.record_failure(decision.request_id, failure_class)
|
||||
decision = governance_store.fallback(route_request, decision.request_id)
|
||||
except ModelGovernanceError as exc:
|
||||
raise ExternalAgentRuntimeError(exc.code) from exc
|
||||
if profile.model_provider_ids and decision.provider_id not in profile.model_provider_ids:
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_MODEL_PROVIDER_PROFILE_DENY")
|
||||
governance_attempts.append(
|
||||
{
|
||||
"request_id": decision.request_id,
|
||||
"provider_id": decision.provider_id,
|
||||
"model_id": decision.model_id,
|
||||
"model_version": decision.model_version,
|
||||
"fallback_index": decision.fallback_index,
|
||||
"previous_failure_class": failure_class,
|
||||
}
|
||||
)
|
||||
|
||||
output_text, output_json = _parse_output(profile, completed.stdout, output_path)
|
||||
model_governance: dict[str, Any] | None = None
|
||||
if governance_store is not None and decision is not None:
|
||||
input_tokens, output_tokens, actual_cost = _extract_runtime_usage(output_json)
|
||||
try:
|
||||
settlement = governance_store.record_success(
|
||||
decision.request_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
actual_cost_micros=actual_cost,
|
||||
usage_source="runtime",
|
||||
)
|
||||
except ModelGovernanceError as exc:
|
||||
raise ExternalAgentRuntimeError(exc.code) from exc
|
||||
model_governance = {
|
||||
"decision_id": decision.decision_id,
|
||||
"request_id": decision.request_id,
|
||||
"provider_id": decision.provider_id,
|
||||
"model_id": decision.model_id,
|
||||
"model_version": decision.model_version,
|
||||
"policy_revision": decision.policy_revision,
|
||||
"model_revision": decision.model_revision,
|
||||
"fallback_index": decision.fallback_index,
|
||||
"prompt_estimated_input_tokens": prompt_estimated_input_tokens,
|
||||
"input_reservation_floor_tokens": (
|
||||
profile.model_input_reservation_floor_tokens
|
||||
),
|
||||
"reserved_estimated_input_tokens": (
|
||||
reserved_estimated_input_tokens
|
||||
),
|
||||
"settlement": settlement,
|
||||
"attempts": governance_attempts,
|
||||
"audit_chain_valid": governance_store.verify_audit_chain(),
|
||||
}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"schema": RESULT_SCHEMA,
|
||||
"request_id": request["request_id"],
|
||||
"request_sha256": sha256(canonical(request)),
|
||||
"runtime": request["runtime"],
|
||||
"task_id": request["task_id"],
|
||||
"attempt": request["attempt"],
|
||||
"execution_digest": request["execution_digest"],
|
||||
"exit_code": completed.returncode,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"output_text": output_text,
|
||||
"output_sha256": sha256(output_text.encode("utf-8", "replace")),
|
||||
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||
"credential_material_exposed": False,
|
||||
"control_plane_access_granted": False,
|
||||
}
|
||||
if output_json is not None:
|
||||
result["output_json"] = output_json
|
||||
if model_governance is not None:
|
||||
result["model_governance"] = model_governance
|
||||
result["result_sha256"] = sha256(canonical(result))
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{result_path.name}.", dir=result_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
result,
|
||||
handle,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, result_path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
if governance_store is not None:
|
||||
governance_store.close()
|
||||
|
||||
|
||||
class ExternalAgentExchange:
|
||||
@@ -566,6 +906,7 @@ class ExternalAgentExchange:
|
||||
request_id: str,
|
||||
runtime: str,
|
||||
task_id: str,
|
||||
attempt: int,
|
||||
execution_digest: str,
|
||||
payload: Mapping[str, Any],
|
||||
timeout_seconds: int = 1800,
|
||||
@@ -576,6 +917,7 @@ class ExternalAgentExchange:
|
||||
"request_id": request_id,
|
||||
"runtime": runtime,
|
||||
"task_id": task_id,
|
||||
"attempt": attempt,
|
||||
"execution_digest": execution_digest,
|
||||
"payload": dict(payload),
|
||||
}
|
||||
@@ -651,6 +993,7 @@ class ExternalAgentExchange:
|
||||
or value.get("request_sha256") != request_sha
|
||||
or value.get("runtime") != request["runtime"]
|
||||
or value.get("task_id") != request["task_id"]
|
||||
or value.get("attempt") != request["attempt"]
|
||||
or value.get("execution_digest") != request["execution_digest"]
|
||||
):
|
||||
raise ExternalAgentRuntimeError("EXTERNAL_AGENT_RESULT_BINDING_INVALID")
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disabled-by-default TLS edge for the read-only GPT Online Task ingress.
|
||||
|
||||
The internal AIOS ingress remains loopback-only. This service is a separate edge
|
||||
that may bind non-loopback only when the owner explicitly approves it. It
|
||||
forwards only the tiny read-only ingress API and never proxies the canonical
|
||||
Bridge or Task API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
UPSTREAM = "http://127.0.0.1:18945"
|
||||
MAX_BODY = 64 * 1024
|
||||
MAX_RESPONSE = 256 * 1024
|
||||
MAX_CONCURRENT_REQUESTS = 32
|
||||
REQUEST_TIMEOUT_SECONDS = 20
|
||||
RATE_WINDOW_SECONDS = 60
|
||||
RATE_LIMIT_REQUESTS = 60
|
||||
RATE_BURST_REQUESTS = 15
|
||||
RATE_MAX_CLIENTS = 4096
|
||||
_AUDIT_SALT = secrets.token_bytes(32)
|
||||
PUBLIC_APPROVAL_ENV = "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED"
|
||||
PUBLIC_DOMAIN_ENV = "AIOS_GPT_EDGE_PUBLIC_DOMAIN"
|
||||
TASK_PATH_RE = re.compile(r"^/v1/read-only-tasks(?:/[A-Za-z0-9][A-Za-z0-9._:-]{7,127})?$")
|
||||
|
||||
|
||||
def _client_pseudonym(value: str) -> str:
|
||||
return hashlib.sha256(_AUDIT_SALT + value.encode("utf-8", "replace")).hexdigest()[:16]
|
||||
|
||||
|
||||
def audit(event: str, **fields: object) -> None:
|
||||
safe = {
|
||||
"event": event,
|
||||
"ts_unix_ms": int(time.time() * 1000),
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in {"authorization", "body", "token", "cookie", "client_ip"}:
|
||||
continue
|
||||
safe[key] = value
|
||||
print(
|
||||
json.dumps(safe, sort_keys=True, separators=(",", ":")),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
class EdgeError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def public_bind_allowed(host: str, approval: str) -> str:
|
||||
value = host.strip()
|
||||
if value in {"127.0.0.1", "::1", "localhost"}:
|
||||
return value
|
||||
if approval != "1":
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_BIND_OWNER_APPROVAL_REQUIRED")
|
||||
if value not in {"0.0.0.0", "::"}:
|
||||
raise RuntimeError("GPT_EDGE_BIND_ADDRESS_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def validated_public_domain(value: str) -> str:
|
||||
domain = value.strip().lower()
|
||||
if not domain:
|
||||
return ""
|
||||
if len(domain) > 253 or re.fullmatch(
|
||||
r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
|
||||
domain,
|
||||
) is None:
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_INVALID")
|
||||
return domain
|
||||
|
||||
|
||||
def validated_edge_identity(host: str, approval: str, domain: str) -> tuple[str, str]:
|
||||
bind_host = public_bind_allowed(host, approval)
|
||||
public_domain = validated_public_domain(domain)
|
||||
if bind_host not in {"127.0.0.1", "::1", "localhost"} and not public_domain:
|
||||
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_REQUIRED")
|
||||
return bind_host, public_domain
|
||||
|
||||
|
||||
def validate_tls_file(path: Path, *, key: bool) -> Path:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||
size = path.stat().st_size
|
||||
if size < 16 or size > 256 * 1024:
|
||||
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
||||
if key and (path.stat().st_mode & 0o077):
|
||||
raise RuntimeError("GPT_EDGE_TLS_KEY_MODE_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def build_tls_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
|
||||
cert = validate_tls_file(cert_path, key=False)
|
||||
key = validate_tls_file(key_path, key=True)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.options |= ssl.OP_NO_COMPRESSION
|
||||
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||
return context
|
||||
|
||||
|
||||
def allowed_route(method: str, raw_path: str) -> str:
|
||||
parsed = urlsplit(raw_path)
|
||||
if parsed.query or parsed.fragment:
|
||||
raise EdgeError("QUERY_OR_FRAGMENT_DENY", 400)
|
||||
path = parsed.path
|
||||
if method == "GET" and path in {"/health", "/openapi.json"}:
|
||||
return path
|
||||
if method == "POST" and path == "/v1/read-only-tasks":
|
||||
return path
|
||||
if method == "GET" and TASK_PATH_RE.fullmatch(path):
|
||||
return path
|
||||
raise EdgeError("ROUTE_DENY", 404)
|
||||
|
||||
|
||||
def route_label(method: str, path: str) -> str:
|
||||
if method == "GET" and path == "/health":
|
||||
return "health"
|
||||
if method == "GET" and path == "/openapi.json":
|
||||
return "openapi"
|
||||
if method == "POST" and path == "/v1/read-only-tasks":
|
||||
return "task_create"
|
||||
if method == "GET" and path.startswith("/v1/read-only-tasks/"):
|
||||
return "task_status"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def forwarded_headers(headers: Mapping[str, str], *, body: bool) -> dict[str, str]:
|
||||
result = {"Accept": "application/json"}
|
||||
authorization = headers.get("Authorization")
|
||||
if not isinstance(authorization, str) or not authorization.startswith("Bearer "):
|
||||
raise EdgeError("AUTHORIZATION_REQUIRED", 401)
|
||||
token = authorization[7:]
|
||||
if len(token) < 32 or len(token) > 4096 or any(ch.isspace() for ch in token):
|
||||
raise EdgeError("AUTHORIZATION_INVALID", 401)
|
||||
result["Authorization"] = authorization
|
||||
if body:
|
||||
content_type = headers.get("Content-Type", "")
|
||||
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise EdgeError("CONTENT_TYPE_INVALID", 415)
|
||||
result["Content-Type"] = "application/json"
|
||||
return result
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
window_seconds: int = RATE_WINDOW_SECONDS,
|
||||
limit: int = RATE_LIMIT_REQUESTS,
|
||||
burst: int = RATE_BURST_REQUESTS,
|
||||
max_clients: int = RATE_MAX_CLIENTS,
|
||||
now_fn=time.monotonic,
|
||||
) -> None:
|
||||
self.window_seconds = window_seconds
|
||||
self.limit = limit
|
||||
self.burst = burst
|
||||
self.max_clients = max_clients
|
||||
self.now_fn = now_fn
|
||||
self._lock = threading.Lock()
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def _prune_stale(self, cutoff: float) -> None:
|
||||
stale = [
|
||||
key
|
||||
for key, events in self._events.items()
|
||||
if not events or events[-1] <= cutoff
|
||||
]
|
||||
for key in stale:
|
||||
self._events.pop(key, None)
|
||||
|
||||
def allowed(self, key: str) -> bool:
|
||||
now = self.now_fn()
|
||||
cutoff = now - self.window_seconds
|
||||
with self._lock:
|
||||
if key not in self._events and len(self._events) >= self.max_clients:
|
||||
self._prune_stale(cutoff)
|
||||
if len(self._events) >= self.max_clients:
|
||||
return False
|
||||
events = self._events[key]
|
||||
while events and events[0] <= cutoff:
|
||||
events.popleft()
|
||||
if len(events) >= self.limit + self.burst:
|
||||
return False
|
||||
events.append(now)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Proxy:
|
||||
upstream: str = UPSTREAM
|
||||
timeout: int = REQUEST_TIMEOUT_SECONDS
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: bytes | None,
|
||||
) -> tuple[int, bytes, str]:
|
||||
if self.upstream != UPSTREAM:
|
||||
raise EdgeError("UPSTREAM_DRIFT_DENY", 503)
|
||||
req = urllib.request.Request(
|
||||
self.upstream + path,
|
||||
method=method,
|
||||
data=body,
|
||||
headers=dict(headers),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
status = response.status
|
||||
content_type = response.headers.get("Content-Type", "application/json")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(MAX_RESPONSE + 1)
|
||||
status = exc.code
|
||||
content_type = exc.headers.get("Content-Type", "application/json")
|
||||
except Exception as exc:
|
||||
raise EdgeError("UPSTREAM_UNAVAILABLE", 502) from exc
|
||||
if len(raw) > MAX_RESPONSE:
|
||||
raise EdgeError("UPSTREAM_RESPONSE_TOO_LARGE", 502)
|
||||
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||
raise EdgeError("UPSTREAM_CONTENT_TYPE_INVALID", 502)
|
||||
return status, raw, "application/json"
|
||||
|
||||
|
||||
class EdgeServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 64
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
proxy = Proxy()
|
||||
limiter = RateLimiter()
|
||||
concurrency = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
||||
expected_domain = ""
|
||||
|
||||
server_version = "AIOSGPTIngressEdge/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def respond_json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = canonical(value)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def send_proxy_response(self, status: int, raw: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'")
|
||||
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def client_key(self) -> str:
|
||||
return str(self.client_address[0])
|
||||
|
||||
def validate_host(self) -> None:
|
||||
if not self.expected_domain:
|
||||
return
|
||||
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||||
if not hmac.compare_digest(host, self.expected_domain):
|
||||
raise EdgeError("HOST_HEADER_DENY", 421)
|
||||
|
||||
def body(self) -> bytes:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError as exc:
|
||||
raise EdgeError("CONTENT_LENGTH_INVALID") from exc
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise EdgeError("REQUEST_SIZE_INVALID", 413)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise EdgeError("REQUEST_BODY_TRUNCATED", 400)
|
||||
# Validate JSON at the edge so malformed bodies do not consume upstream work.
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise EdgeError("REQUEST_JSON_INVALID", 400) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise EdgeError("REQUEST_JSON_OBJECT_REQUIRED", 400)
|
||||
return canonical(value)
|
||||
|
||||
def handle_proxy(self, method: str) -> None:
|
||||
client_id = _client_pseudonym(self.client_key())
|
||||
started = time.monotonic()
|
||||
acquired = self.concurrency.acquire(blocking=False)
|
||||
if not acquired:
|
||||
audit("EDGE_BUSY", client_id=client_id, method=method)
|
||||
self.respond_json({"error": "EDGE_BUSY"}, 503)
|
||||
return
|
||||
label = "unknown"
|
||||
try:
|
||||
self.validate_host()
|
||||
path = allowed_route(method, self.path)
|
||||
label = route_label(method, path)
|
||||
if path == "/health" and method == "GET":
|
||||
self.respond_json(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-gpt-https-edge",
|
||||
"upstream": "loopback-readonly-ingress",
|
||||
},
|
||||
200,
|
||||
)
|
||||
audit(
|
||||
"EDGE_REQUEST",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=200,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return
|
||||
if not self.limiter.allowed(self.client_key()):
|
||||
audit(
|
||||
"EDGE_RATE_LIMIT",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
)
|
||||
self.respond_json({"error": "RATE_LIMITED"}, 429)
|
||||
return
|
||||
has_body = method == "POST"
|
||||
headers = forwarded_headers(self.headers, body=has_body)
|
||||
body = self.body() if has_body else None
|
||||
status, raw, content_type = self.proxy.request(
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
self.send_proxy_response(status, raw, content_type)
|
||||
audit(
|
||||
"EDGE_REQUEST",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=status,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
except EdgeError as exc:
|
||||
audit(
|
||||
"EDGE_REJECT",
|
||||
client_id=client_id,
|
||||
method=method,
|
||||
route=label,
|
||||
status=exc.status,
|
||||
error=exc.code,
|
||||
)
|
||||
self.respond_json({"error": exc.code}, exc.status)
|
||||
finally:
|
||||
self.concurrency.release()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.handle_proxy("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.handle_proxy("POST")
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_CONNECT(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self.respond_json({"error": "METHOD_DENY"}, 405)
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
host, public_domain = validated_edge_identity(
|
||||
os.environ.get("AIOS_GPT_EDGE_HOST", "127.0.0.1"),
|
||||
os.environ.get(PUBLIC_APPROVAL_ENV, "0"),
|
||||
os.environ.get(PUBLIC_DOMAIN_ENV, ""),
|
||||
)
|
||||
port = int(os.environ.get("AIOS_GPT_EDGE_PORT", "18443"))
|
||||
if not 1024 <= port <= 65535:
|
||||
raise RuntimeError("GPT_EDGE_PORT_INVALID")
|
||||
Handler.expected_domain = public_domain
|
||||
|
||||
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
cert_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_EDGE_TLS_CERT_FILE",
|
||||
str(credentials / "tls_cert"),
|
||||
)
|
||||
)
|
||||
key_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_EDGE_TLS_KEY_FILE",
|
||||
str(credentials / "tls_key"),
|
||||
)
|
||||
)
|
||||
context = build_tls_context(cert_path, key_path)
|
||||
|
||||
server = EdgeServer((host, port), Handler)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,561 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Owner-approved activation/rollback controller for the AIOS GPT HTTPS edge.
|
||||
|
||||
This controller never creates approval, TLS, or ingress credentials. It consumes
|
||||
owner-managed inputs, applies one exact public-bind overlay, verifies the live
|
||||
edge, and provides an idempotent rollback path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
|
||||
APPROVAL_SCHEMA = "aios.gpt-https-edge.public-approval.v1"
|
||||
STATE_SCHEMA = "aios.gpt-https-edge.activation-state.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
APPROVAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
DOMAIN_RE = re.compile(
|
||||
r"(?=.{1,253}\Z)"
|
||||
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
)
|
||||
|
||||
EDGE_UNIT = "aios-gpt-https-edge.service"
|
||||
INGRESS_UNIT = "aios-gpt-online-task-ingress.service"
|
||||
PUBLIC_HOST = "0.0.0.0"
|
||||
PUBLIC_PORT = 18443
|
||||
INGRESS_HEALTH = "http://127.0.0.1:18945/health"
|
||||
DROPIN_NAME = "zzzzzzzzzz-owner-approved-public.conf"
|
||||
|
||||
|
||||
class ActivationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
return sha256_bytes(path.read_bytes())
|
||||
|
||||
|
||||
def parse_time(value: object, *, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID")
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_domain(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ActivationError("APPROVAL_DOMAIN_INVALID")
|
||||
domain = value.strip().lower()
|
||||
if DOMAIN_RE.fullmatch(domain) is None:
|
||||
raise ActivationError("APPROVAL_DOMAIN_INVALID")
|
||||
return domain
|
||||
|
||||
|
||||
def validate_root_file(
|
||||
path: Path,
|
||||
*,
|
||||
exact_mode: int | None = None,
|
||||
max_size: int = 256 * 1024,
|
||||
) -> Path:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ActivationError("OWNER_FILE_INVALID")
|
||||
info = path.stat()
|
||||
if info.st_uid != 0 or info.st_gid != 0:
|
||||
raise ActivationError("OWNER_FILE_OWNERSHIP_INVALID")
|
||||
mode = stat.S_IMODE(info.st_mode)
|
||||
if exact_mode is not None and mode != exact_mode:
|
||||
raise ActivationError("OWNER_FILE_MODE_INVALID")
|
||||
if info.st_size < 16 or info.st_size > max_size:
|
||||
raise ActivationError("OWNER_FILE_SIZE_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def certificate_dns_names(cert_path: Path) -> tuple[str, ...]:
|
||||
try:
|
||||
decoded = ssl._ssl._test_decode_cert(str(cert_path))
|
||||
except Exception as exc:
|
||||
raise ActivationError("TLS_CERT_DECODE_INVALID") from exc
|
||||
names = [
|
||||
value
|
||||
for kind, value in decoded.get("subjectAltName", ())
|
||||
if kind == "DNS"
|
||||
]
|
||||
if not names:
|
||||
for rdn in decoded.get("subject", ()):
|
||||
for key, value in rdn:
|
||||
if key == "commonName":
|
||||
names.append(value)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def cert_matches_domain(cert_path: Path, domain: str) -> bool:
|
||||
names = certificate_dns_names(cert_path)
|
||||
try:
|
||||
return bool(names) and any(ssl._dnsname_match(name, domain) for name in names)
|
||||
except ssl.CertificateError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_tls_pair(
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
domain: str,
|
||||
*,
|
||||
now_fn=time.time,
|
||||
) -> dict[str, Any]:
|
||||
validate_root_file(cert_path)
|
||||
validate_root_file(key_path, exact_mode=0o600)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.options |= ssl.OP_NO_COMPRESSION
|
||||
try:
|
||||
context.load_cert_chain(str(cert_path), str(key_path))
|
||||
except Exception as exc:
|
||||
raise ActivationError("TLS_CERT_KEY_PAIR_INVALID") from exc
|
||||
if not cert_matches_domain(cert_path, domain):
|
||||
raise ActivationError("TLS_CERT_DOMAIN_MISMATCH")
|
||||
decoded = ssl._ssl._test_decode_cert(str(cert_path))
|
||||
not_after = decoded.get("notAfter")
|
||||
if not not_after:
|
||||
raise ActivationError("TLS_CERT_EXPIRY_MISSING")
|
||||
remaining = ssl.cert_time_to_seconds(not_after) - now_fn()
|
||||
if remaining < 7 * 86400:
|
||||
raise ActivationError("TLS_CERT_EXPIRY_TOO_CLOSE")
|
||||
return {
|
||||
"cert_sha256": sha256_file(cert_path),
|
||||
"remaining_days": int(remaining // 86400),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Approval:
|
||||
approval_id: str
|
||||
candidate_oid: str
|
||||
domain: str
|
||||
bind_host: str
|
||||
port: int
|
||||
tls_cert_sha256: str
|
||||
owner: str
|
||||
issued_at: datetime
|
||||
not_before: datetime
|
||||
expires_at: datetime
|
||||
raw_sha256: str
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
candidate_oid: str,
|
||||
cert_path: Path,
|
||||
now: datetime | None = None,
|
||||
) -> "Approval":
|
||||
validate_root_file(path, exact_mode=0o600, max_size=64 * 1024)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise ActivationError("APPROVAL_JSON_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ActivationError("APPROVAL_JSON_INVALID")
|
||||
allowed = {
|
||||
"schema",
|
||||
"approval_id",
|
||||
"candidate_oid",
|
||||
"domain",
|
||||
"public_bind",
|
||||
"bind_host",
|
||||
"port",
|
||||
"tls_cert_sha256",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"owner",
|
||||
}
|
||||
if set(value) != allowed or value.get("schema") != APPROVAL_SCHEMA:
|
||||
raise ActivationError("APPROVAL_FIELDS_INVALID")
|
||||
|
||||
approval_id = value.get("approval_id")
|
||||
if not isinstance(approval_id, str) or APPROVAL_ID_RE.fullmatch(approval_id) is None:
|
||||
raise ActivationError("APPROVAL_ID_INVALID")
|
||||
if not OID_RE.fullmatch(str(value.get("candidate_oid") or "")):
|
||||
raise ActivationError("APPROVAL_CANDIDATE_INVALID")
|
||||
if value["candidate_oid"] != candidate_oid:
|
||||
raise ActivationError("APPROVAL_CANDIDATE_MISMATCH")
|
||||
if value.get("public_bind") is not True:
|
||||
raise ActivationError("APPROVAL_PUBLIC_BIND_REQUIRED")
|
||||
if value.get("bind_host") != PUBLIC_HOST or value.get("port") != PUBLIC_PORT:
|
||||
raise ActivationError("APPROVAL_BINDING_INVALID")
|
||||
|
||||
cert_sha = str(value.get("tls_cert_sha256") or "")
|
||||
if SHA_RE.fullmatch(cert_sha) is None:
|
||||
raise ActivationError("APPROVAL_CERT_SHA_INVALID")
|
||||
if not cert_path.is_file() or sha256_file(cert_path) != cert_sha:
|
||||
raise ActivationError("APPROVAL_CERT_SHA_MISMATCH")
|
||||
|
||||
domain = validate_domain(value.get("domain"))
|
||||
owner = value.get("owner")
|
||||
if not isinstance(owner, str) or not owner.strip() or len(owner) > 255:
|
||||
raise ActivationError("APPROVAL_OWNER_INVALID")
|
||||
|
||||
issued_at = parse_time(value.get("issued_at"), field="issued_at")
|
||||
not_before = parse_time(value.get("not_before"), field="not_before")
|
||||
expires_at = parse_time(value.get("expires_at"), field="expires_at")
|
||||
current = now or datetime.now(timezone.utc)
|
||||
if not (issued_at <= not_before <= expires_at):
|
||||
raise ActivationError("APPROVAL_TIME_ORDER_INVALID")
|
||||
if current < not_before:
|
||||
raise ActivationError("APPROVAL_NOT_YET_VALID")
|
||||
if current >= expires_at:
|
||||
raise ActivationError("APPROVAL_EXPIRED")
|
||||
if expires_at - issued_at > __import__("datetime").timedelta(days=7):
|
||||
raise ActivationError("APPROVAL_VALIDITY_TOO_LONG")
|
||||
|
||||
return cls(
|
||||
approval_id=approval_id,
|
||||
candidate_oid=candidate_oid,
|
||||
domain=domain,
|
||||
bind_host=PUBLIC_HOST,
|
||||
port=PUBLIC_PORT,
|
||||
tls_cert_sha256=cert_sha,
|
||||
owner=owner.strip(),
|
||||
issued_at=issued_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
raw_sha256=sha256_file(path),
|
||||
)
|
||||
|
||||
|
||||
def validate_release(release_root: Path) -> dict[str, Path]:
|
||||
if release_root.is_symlink() or not release_root.is_dir():
|
||||
raise ActivationError("IMMUTABLE_RELEASE_INVALID")
|
||||
edge = release_root / "active-runtime/app/aios_gpt_https_edge.py"
|
||||
controller = release_root / "active-runtime/app/aios_gpt_https_edge_activation.py"
|
||||
for path in (edge, controller):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ActivationError("IMMUTABLE_RELEASE_INVALID")
|
||||
return {"edge": edge, "controller": controller}
|
||||
|
||||
|
||||
def render_dropin(approval: Approval, release_root: Path) -> str:
|
||||
app = release_root / "active-runtime/app/aios_gpt_https_edge.py"
|
||||
return (
|
||||
"[Service]\n"
|
||||
f"Environment=AIOS_GPT_EDGE_HOST={approval.bind_host}\n"
|
||||
f"Environment=AIOS_GPT_EDGE_PORT={approval.port}\n"
|
||||
"Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1\n"
|
||||
f"Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN={approval.domain}\n"
|
||||
"ExecStart=\n"
|
||||
f"ExecStart=/usr/bin/python3 -B {app}\n"
|
||||
f"ReadOnlyPaths={release_root}\n"
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: str, mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class Systemd:
|
||||
def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["/usr/bin/systemctl", *args],
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def active(self, unit: str) -> bool:
|
||||
return self.run("is-active", "--quiet", unit, check=False).returncode == 0
|
||||
|
||||
def enabled(self, unit: str) -> bool:
|
||||
return self.run("is-enabled", "--quiet", unit, check=False).returncode == 0
|
||||
|
||||
def show(self, unit: str, prop: str) -> str:
|
||||
return self.run("show", unit, f"--property={prop}", "--value").stdout.strip()
|
||||
|
||||
|
||||
def require_ingress_ready(systemd: Systemd) -> None:
|
||||
if not systemd.active(INGRESS_UNIT):
|
||||
raise ActivationError("INGRESS_SERVICE_NOT_ACTIVE")
|
||||
try:
|
||||
with urllib.request.urlopen(INGRESS_HEALTH, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
raise ActivationError("INGRESS_HEALTH_FAILED")
|
||||
value = json.loads(response.read().decode())
|
||||
except ActivationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ActivationError("INGRESS_HEALTH_FAILED") from exc
|
||||
if value.get("status") != "ok":
|
||||
raise ActivationError("INGRESS_HEALTH_FAILED")
|
||||
|
||||
|
||||
def tls_probe(domain: str, cert_path: Path, *, port: int = PUBLIC_PORT) -> None:
|
||||
context = ssl.create_default_context(cafile=str(cert_path))
|
||||
context.check_hostname = True
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=domain) as tls_sock:
|
||||
if tls_sock.version() not in {"TLSv1.2", "TLSv1.3"}:
|
||||
raise ActivationError("EDGE_TLS_VERSION_INVALID")
|
||||
except ActivationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ActivationError("EDGE_TLS_PROBE_FAILED") from exc
|
||||
|
||||
|
||||
def receipt_for(
|
||||
approval: Approval,
|
||||
release_root: Path,
|
||||
*,
|
||||
state: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STATE_SCHEMA,
|
||||
"state": state,
|
||||
"approval_id": approval.approval_id,
|
||||
"approval_sha256": approval.raw_sha256,
|
||||
"candidate_oid": approval.candidate_oid,
|
||||
"domain": approval.domain,
|
||||
"bind_host": approval.bind_host,
|
||||
"port": approval.port,
|
||||
"tls_cert_sha256": approval.tls_cert_sha256,
|
||||
"release_root": str(release_root),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def activate(
|
||||
*,
|
||||
approval: Approval,
|
||||
release_root: Path,
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
dropin_path: Path,
|
||||
state_path: Path,
|
||||
systemd: Systemd,
|
||||
) -> dict[str, Any]:
|
||||
if os.geteuid() != 0:
|
||||
raise ActivationError("ROOT_REQUIRED")
|
||||
release = validate_release(release_root)
|
||||
app = release["edge"]
|
||||
require_ingress_ready(systemd)
|
||||
validate_tls_pair(cert_path, key_path, approval.domain)
|
||||
|
||||
if state_path.is_file():
|
||||
try:
|
||||
existing = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
existing = {}
|
||||
if (
|
||||
existing.get("schema") == STATE_SCHEMA
|
||||
and existing.get("state") == "ACTIVE"
|
||||
and existing.get("approval_sha256") == approval.raw_sha256
|
||||
and existing.get("candidate_oid") == approval.candidate_oid
|
||||
and systemd.active(EDGE_UNIT)
|
||||
):
|
||||
return existing
|
||||
|
||||
dropin = render_dropin(approval, release_root)
|
||||
modified = False
|
||||
try:
|
||||
atomic_write(dropin_path, dropin, mode=0o644)
|
||||
modified = True
|
||||
systemd.run("daemon-reload")
|
||||
systemd.run("enable", EDGE_UNIT)
|
||||
systemd.run("restart", EDGE_UNIT)
|
||||
if not systemd.active(EDGE_UNIT):
|
||||
raise ActivationError("EDGE_SERVICE_NOT_ACTIVE")
|
||||
exec_start = systemd.show(EDGE_UNIT, "ExecStart")
|
||||
environment = systemd.show(EDGE_UNIT, "Environment")
|
||||
if str(app) not in exec_start:
|
||||
raise ActivationError("EDGE_EFFECTIVE_EXECSTART_INVALID")
|
||||
for expected in (
|
||||
f"AIOS_GPT_EDGE_HOST={approval.bind_host}",
|
||||
f"AIOS_GPT_EDGE_PORT={approval.port}",
|
||||
"AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1",
|
||||
f"AIOS_GPT_EDGE_PUBLIC_DOMAIN={approval.domain}",
|
||||
):
|
||||
if expected not in environment:
|
||||
raise ActivationError("EDGE_EFFECTIVE_ENVIRONMENT_INVALID")
|
||||
tls_probe(approval.domain, cert_path, port=approval.port)
|
||||
receipt = receipt_for(approval, release_root, state="ACTIVE")
|
||||
atomic_write(state_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
||||
return receipt
|
||||
except Exception:
|
||||
if modified:
|
||||
rollback(
|
||||
dropin_path=dropin_path,
|
||||
state_path=state_path,
|
||||
systemd=systemd,
|
||||
reason="activation_failed",
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def rollback(
|
||||
*,
|
||||
dropin_path: Path,
|
||||
state_path: Path,
|
||||
systemd: Systemd,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
if os.geteuid() != 0:
|
||||
raise ActivationError("ROOT_REQUIRED")
|
||||
systemd.run("stop", EDGE_UNIT, check=False)
|
||||
systemd.run("disable", EDGE_UNIT, check=False)
|
||||
dropin_path.unlink(missing_ok=True)
|
||||
systemd.run("daemon-reload")
|
||||
if systemd.active(EDGE_UNIT):
|
||||
raise ActivationError("EDGE_ROLLBACK_SERVICE_STILL_ACTIVE")
|
||||
previous: dict[str, Any] = {}
|
||||
if state_path.is_file():
|
||||
try:
|
||||
previous = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
previous = {}
|
||||
receipt = {
|
||||
"schema": STATE_SCHEMA,
|
||||
"state": "ROLLED_BACK",
|
||||
"reason": reason[:255],
|
||||
"candidate_oid": previous.get("candidate_oid"),
|
||||
"approval_id": previous.get("approval_id"),
|
||||
"domain": previous.get("domain"),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
atomic_write(state_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", choices=("check", "activate", "rollback"))
|
||||
parser.add_argument("--candidate")
|
||||
parser.add_argument("--approval", default="/etc/aios-gitops/gpt-https-edge.approved")
|
||||
parser.add_argument("--tls-cert", default="/etc/aios-gitops/gpt-https-edge-tls.crt")
|
||||
parser.add_argument("--tls-key", default="/etc/aios-gitops/gpt-https-edge-tls.key")
|
||||
parser.add_argument("--release-root")
|
||||
parser.add_argument(
|
||||
"--dropin",
|
||||
default=f"/etc/systemd/system/{EDGE_UNIT}.d/{DROPIN_NAME}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
default="/var/lib/aios-gpt-https-edge-activation/state.json",
|
||||
)
|
||||
parser.add_argument("--reason", default="owner_requested")
|
||||
args = parser.parse_args()
|
||||
|
||||
systemd = Systemd()
|
||||
if args.command == "rollback":
|
||||
value = rollback(
|
||||
dropin_path=Path(args.dropin),
|
||||
state_path=Path(args.state),
|
||||
systemd=systemd,
|
||||
reason=args.reason,
|
||||
)
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
candidate = str(args.candidate or "")
|
||||
if OID_RE.fullmatch(candidate) is None:
|
||||
raise SystemExit("CANDIDATE_REQUIRED")
|
||||
release_root = (
|
||||
Path(args.release_root)
|
||||
if args.release_root
|
||||
else Path(f"/opt/aios/releases/{candidate}-gpt-https-edge-public")
|
||||
)
|
||||
cert_path = Path(args.tls_cert)
|
||||
key_path = Path(args.tls_key)
|
||||
approval = Approval.load(
|
||||
Path(args.approval),
|
||||
candidate_oid=candidate,
|
||||
cert_path=cert_path,
|
||||
)
|
||||
tls = validate_tls_pair(cert_path, key_path, approval.domain)
|
||||
release = validate_release(release_root)
|
||||
require_ingress_ready(systemd)
|
||||
|
||||
if args.command == "check":
|
||||
value = {
|
||||
"status": "READY",
|
||||
"candidate_oid": candidate,
|
||||
"approval_id": approval.approval_id,
|
||||
"approval_sha256": approval.raw_sha256,
|
||||
"domain": approval.domain,
|
||||
"bind_host": approval.bind_host,
|
||||
"port": approval.port,
|
||||
"edge_sha256": sha256_file(release["edge"]),
|
||||
"controller_sha256": sha256_file(release["controller"]),
|
||||
**tls,
|
||||
"public_network_mutation": False,
|
||||
}
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
value = activate(
|
||||
approval=approval,
|
||||
release_root=release_root,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
dropin_path=Path(args.dropin),
|
||||
state_path=Path(args.state),
|
||||
systemd=systemd,
|
||||
)
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,816 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Credential-isolated host-side ingress for externally scheduled read-only Online Tasks.
|
||||
|
||||
This service is intentionally not a public AIOS control plane. It accepts a tiny,
|
||||
authenticated external intent and translates it to the canonical loopback Bridge.
|
||||
Callers cannot select repository coordinates, capabilities, execution action,
|
||||
worker, branch policy, credentials, verification decisions, or write permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
|
||||
SCHEMA = "aios.gpt-online-task-ingress.request.v1"
|
||||
RESPONSE_SCHEMA = "aios.gpt-online-task-ingress.response.v1"
|
||||
OPENAPI_VERSION = "3.1.0"
|
||||
|
||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
JOB_ID_RE = re.compile(r"^job-[0-9a-f]{32}$")
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
MAX_BODY = 64 * 1024
|
||||
MAX_GOAL = 12_000
|
||||
MAX_INSTRUCTIONS = 24_000
|
||||
MAX_CRITERIA = 16
|
||||
MAX_CRITERION_TEXT = 1_000
|
||||
|
||||
READONLY_CAPABILITIES = ("git.read", "git.test")
|
||||
REPOSITORY = "aios/aios"
|
||||
|
||||
|
||||
class IngressError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def load_secret(path: Path, *, code: str) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError(code + "_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32 or "\n" in value or "\r" in value:
|
||||
raise RuntimeError(code + "_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def normalized_request(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"schema",
|
||||
"request_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria",
|
||||
"constraints",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise IngressError("UNKNOWN_FIELDS")
|
||||
if value.get("schema") != SCHEMA:
|
||||
raise IngressError("SCHEMA_INVALID")
|
||||
|
||||
request_id = value.get("request_id")
|
||||
if not isinstance(request_id, str) or REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||
raise IngressError("REQUEST_ID_INVALID")
|
||||
|
||||
goal = value.get("goal")
|
||||
if not isinstance(goal, str) or not goal.strip() or len(goal) > MAX_GOAL:
|
||||
raise IngressError("GOAL_INVALID")
|
||||
instructions = value.get("instructions", "")
|
||||
if not isinstance(instructions, str) or len(instructions) > MAX_INSTRUCTIONS:
|
||||
raise IngressError("INSTRUCTIONS_INVALID")
|
||||
|
||||
criteria = value.get("success_criteria") or []
|
||||
if not isinstance(criteria, list) or len(criteria) > MAX_CRITERIA:
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria = []
|
||||
for item in criteria:
|
||||
if not isinstance(item, Mapping) or set(item) != {"id", "description"}:
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
criterion_id = item.get("id")
|
||||
description = item.get("description")
|
||||
if (
|
||||
not isinstance(criterion_id, str)
|
||||
or not criterion_id.strip()
|
||||
or len(criterion_id) > 120
|
||||
or not isinstance(description, str)
|
||||
or not description.strip()
|
||||
or len(description) > MAX_CRITERION_TEXT
|
||||
):
|
||||
raise IngressError("SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria.append(
|
||||
{"id": criterion_id.strip(), "description": description.strip()}
|
||||
)
|
||||
|
||||
constraints = value.get("constraints") or {}
|
||||
if not isinstance(constraints, Mapping):
|
||||
raise IngressError("CONSTRAINTS_INVALID")
|
||||
allowed_constraints = {
|
||||
"max_files",
|
||||
"focus_paths",
|
||||
"notes",
|
||||
}
|
||||
if set(constraints) - allowed_constraints:
|
||||
raise IngressError("CONSTRAINTS_FIELDS_INVALID")
|
||||
normalized_constraints: dict[str, Any] = {}
|
||||
if "max_files" in constraints:
|
||||
max_files = constraints["max_files"]
|
||||
if (
|
||||
not isinstance(max_files, int)
|
||||
or isinstance(max_files, bool)
|
||||
or not 1 <= max_files <= 500
|
||||
):
|
||||
raise IngressError("MAX_FILES_INVALID")
|
||||
normalized_constraints["max_files"] = max_files
|
||||
if "focus_paths" in constraints:
|
||||
paths = constraints["focus_paths"]
|
||||
if (
|
||||
not isinstance(paths, list)
|
||||
or len(paths) > 32
|
||||
or not all(
|
||||
isinstance(path, str)
|
||||
and path
|
||||
and len(path) <= 512
|
||||
and not path.startswith("/")
|
||||
and ".." not in Path(path).parts
|
||||
for path in paths
|
||||
)
|
||||
):
|
||||
raise IngressError("FOCUS_PATHS_INVALID")
|
||||
normalized_constraints["focus_paths"] = paths
|
||||
if "notes" in constraints:
|
||||
notes = constraints["notes"]
|
||||
if not isinstance(notes, str) or len(notes) > 4_000:
|
||||
raise IngressError("CONSTRAINT_NOTES_INVALID")
|
||||
normalized_constraints["notes"] = notes
|
||||
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"request_id": request_id,
|
||||
"goal": goal.strip(),
|
||||
"instructions": instructions,
|
||||
"success_criteria": normalized_criteria,
|
||||
"constraints": normalized_constraints,
|
||||
}
|
||||
|
||||
|
||||
def validated_bind_host(value: str) -> str:
|
||||
host = value.strip()
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise RuntimeError("GPT_INGRESS_NON_LOOPBACK_BIND_DENY")
|
||||
return host
|
||||
|
||||
|
||||
def task_id_for(request_id: str) -> str:
|
||||
suffix = hashlib.sha256(request_id.encode("utf-8")).hexdigest()[:24]
|
||||
return "gpt-readonly-" + suffix
|
||||
|
||||
|
||||
def branch_for(task_id: str) -> str:
|
||||
return "agent/external-gpt-readonly/" + task_id[-24:]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Upstream:
|
||||
bridge_base: str
|
||||
task_base: str
|
||||
control_token: str
|
||||
timeout: int = 20
|
||||
|
||||
def _request(
|
||||
self,
|
||||
base: str,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
raw_body = None if body is None else canonical(body)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.control_token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if raw_body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
base.rstrip("/") + path,
|
||||
method=method,
|
||||
data=raw_body,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = exc.code
|
||||
except Exception as exc:
|
||||
raise IngressError("UPSTREAM_TRANSPORT_FAILED", 503) from exc
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise IngressError("UPSTREAM_RESPONSE_INVALID", 502)
|
||||
return status, dict(value)
|
||||
|
||||
def control_snapshot(self) -> dict[str, Any]:
|
||||
status, value = self._request(
|
||||
self.bridge_base,
|
||||
"GET",
|
||||
"/v2/control-plane-snapshot",
|
||||
)
|
||||
if status != 200:
|
||||
raise IngressError("CONTROL_SNAPSHOT_UNAVAILABLE", 503)
|
||||
return value
|
||||
|
||||
def create_governed_task(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.bridge_base,
|
||||
"POST",
|
||||
"/v2/governed-task",
|
||||
body,
|
||||
)
|
||||
|
||||
def execution_envelope(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.bridge_base,
|
||||
"POST",
|
||||
"/v2/execution-envelope",
|
||||
body,
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
||||
return self._request(
|
||||
self.task_base,
|
||||
"GET",
|
||||
"/v1/tasks/" + task_id,
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.RLock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as con:
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS external_task_requests(
|
||||
request_id TEXT PRIMARY KEY,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
job_id TEXT,
|
||||
base_oid TEXT,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
response_json TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
con = sqlite3.connect(self.path, timeout=30)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA busy_timeout=30000")
|
||||
return con
|
||||
|
||||
def get(self, request_id: str) -> sqlite3.Row | None:
|
||||
with self._connect() as con:
|
||||
return con.execute(
|
||||
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
|
||||
def reserve(self, request: Mapping[str, Any], task_id: str) -> sqlite3.Row:
|
||||
request_sha = digest(request)
|
||||
timestamp = now()
|
||||
with self._lock, self._connect() as con:
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT * FROM external_task_requests WHERE request_id=?",
|
||||
(request["request_id"],),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row["request_sha256"] != request_sha:
|
||||
raise IngressError("IDEMPOTENCY_CONFLICT", 409)
|
||||
con.commit()
|
||||
return row
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO external_task_requests(
|
||||
request_id,request_sha256,task_id,state,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
request["request_id"],
|
||||
request_sha,
|
||||
task_id,
|
||||
"PENDING",
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
row = self.get(request["request_id"])
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
def bound(
|
||||
self,
|
||||
request_id: str,
|
||||
*,
|
||||
job_id: str,
|
||||
base_oid: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
with self._lock, self._connect() as con:
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE external_task_requests
|
||||
SET job_id=?,base_oid=?,state='BOUND',response_json=?,updated_at=?
|
||||
WHERE request_id=?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
base_oid,
|
||||
json.dumps(response, sort_keys=True, separators=(",", ":")),
|
||||
now(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
|
||||
class IngressService:
|
||||
def __init__(self, upstream: Upstream, store: Store) -> None:
|
||||
self.upstream = upstream
|
||||
self.store = store
|
||||
|
||||
@staticmethod
|
||||
def _main_oid(snapshot: Mapping[str, Any]) -> str:
|
||||
candidates = [
|
||||
snapshot.get("authoritative_head_oid"),
|
||||
snapshot.get("head"),
|
||||
(snapshot.get("aggregate") or {}).get("authoritative_head_oid")
|
||||
if isinstance(snapshot.get("aggregate"), Mapping)
|
||||
else None,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, str) and OID_RE.fullmatch(candidate):
|
||||
return candidate
|
||||
raise IngressError("AUTHORITATIVE_HEAD_UNAVAILABLE", 503)
|
||||
|
||||
@staticmethod
|
||||
def _public_response(
|
||||
request_id: str,
|
||||
task_id: str,
|
||||
job_id: str,
|
||||
state: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"state": state,
|
||||
"execution_mode": "source_capture",
|
||||
"capabilities": list(READONLY_CAPABILITIES),
|
||||
"write_access": False,
|
||||
"official_verification_required": True,
|
||||
}
|
||||
|
||||
def _recover_existing(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
row: sqlite3.Row,
|
||||
) -> dict[str, Any] | None:
|
||||
task_id = str(row["task_id"])
|
||||
status, task = self.upstream.get_task(task_id)
|
||||
if status == 404:
|
||||
return None
|
||||
if status != 200:
|
||||
raise IngressError("TASK_RECOVERY_FAILED", 503)
|
||||
metadata = task.get("metadata")
|
||||
execution = (
|
||||
metadata.get("execution")
|
||||
if isinstance(metadata, Mapping)
|
||||
and isinstance(metadata.get("execution"), Mapping)
|
||||
else {}
|
||||
)
|
||||
job_id = str(execution.get("job_id") or row["job_id"] or "")
|
||||
base_oid = str(execution.get("base_oid") or row["base_oid"] or "")
|
||||
if (
|
||||
task.get("task_id") != task_id
|
||||
or not JOB_ID_RE.fullmatch(job_id)
|
||||
or not OID_RE.fullmatch(base_oid)
|
||||
or execution.get("action") != "source_capture"
|
||||
or sorted(execution.get("capability_scope") or [])
|
||||
!= sorted(READONLY_CAPABILITIES)
|
||||
or (execution.get("governance") or {}).get("writer_lease") is not False
|
||||
):
|
||||
raise IngressError("TASK_RECOVERY_BINDING_INVALID", 409)
|
||||
response = self._public_response(
|
||||
request["request_id"],
|
||||
task_id,
|
||||
job_id,
|
||||
str(task.get("status") or "BOUND"),
|
||||
)
|
||||
self.store.bound(
|
||||
request["request_id"],
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def create(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
request = normalized_request(raw)
|
||||
task_id = task_id_for(request["request_id"])
|
||||
row = self.store.reserve(request, task_id)
|
||||
if row["state"] == "BOUND" and row["response_json"]:
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
recovered = self._recover_existing(request, row)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
|
||||
snapshot = self.upstream.control_snapshot()
|
||||
base_oid = self._main_oid(snapshot)
|
||||
target_branch = branch_for(task_id)
|
||||
binding_key = "gpt-readonly-binding-" + digest(
|
||||
{"request_id": request["request_id"], "base_oid": base_oid}
|
||||
)[:24]
|
||||
constraints = {
|
||||
**request["constraints"],
|
||||
"repository_mutation": "forbidden",
|
||||
"control_plane_side_effects": "forbidden",
|
||||
"credential_access": "forbidden",
|
||||
}
|
||||
body = {
|
||||
"task_id": task_id,
|
||||
"repository": REPOSITORY,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": base_oid,
|
||||
"target_branch": target_branch,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
"risk": "low",
|
||||
"description": "Externally requested read-only governed Online Task.",
|
||||
"idempotency_key": binding_key,
|
||||
"priority": 0,
|
||||
"max_attempts": 3,
|
||||
"require_online_task_projection": True,
|
||||
"online_task": {
|
||||
"goal": request["goal"],
|
||||
"instructions": request["instructions"],
|
||||
"success_criteria": request["success_criteria"],
|
||||
"constraints": constraints,
|
||||
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||
"priority": 0,
|
||||
"max_attempts": 3,
|
||||
},
|
||||
"execution": {
|
||||
"action": "source_capture",
|
||||
"adapter": "source_capture",
|
||||
"capability_scope": list(READONLY_CAPABILITIES),
|
||||
"source_signals": [
|
||||
"external read-only source analysis request",
|
||||
"no repository mutation no credential access no control-plane side effects",
|
||||
],
|
||||
},
|
||||
}
|
||||
status, value = self.upstream.create_governed_task(body)
|
||||
if status not in {200, 201, 202}:
|
||||
if status == 409:
|
||||
recovered = self._recover_existing(request, row)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise IngressError(
|
||||
"GOVERNED_TASK_CREATE_FAILED",
|
||||
409 if status == 409 else 503,
|
||||
)
|
||||
job_id = str(value.get("job_id") or "")
|
||||
if value.get("task_id") != task_id or JOB_ID_RE.fullmatch(job_id) is None:
|
||||
raise IngressError("GOVERNED_TASK_RESPONSE_INVALID", 502)
|
||||
|
||||
envelope_status, envelope = self.upstream.execution_envelope(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"required_capabilities": list(READONLY_CAPABILITIES),
|
||||
"execution": body["execution"],
|
||||
}
|
||||
)
|
||||
if envelope_status != 200:
|
||||
raise IngressError("EXECUTION_PROJECTION_FAILED", 503)
|
||||
if (
|
||||
envelope.get("task_id") != task_id
|
||||
or envelope.get("job_id") != job_id
|
||||
or envelope.get("base_oid") != base_oid
|
||||
or envelope.get("action") != "source_capture"
|
||||
or sorted(envelope.get("capability_scope") or [])
|
||||
!= sorted(READONLY_CAPABILITIES)
|
||||
or (envelope.get("governance") or {}).get("writer_lease") is not False
|
||||
):
|
||||
raise IngressError("EXECUTION_PROJECTION_INVALID", 502)
|
||||
|
||||
response = self._public_response(
|
||||
request["request_id"],
|
||||
task_id,
|
||||
job_id,
|
||||
"BOUND",
|
||||
)
|
||||
self.store.bound(
|
||||
request["request_id"],
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def status(self, request_id: str) -> dict[str, Any]:
|
||||
if REQUEST_ID_RE.fullmatch(request_id) is None:
|
||||
raise IngressError("REQUEST_ID_INVALID")
|
||||
row = self.store.get(request_id)
|
||||
if row is None:
|
||||
raise IngressError("REQUEST_NOT_FOUND", 404)
|
||||
task_id = str(row["task_id"])
|
||||
status, task = self.upstream.get_task(task_id)
|
||||
if status == 200 and not row["job_id"]:
|
||||
metadata = task.get("metadata")
|
||||
execution = (
|
||||
metadata.get("execution")
|
||||
if isinstance(metadata, Mapping)
|
||||
and isinstance(metadata.get("execution"), Mapping)
|
||||
else {}
|
||||
)
|
||||
job_id = str(execution.get("job_id") or "")
|
||||
base_oid = str(execution.get("base_oid") or "")
|
||||
if (
|
||||
JOB_ID_RE.fullmatch(job_id)
|
||||
and OID_RE.fullmatch(base_oid)
|
||||
and execution.get("action") == "source_capture"
|
||||
and sorted(execution.get("capability_scope") or [])
|
||||
== sorted(READONLY_CAPABILITIES)
|
||||
and (execution.get("governance") or {}).get("writer_lease") is False
|
||||
):
|
||||
recovered = self._public_response(
|
||||
request_id,
|
||||
task_id,
|
||||
job_id,
|
||||
str(task.get("status") or "BOUND"),
|
||||
)
|
||||
self.store.bound(
|
||||
request_id,
|
||||
job_id=job_id,
|
||||
base_oid=base_oid,
|
||||
response=recovered,
|
||||
)
|
||||
row = self.store.get(request_id) or row
|
||||
if status == 404:
|
||||
return {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"state": "PENDING",
|
||||
"official_verification_required": True,
|
||||
}
|
||||
if status != 200:
|
||||
raise IngressError("TASK_STATUS_UNAVAILABLE", 503)
|
||||
|
||||
result = task.get("result") if isinstance(task.get("result"), Mapping) else {}
|
||||
verification = (
|
||||
task.get("verification")
|
||||
if isinstance(task.get("verification"), Mapping)
|
||||
else {}
|
||||
)
|
||||
safe = {
|
||||
"schema": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"job_id": str(row["job_id"] or ""),
|
||||
"state": str(task.get("status") or "UNKNOWN"),
|
||||
"attempt": int(task.get("attempt") or 0),
|
||||
"claimed": bool(task.get("claimed_by")),
|
||||
"result_status": result.get("status"),
|
||||
"result_classification": result.get("classification"),
|
||||
"official_verification": verification.get("decision"),
|
||||
"official_classification": verification.get("classification"),
|
||||
"write_access": False,
|
||||
}
|
||||
if result.get("execution_mode") == "source_capture":
|
||||
snapshot = result.get("source_snapshot")
|
||||
if isinstance(snapshot, Mapping):
|
||||
safe["source_snapshot_sha256"] = snapshot.get("payload_sha256")
|
||||
safe["source_snapshot_file_count"] = snapshot.get("file_count")
|
||||
return safe
|
||||
|
||||
|
||||
def openapi_document() -> dict[str, Any]:
|
||||
return {
|
||||
"openapi": OPENAPI_VERSION,
|
||||
"info": {
|
||||
"title": "AIOS Read-Only Online Task Ingress",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"paths": {
|
||||
"/v1/read-only-tasks": {
|
||||
"post": {
|
||||
"operationId": "createReadOnlyOnlineTask",
|
||||
"security": [{"bearerAuth": []}],
|
||||
"requestBody": {"required": True},
|
||||
"responses": {"200": {"description": "Task accepted"}},
|
||||
}
|
||||
},
|
||||
"/v1/read-only-tasks/{request_id}": {
|
||||
"get": {
|
||||
"operationId": "getReadOnlyOnlineTaskStatus",
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {"200": {"description": "Sanitized task status"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
service: IngressService | None = None
|
||||
ingress_token: str = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def respond(self, value: Mapping[str, Any], status: int) -> None:
|
||||
raw = canonical(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 authorized(self) -> bool:
|
||||
header = self.headers.get("Authorization", "")
|
||||
return (
|
||||
header.startswith("Bearer ")
|
||||
and bool(self.ingress_token)
|
||||
and hmac.compare_digest(header[7:], self.ingress_token)
|
||||
)
|
||||
|
||||
def body(self) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError as exc:
|
||||
raise IngressError("CONTENT_LENGTH_INVALID") from exc
|
||||
if length < 1 or length > MAX_BODY:
|
||||
raise IngressError("REQUEST_SIZE_INVALID", 413)
|
||||
try:
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise IngressError("REQUEST_JSON_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise IngressError("REQUEST_OBJECT_REQUIRED")
|
||||
return dict(value)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self.respond(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-gpt-online-task-ingress",
|
||||
"mode": "read-only",
|
||||
},
|
||||
200,
|
||||
)
|
||||
return
|
||||
if self.path == "/openapi.json":
|
||||
self.respond(openapi_document(), 200)
|
||||
return
|
||||
if not self.authorized():
|
||||
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
match = re.fullmatch(r"/v1/read-only-tasks/([^/?#]+)", self.path)
|
||||
if match is None:
|
||||
self.respond({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
try:
|
||||
if self.service is None:
|
||||
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||
self.respond(self.service.status(match.group(1)), 200)
|
||||
except IngressError as exc:
|
||||
self.respond({"error": exc.code}, exc.status)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/read-only-tasks":
|
||||
self.respond({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if not self.authorized():
|
||||
self.respond({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
try:
|
||||
if self.service is None:
|
||||
raise IngressError("RUNTIME_UNAVAILABLE", 503)
|
||||
value = self.service.create(self.body())
|
||||
self.respond(value, 200)
|
||||
except IngressError as exc:
|
||||
self.respond({"error": exc.code}, exc.status)
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
ingress_token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_INGRESS_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "gpt_ingress_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
control_token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "agent_task_control_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
ingress_token = load_secret(
|
||||
ingress_token_path,
|
||||
code="GPT_INGRESS_CREDENTIAL",
|
||||
)
|
||||
control_token = load_secret(
|
||||
control_token_path,
|
||||
code="CONTROL_PLANE_CREDENTIAL",
|
||||
)
|
||||
|
||||
state_root = Path(
|
||||
os.environ.get(
|
||||
"AIOS_GPT_INGRESS_STATE_ROOT",
|
||||
"/var/lib/aios-gpt-online-task-ingress",
|
||||
)
|
||||
)
|
||||
host = validated_bind_host(
|
||||
os.environ.get("AIOS_GPT_INGRESS_HOST", "127.0.0.1")
|
||||
)
|
||||
port = int(os.environ.get("AIOS_GPT_INGRESS_PORT", "18945"))
|
||||
if not 1024 <= port <= 65535:
|
||||
raise RuntimeError("GPT_INGRESS_PORT_INVALID")
|
||||
|
||||
Handler.service = IngressService(
|
||||
Upstream(
|
||||
bridge_base=os.environ.get(
|
||||
"AIOS_GOVERNED_WRITE_API_URL",
|
||||
"http://127.0.0.1:18892",
|
||||
),
|
||||
task_base=os.environ.get(
|
||||
"AIOS_AGENT_TASK_API_URL",
|
||||
"http://127.0.0.1:18940",
|
||||
),
|
||||
control_token=control_token,
|
||||
),
|
||||
Store(state_root / "state.sqlite3"),
|
||||
)
|
||||
Handler.ingress_token = ingress_token
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -4,6 +4,7 @@ import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
@@ -28,11 +29,18 @@ from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SagaConflict,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
TrustedCandidateBundle,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
@@ -82,13 +90,33 @@ SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
))
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
# This immutable staging API can prepare only a shadow candidate. An
|
||||
# environment value must not turn its HTTP process into a release executor.
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = False
|
||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
TRUSTED_BUILDER_ENABLED = str(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
TRUSTED_BUILDER_POLICY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY', ''
|
||||
))
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY',
|
||||
os.getenv('CREDENTIALS_DIRECTORY', ''),
|
||||
))
|
||||
TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET',
|
||||
'/run/aios-trusted-candidate-builder.sock',
|
||||
))
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
||||
))
|
||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY', ''
|
||||
))
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -166,6 +194,79 @@ def _file_sha256(path: Path) -> str:
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _trusted_release_policy_from_file(path: Path) -> TrustedReleasePolicy:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
required = {
|
||||
'services', 'release_roots', 'cutover_links', 'dropin_paths',
|
||||
'authoritative_git_dirs', 'repository_identities',
|
||||
}
|
||||
if not isinstance(payload, dict) or set(payload) != required:
|
||||
raise ValueError('trusted release policy schema is invalid')
|
||||
values = {}
|
||||
for name in required:
|
||||
raw = payload[name]
|
||||
if not isinstance(raw, list) or not raw or not all(
|
||||
isinstance(item, str) and item for item in raw
|
||||
):
|
||||
raise ValueError('trusted release policy values are invalid')
|
||||
values[name] = frozenset(raw)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
class _HostTrustedCandidateBuilderAdapter:
|
||||
"""Maps only a complete host-owned silent-release bundle response.
|
||||
|
||||
The API never manufactures candidate evidence or a builder signature. A
|
||||
host builder protocol that returns a different response is therefore
|
||||
unavailable, rather than silently downgraded to caller-owned evidence.
|
||||
"""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def build(self, intent):
|
||||
import secrets
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
|
||||
try:
|
||||
version = str(intent['version'])
|
||||
candidate_id = str(intent['candidate_id'])
|
||||
source_commit = str(intent['source_commit'])
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder requires a release worker request'
|
||||
) from exc
|
||||
try:
|
||||
response = self._client.build(
|
||||
version,
|
||||
candidate_id,
|
||||
source_commit,
|
||||
nonce=secrets.token_hex(32),
|
||||
)
|
||||
except BuilderError as exc:
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
'trusted builder request failed'
|
||||
) from exc
|
||||
raw_bundle = response.get('silent_release_bundle')
|
||||
if not isinstance(raw_bundle, dict):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is not a silent release bundle'
|
||||
)
|
||||
try:
|
||||
return TrustedCandidateBundle(
|
||||
candidate=ReleaseCandidate.from_dict(raw_bundle['candidate']),
|
||||
release_plan=ReleasePlan.from_dict(raw_bundle['release_plan']),
|
||||
checks=raw_bundle['checks'],
|
||||
attestation=BuilderAttestation.from_dict(
|
||||
raw_bundle['attestation'], require_fingerprint=True
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder response is invalid'
|
||||
) from exc
|
||||
|
||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
verifier=None
|
||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||
@@ -177,10 +278,86 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
)
|
||||
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
||||
candidate_builder = None
|
||||
builder_attestation_verifier = None
|
||||
trusted_policy = None
|
||||
builder_readiness_probe = None
|
||||
if TRUSTED_BUILDER_ENABLED:
|
||||
# Every path is opt-in. In particular, this API receives its own
|
||||
# client HMAC credential, never the builder's signing private key.
|
||||
if not (
|
||||
TRUSTED_BUILDER_POLICY.is_file()
|
||||
and TRUSTED_BUILDER_CREDENTIALS_DIRECTORY.is_dir()
|
||||
and TRUSTED_BUILDER_PUBLIC_KEYRING.is_file()
|
||||
and TRUSTED_RELEASE_POLICY.is_file()
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is incomplete'
|
||||
)
|
||||
try:
|
||||
from aios_trusted_candidate_builder import (
|
||||
BuilderKeyring,
|
||||
TrustedCandidateBuilderConfig,
|
||||
UnixTrustedCandidateBuilderClient,
|
||||
load_builder_policy,
|
||||
readiness_probe,
|
||||
)
|
||||
builder_policy = load_builder_policy(TRUSTED_BUILDER_POLICY)
|
||||
builder_config = TrustedCandidateBuilderConfig.from_policy(
|
||||
builder_policy
|
||||
)
|
||||
client_hmac = (
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
||||
).read_bytes().strip()
|
||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
builder_config,
|
||||
client_keyring,
|
||||
socket_path=TRUSTED_BUILDER_SOCKET,
|
||||
)
|
||||
candidate_builder = _HostTrustedCandidateBuilderAdapter(client)
|
||||
builder_attestation_verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING
|
||||
)
|
||||
)
|
||||
trusted_policy = _trusted_release_policy_from_file(
|
||||
TRUSTED_RELEASE_POLICY
|
||||
)
|
||||
|
||||
def builder_readiness_probe():
|
||||
if not TRUSTED_BUILDER_SOCKET.exists():
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
remote = dict(readiness_probe(client))
|
||||
if remote.get('policy_sha256') != builder_config.policy_sha256:
|
||||
return {'status': 'UNAVAILABLE'}
|
||||
builder_attestation_verifier.keyring.require_active_identity(
|
||||
key_id=str(remote.get('builder_key_id') or ''),
|
||||
generation=remote.get('builder_key_generation'),
|
||||
algorithm=str(remote.get('signature_algorithm') or ''),
|
||||
purpose=str(remote.get('signature_purpose') or ''),
|
||||
)
|
||||
return {
|
||||
**remote,
|
||||
'hmac_credential_loaded': True,
|
||||
'peer_verification_configured': True,
|
||||
'public_key_trusted': True,
|
||||
'policy_loaded': True,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'trusted builder configuration is invalid'
|
||||
) from exc
|
||||
return SilentReleaseActionService(
|
||||
SILENT_RELEASE_STATE_ROOT,
|
||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||
approval_verifier=verifier,
|
||||
candidate_builder=candidate_builder,
|
||||
builder_attestation_verifier=builder_attestation_verifier,
|
||||
trusted_policy=trusted_policy,
|
||||
builder_readiness_probe=builder_readiness_probe,
|
||||
allow_legacy_candidate_input=False,
|
||||
expected_repository=REPOSITORY_IDENTITY,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
@@ -466,8 +643,11 @@ class H(BaseHTTPRequestHandler):
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
if path in ('/health','/api/health'):
|
||||
builder_readiness = silent_release_service().get_builder_readiness()
|
||||
return self.sendj({
|
||||
'status': 'ok',
|
||||
'process_liveness': 'PASS',
|
||||
'builder_readiness': builder_readiness,
|
||||
'service': os.getenv('AIOS_GITOPS_MODEL_API_SERVICE', 'aios-gitops-model-api-v2r2c1-staging.service'),
|
||||
'workspace': str(WS),
|
||||
'version': 'V2R2C1',
|
||||
@@ -706,8 +886,15 @@ class H(BaseHTTPRequestHandler):
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
service = silent_release_service()
|
||||
readiness = service.get_builder_readiness()
|
||||
if readiness.get('status') != 'READY':
|
||||
return self.sendj({
|
||||
'error': 'TRUSTED_BUILDER_UNAVAILABLE',
|
||||
'builder_readiness': readiness,
|
||||
}, 503)
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
service.create_release_candidate(data)
|
||||
)
|
||||
if path == '/createReleaseIntent':
|
||||
a=self.need('release:candidate')
|
||||
@@ -747,6 +934,13 @@ class H(BaseHTTPRequestHandler):
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},403)
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
monitor.record_failure(
|
||||
'do_POST', 'TRUSTED_BUILDER_UNAVAILABLE', {'path': path}
|
||||
)
|
||||
return self.sendj(
|
||||
{'error': 'TRUSTED_BUILDER_UNAVAILABLE'}, 503
|
||||
)
|
||||
except SagaConflict as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-owned Model Governance configuration reconciler.
|
||||
|
||||
The config contains provider/model/policy metadata only. Raw credentials are rejected by
|
||||
aios_model_governance and never stored in the governance database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_model_governance import (
|
||||
ModelGovernanceError,
|
||||
ModelGovernanceStore,
|
||||
ModelRecord,
|
||||
ProviderRecord,
|
||||
ScopePolicy,
|
||||
)
|
||||
|
||||
SCHEMA = "aios.model-governance.bootstrap.v1"
|
||||
MAX_CONFIG_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_CONFIG_BYTES:
|
||||
fail("MODEL_GOVERNANCE_CONFIG_INVALID")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
fail("MODEL_GOVERNANCE_CONFIG_INVALID")
|
||||
if not isinstance(value, Mapping) or value.get("schema") != SCHEMA:
|
||||
fail("MODEL_GOVERNANCE_CONFIG_SCHEMA_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _provider_desired(value: Mapping[str, Any]) -> ProviderRecord:
|
||||
return ProviderRecord(
|
||||
provider_id=str(value.get("provider_id") or ""),
|
||||
credential_ref=value.get("credential_ref"),
|
||||
enabled=bool(value.get("enabled", True)),
|
||||
failure_domain=str(value.get("failure_domain") or "default"),
|
||||
max_failures=int(value.get("max_failures") or 3),
|
||||
cooldown_seconds=int(value.get("cooldown_seconds") or 60),
|
||||
)
|
||||
|
||||
|
||||
def _model_desired(value: Mapping[str, Any]) -> ModelRecord:
|
||||
return ModelRecord(
|
||||
model_id=str(value.get("model_id") or ""),
|
||||
provider_id=str(value.get("provider_id") or ""),
|
||||
version=str(value.get("version") or ""),
|
||||
capabilities=tuple(value.get("capabilities") or ()),
|
||||
input_cost_micros_per_1k=int(value.get("input_cost_micros_per_1k") or 0),
|
||||
output_cost_micros_per_1k=int(value.get("output_cost_micros_per_1k") or 0),
|
||||
max_input_tokens=int(value.get("max_input_tokens") or 0),
|
||||
max_output_tokens=int(value.get("max_output_tokens") or 0),
|
||||
evaluation_state=str(value.get("evaluation_state") or "APPROVED"),
|
||||
evaluation_sha256=str(value.get("evaluation_sha256") or ""),
|
||||
enabled=bool(value.get("enabled", True)),
|
||||
priority=int(value.get("priority") or 100),
|
||||
)
|
||||
|
||||
|
||||
def _policy_desired(value: Mapping[str, Any]) -> ScopePolicy:
|
||||
return ScopePolicy(
|
||||
scope_type=str(value.get("scope_type") or ""),
|
||||
tenant_id=value.get("tenant_id"),
|
||||
project_id=value.get("project_id"),
|
||||
allowed_models=tuple(value.get("allowed_models") or ()),
|
||||
denied_providers=tuple(value.get("denied_providers") or ()),
|
||||
required_capabilities=tuple(value.get("required_capabilities") or ()),
|
||||
pinned_model_id=value.get("pinned_model_id"),
|
||||
pinned_model_version=value.get("pinned_model_version"),
|
||||
max_input_tokens_per_request=int(value.get("max_input_tokens_per_request") or 0),
|
||||
max_output_tokens_per_request=int(value.get("max_output_tokens_per_request") or 0),
|
||||
daily_token_budget=int(value.get("daily_token_budget") or 0),
|
||||
daily_cost_budget_micros=int(value.get("daily_cost_budget_micros") or 0),
|
||||
fallback_limit=int(value.get("fallback_limit") or 0),
|
||||
)
|
||||
|
||||
|
||||
def _row_dict(row: object) -> dict[str, Any] | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def _provider_matches(row: Mapping[str, Any], desired: ProviderRecord) -> bool:
|
||||
return (
|
||||
row["credential_ref"] == desired.credential_ref
|
||||
and bool(row["enabled"]) == desired.enabled
|
||||
and row["failure_domain"] == desired.failure_domain
|
||||
and int(row["max_failures"]) == desired.max_failures
|
||||
and int(row["cooldown_seconds"]) == desired.cooldown_seconds
|
||||
)
|
||||
|
||||
|
||||
def _model_matches(row: Mapping[str, Any], desired: ModelRecord) -> bool:
|
||||
return (
|
||||
row["provider_id"] == desired.provider_id
|
||||
and row["version"] == desired.version
|
||||
and tuple(json.loads(row["capabilities_json"])) == tuple(sorted(set(desired.capabilities)))
|
||||
and int(row["input_cost_micros_per_1k"]) == desired.input_cost_micros_per_1k
|
||||
and int(row["output_cost_micros_per_1k"]) == desired.output_cost_micros_per_1k
|
||||
and int(row["max_input_tokens"]) == desired.max_input_tokens
|
||||
and int(row["max_output_tokens"]) == desired.max_output_tokens
|
||||
and row["evaluation_state"] == desired.evaluation_state
|
||||
and row["evaluation_sha256"] == desired.evaluation_sha256
|
||||
and bool(row["enabled"]) == desired.enabled
|
||||
and int(row["priority"]) == desired.priority
|
||||
)
|
||||
|
||||
|
||||
def _policy_matches(row: Mapping[str, Any], desired: ScopePolicy) -> bool:
|
||||
return (
|
||||
row["scope_type"] == desired.scope_type
|
||||
and row["tenant_id"] == desired.tenant_id
|
||||
and row["project_id"] == desired.project_id
|
||||
and tuple(json.loads(row["allowed_models_json"])) == tuple(sorted(set(desired.allowed_models)))
|
||||
and tuple(json.loads(row["denied_providers_json"])) == tuple(sorted(set(desired.denied_providers)))
|
||||
and tuple(json.loads(row["required_capabilities_json"])) == tuple(sorted(set(desired.required_capabilities)))
|
||||
and row["pinned_model_id"] == desired.pinned_model_id
|
||||
and row["pinned_model_version"] == desired.pinned_model_version
|
||||
and int(row["max_input_tokens_per_request"]) == desired.max_input_tokens_per_request
|
||||
and int(row["max_output_tokens_per_request"]) == desired.max_output_tokens_per_request
|
||||
and int(row["daily_token_budget"]) == desired.daily_token_budget
|
||||
and int(row["daily_cost_budget_micros"]) == desired.daily_cost_budget_micros
|
||||
and int(row["fallback_limit"]) == desired.fallback_limit
|
||||
)
|
||||
|
||||
|
||||
def apply_config(db_path: Path, config_path: Path) -> dict[str, Any]:
|
||||
config = _load(config_path)
|
||||
config_sha = hashlib.sha256(canonical(config)).hexdigest()
|
||||
providers = config.get("providers") or []
|
||||
models = config.get("models") or []
|
||||
policies = config.get("policies") or []
|
||||
if not all(isinstance(value, list) for value in (providers, models, policies)):
|
||||
fail("MODEL_GOVERNANCE_CONFIG_COLLECTION_INVALID")
|
||||
|
||||
store = ModelGovernanceStore(db_path)
|
||||
changes = {"providers": 0, "models": 0, "policies": 0, "unchanged": 0}
|
||||
try:
|
||||
for item in providers:
|
||||
if not isinstance(item, Mapping):
|
||||
fail("MODEL_GOVERNANCE_PROVIDER_CONFIG_INVALID")
|
||||
desired = _provider_desired(item)
|
||||
row = store.conn.execute(
|
||||
"SELECT * FROM model_governance_providers WHERE provider_id=?",
|
||||
(desired.provider_id,),
|
||||
).fetchone()
|
||||
if row is not None and _provider_matches(row, desired):
|
||||
changes["unchanged"] += 1
|
||||
continue
|
||||
expected = int(row["revision"]) if row is not None else 0
|
||||
store.register_provider(desired, expected_revision=expected)
|
||||
changes["providers"] += 1
|
||||
|
||||
for item in models:
|
||||
if not isinstance(item, Mapping):
|
||||
fail("MODEL_GOVERNANCE_MODEL_CONFIG_INVALID")
|
||||
desired = _model_desired(item)
|
||||
row = store.conn.execute(
|
||||
"SELECT * FROM model_governance_models WHERE model_id=?",
|
||||
(desired.model_id,),
|
||||
).fetchone()
|
||||
if row is not None and _model_matches(row, desired):
|
||||
changes["unchanged"] += 1
|
||||
continue
|
||||
expected = int(row["revision"]) if row is not None else 0
|
||||
store.register_model(desired, expected_revision=expected)
|
||||
changes["models"] += 1
|
||||
|
||||
for item in policies:
|
||||
if not isinstance(item, Mapping):
|
||||
fail("MODEL_GOVERNANCE_POLICY_CONFIG_INVALID")
|
||||
desired = _policy_desired(item)
|
||||
row = store.conn.execute(
|
||||
"SELECT * FROM model_governance_scope_policies WHERE scope_key=?",
|
||||
(desired.scope_key,),
|
||||
).fetchone()
|
||||
if row is not None and _policy_matches(row, desired):
|
||||
changes["unchanged"] += 1
|
||||
continue
|
||||
expected = int(row["revision"]) if row is not None else 0
|
||||
store.set_scope_policy(desired, expected_revision=expected)
|
||||
changes["policies"] += 1
|
||||
|
||||
if not store.verify_audit_chain():
|
||||
fail("MODEL_GOVERNANCE_AUDIT_CHAIN_INVALID")
|
||||
return {
|
||||
"schema": "aios.model-governance.bootstrap-result.v1",
|
||||
"database": str(db_path),
|
||||
"config_sha256": config_sha,
|
||||
"changes": changes,
|
||||
"audit_chain_valid": True,
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--db", required=True)
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--output")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = apply_config(Path(args.db), Path(args.config))
|
||||
except ModelGovernanceError as exc:
|
||||
print(json.dumps({"status": "FAILED", "error": exc.code}, sort_keys=True))
|
||||
return 20
|
||||
data = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||
if args.output:
|
||||
Path(args.output).write_text(data, encoding="utf-8")
|
||||
print(data, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -179,7 +179,10 @@ def _agent_runtime_input(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
raw = dict(value)
|
||||
allowed = {"goal", "instructions", "success_criteria", "constraints"}
|
||||
allowed = {
|
||||
"goal", "instructions", "success_criteria", "constraints",
|
||||
"scope", "trace_id",
|
||||
}
|
||||
if set(raw) - allowed:
|
||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
||||
@@ -208,11 +211,34 @@ def _agent_runtime_input(value: object) -> dict[str, Any]:
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in sensitive_markers):
|
||||
_error("AGENT_RUNTIME_SECRET_FIELD_DENY")
|
||||
|
||||
scope = raw.get("scope", {})
|
||||
if not isinstance(scope, Mapping):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
scope = {
|
||||
"scope_type": str(scope.get("scope_type") or "legacy"),
|
||||
"tenant_id": scope.get("tenant_id"),
|
||||
"project_id": scope.get("project_id"),
|
||||
}
|
||||
if scope["scope_type"] not in {"legacy", "system", "tenant"}:
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
if scope["scope_type"] == "tenant":
|
||||
if not isinstance(scope["tenant_id"], str) or not scope["tenant_id"].strip():
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
elif scope["tenant_id"] not in (None, "") or scope["project_id"] not in (None, ""):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
|
||||
trace_id = str(raw.get("trace_id") or "").strip()
|
||||
if not trace_id or len(trace_id) > 255:
|
||||
_error("AGENT_RUNTIME_TRACE_ID_INVALID")
|
||||
|
||||
normalized = {
|
||||
"goal": goal[:12000],
|
||||
"instructions": instructions[:32000],
|
||||
"success_criteria": normalized_criteria,
|
||||
"constraints": dict(constraints),
|
||||
"scope": scope,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
if len(_canonical(normalized).encode("utf-8")) > MAX_AGENT_RUNTIME_INPUT_BYTES:
|
||||
_error("AGENT_RUNTIME_INPUT_TOO_LARGE")
|
||||
@@ -425,6 +451,12 @@ def project_online_task(
|
||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
||||
runtime_input = execution_value.get("runtime_input")
|
||||
tenant_id = request.get("tenant_id")
|
||||
project_id = request.get("project_id")
|
||||
declared_scope = str(
|
||||
request.get("scope_type")
|
||||
or ("tenant" if isinstance(tenant_id, str) and tenant_id.strip() else "legacy")
|
||||
)
|
||||
if runtime_input is None:
|
||||
runtime_input = {
|
||||
"goal": request.get("goal"),
|
||||
@@ -432,6 +464,20 @@ def project_online_task(
|
||||
"success_criteria": request.get("success_criteria") or [],
|
||||
"constraints": request.get("constraints") or {},
|
||||
}
|
||||
elif not isinstance(runtime_input, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
runtime_input = dict(runtime_input)
|
||||
# Scope/trace are authoritative Task identity, never caller-selected runtime hints.
|
||||
runtime_input["scope"] = {
|
||||
"scope_type": declared_scope,
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": project_id,
|
||||
}
|
||||
runtime_input["trace_id"] = str(
|
||||
request.get("trace_id")
|
||||
or request.get("run_id")
|
||||
or binding.task_id
|
||||
)
|
||||
execution_value["runtime_input"] = runtime_input
|
||||
execution = project_execution_envelope(
|
||||
binding,
|
||||
|
||||
@@ -583,11 +583,44 @@ class ExecutionEnvelope:
|
||||
)
|
||||
|
||||
|
||||
def external_agent_request_id(
|
||||
envelope: ExecutionEnvelope,
|
||||
runtime_name: str,
|
||||
attempt: int,
|
||||
) -> str:
|
||||
if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1:
|
||||
raise WorkerError("TASK_ATTEMPT_INVALID", phase="external_agent")
|
||||
if runtime_name not in {"hermes", "openclaw"}:
|
||||
raise WorkerError(
|
||||
"EXTERNAL_AGENT_RUNTIME_UNSUPPORTED",
|
||||
phase="external_agent",
|
||||
details={"runtime": runtime_name},
|
||||
)
|
||||
return (
|
||||
f"external-{runtime_name}-"
|
||||
+ digest_json(
|
||||
{
|
||||
"task_id": envelope.task_id,
|
||||
"job_id": envelope.job_id,
|
||||
"execution_digest": envelope.digest,
|
||||
"runtime": runtime_name,
|
||||
"attempt": attempt,
|
||||
}
|
||||
)[:32]
|
||||
)
|
||||
|
||||
|
||||
class RuntimeAdapter(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -608,7 +641,13 @@ class DeterministicExecutor(RuntimeAdapter):
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
changed: set[str] = set()
|
||||
for change in envelope.change_set:
|
||||
relative = str(change["path"])
|
||||
@@ -663,7 +702,13 @@ class SourceCaptureExecutor(RuntimeAdapter):
|
||||
"governed", "trace", "tool", "credential", "scope", "message", "handoff",
|
||||
}
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
terms = set(self.defaults)
|
||||
for signal in envelope.source_signals:
|
||||
terms.update(token.lower() for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal))
|
||||
@@ -732,7 +777,13 @@ class CompactInterfaceMapExecutor(RuntimeAdapter):
|
||||
|
||||
name = "compact_interface_map"
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
if envelope.action != "source_capture":
|
||||
raise WorkerError("COMPACT_INTERFACE_MAP_ACTION_INVALID", phase="interface_map")
|
||||
try:
|
||||
@@ -762,7 +813,13 @@ class CompactInterfaceMapExecutor(RuntimeAdapter):
|
||||
class CodexExecutor(RuntimeAdapter):
|
||||
name = "codex"
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
raise WorkerError(
|
||||
"CODEX_ADAPTER_REQUIRES_EXPLICIT_POLICY",
|
||||
phase="adapter",
|
||||
@@ -773,7 +830,13 @@ class CodexExecutor(RuntimeAdapter):
|
||||
class ExternalAgentExecutor(RuntimeAdapter):
|
||||
runtime_name: str
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
if envelope.action != "agent_runtime":
|
||||
raise WorkerError(
|
||||
"EXTERNAL_AGENT_ACTION_INVALID",
|
||||
@@ -787,16 +850,10 @@ class ExternalAgentExecutor(RuntimeAdapter):
|
||||
)
|
||||
if not envelope.runtime_input:
|
||||
raise WorkerError("EXTERNAL_AGENT_RUNTIME_INPUT_MISSING", phase="adapter")
|
||||
request_id = (
|
||||
f"external-{self.runtime_name}-"
|
||||
+ digest_json(
|
||||
{
|
||||
"task_id": envelope.task_id,
|
||||
"job_id": envelope.job_id,
|
||||
"execution_digest": envelope.digest,
|
||||
"runtime": self.runtime_name,
|
||||
}
|
||||
)[:32]
|
||||
request_id = external_agent_request_id(
|
||||
envelope,
|
||||
self.runtime_name,
|
||||
attempt,
|
||||
)
|
||||
exchange = ExternalAgentExchange(EXTERNAL_AGENT_EXECUTOR_ROOT)
|
||||
try:
|
||||
@@ -804,6 +861,7 @@ class ExternalAgentExecutor(RuntimeAdapter):
|
||||
request_id=request_id,
|
||||
runtime=self.runtime_name,
|
||||
task_id=envelope.task_id,
|
||||
attempt=attempt,
|
||||
execution_digest=envelope.digest,
|
||||
payload=envelope.runtime_input,
|
||||
timeout_seconds=EXTERNAL_AGENT_EXECUTOR_TIMEOUT_SECONDS,
|
||||
@@ -819,6 +877,7 @@ class ExternalAgentExecutor(RuntimeAdapter):
|
||||
task_id=envelope.task_id,
|
||||
adapter=self.runtime_name,
|
||||
request_id=request_id,
|
||||
attempt=attempt,
|
||||
output_sha256=external.get("output_sha256"),
|
||||
duration_ms=external.get("duration_ms"),
|
||||
credential_material_exposed=False,
|
||||
@@ -1354,7 +1413,13 @@ class IsolatedDeterministicExecutor(RuntimeAdapter):
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
||||
def execute(
|
||||
self,
|
||||
envelope: ExecutionEnvelope,
|
||||
worktree: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
) -> dict[str, Any]:
|
||||
if envelope.action != "deterministic":
|
||||
raise WorkerError("ISOLATED_EXECUTOR_ACTION_INVALID", phase="adapter")
|
||||
root = ISOLATED_EXECUTOR_ROOT
|
||||
@@ -2193,7 +2258,11 @@ class WorkerSupervisor:
|
||||
save_task_state(task_id, state)
|
||||
save_checkpoint(task_id, state, envelope, attempt)
|
||||
adapter = adapter_for(envelope.adapter)
|
||||
execution = adapter.execute(envelope, worktree)
|
||||
execution = adapter.execute(
|
||||
envelope,
|
||||
worktree,
|
||||
attempt=attempt,
|
||||
)
|
||||
state.update(
|
||||
{
|
||||
"phase": "verification_passed",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Immutable release-lineage contracts used by activation and recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseLineage:
|
||||
task_id: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
version: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
FIXED_V2R2C2 = ReleaseLineage(
|
||||
task_id="r42-release-candidate-v2r2c2-live-20260819",
|
||||
candidate_id="r42-release-candidate-v2r2c2-20260819",
|
||||
source_commit="71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
version="V2R2C2",
|
||||
required_capabilities=("release:candidate",),
|
||||
)
|
||||
|
||||
|
||||
class ReleaseLineageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_fixed_v2r2c2_task(task: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate identity/source/version without mutating Task state.
|
||||
|
||||
This contract intentionally does not bind status/attempt/revision, because
|
||||
those are lifecycle state and may change through governed retry/recovery.
|
||||
"""
|
||||
|
||||
if str(task.get("task_id") or "") != FIXED_V2R2C2.task_id:
|
||||
raise ReleaseLineageError("FIXED_TASK_ID_DRIFT")
|
||||
|
||||
capabilities = task.get("required_capabilities")
|
||||
if (
|
||||
not isinstance(capabilities, list)
|
||||
or tuple(capabilities) != FIXED_V2R2C2.required_capabilities
|
||||
):
|
||||
raise ReleaseLineageError("FIXED_TASK_CAPABILITY_DRIFT")
|
||||
|
||||
constraints = task.get("constraints")
|
||||
if not isinstance(constraints, Mapping):
|
||||
raise ReleaseLineageError("FIXED_TASK_CONSTRAINTS_MISSING")
|
||||
release = constraints.get("release_candidate")
|
||||
if not isinstance(release, Mapping):
|
||||
raise ReleaseLineageError("FIXED_RELEASE_BINDING_MISSING")
|
||||
|
||||
actual = {
|
||||
"candidate_id": str(release.get("candidate_id") or ""),
|
||||
"source_commit": str(release.get("source_commit") or ""),
|
||||
"version": str(release.get("version") or ""),
|
||||
}
|
||||
expected = {
|
||||
"candidate_id": FIXED_V2R2C2.candidate_id,
|
||||
"source_commit": FIXED_V2R2C2.source_commit,
|
||||
"version": FIXED_V2R2C2.version,
|
||||
}
|
||||
if actual != expected:
|
||||
raise ReleaseLineageError(
|
||||
"FIXED_RELEASE_LINEAGE_DRIFT:"
|
||||
+ repr({"expected": expected, "actual": actual})
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "aios.release-lineage.validation.v1",
|
||||
"status": "VERIFIED",
|
||||
"lineage": FIXED_V2R2C2.as_dict(),
|
||||
"task_status": task.get("status"),
|
||||
"attempt": task.get("attempt"),
|
||||
"max_attempts": task.get("max_attempts"),
|
||||
"revision": task.get("revision"),
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent release-preparation Online Agent worker.
|
||||
|
||||
The worker is intentionally narrower than the generic engineering worker:
|
||||
it can only consume tasks whose required capability set is exactly
|
||||
{"release:candidate"} and whose payload asks for immutable release-candidate
|
||||
creation. It never receives Owner signing, deployment, tag, baseline or Git
|
||||
write credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
from aios_autonomous_control_plane import (
|
||||
classify_failure,
|
||||
repair_for_failure,
|
||||
runtime_admission,
|
||||
)
|
||||
|
||||
AGENT_ID = os.environ.get(
|
||||
"AIOS_RELEASE_WORKER_AGENT_ID",
|
||||
"online-release-worker-cn-network-mkt-20260819",
|
||||
).strip()
|
||||
AGENT_VERSION = os.environ.get(
|
||||
"AIOS_RELEASE_WORKER_AGENT_VERSION",
|
||||
"2026-08-19.1-release-candidate",
|
||||
).strip()
|
||||
TASK_API = os.environ.get(
|
||||
"AIOS_AGENT_TASK_API_URL",
|
||||
"http://127.0.0.1:18940",
|
||||
).rstrip("/")
|
||||
RELEASE_API = os.environ.get("AIOS_RELEASE_API_URL", "").rstrip("/")
|
||||
POLL_SECONDS = int(os.environ.get("AIOS_RELEASE_WORKER_POLL_SECONDS", "3"))
|
||||
LEASE_SECONDS = int(os.environ.get("AIOS_RELEASE_WORKER_LEASE_SECONDS", "60"))
|
||||
HEARTBEAT_SECONDS = int(
|
||||
os.environ.get("AIOS_RELEASE_WORKER_HEARTBEAT_SECONDS", "15")
|
||||
)
|
||||
STATE_ROOT = Path(
|
||||
os.environ.get(
|
||||
"AIOS_RELEASE_WORKER_STATE_ROOT",
|
||||
"/var/lib/aios-online-agent-release",
|
||||
)
|
||||
)
|
||||
EXPECTED_CAPABILITIES = frozenset({"release:candidate"})
|
||||
LIVENESS_PATH = Path(
|
||||
os.environ.get(
|
||||
"AIOS_WORKER_LIVENESS_PATH",
|
||||
str(STATE_ROOT / "worker-liveness.json"),
|
||||
)
|
||||
)
|
||||
CANDIDATE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,255}$")
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
VERSION_RE = re.compile(r"^V[0-9]+R[0-9]+C[0-9]+$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
SECRET_MARKERS = (
|
||||
"authorization",
|
||||
"token",
|
||||
"secret",
|
||||
"credential",
|
||||
"password",
|
||||
"private_key",
|
||||
"signature",
|
||||
)
|
||||
|
||||
|
||||
class ReleaseWorkerError(RuntimeError):
|
||||
def __init__(self, code: str, *, phase: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.phase = phase
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sanitized(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
result: dict[str, Any] = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in SECRET_MARKERS):
|
||||
result[str(key)] = "[REDACTED]"
|
||||
else:
|
||||
result[str(key)] = sanitized(inner)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [sanitized(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def audit(event: str, **fields: Any) -> None:
|
||||
STATE_ROOT.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
path = STATE_ROOT / "events.jsonl"
|
||||
record = {
|
||||
"schema": "aios.release-worker.event.v1",
|
||||
"event": event,
|
||||
"created_at": now(),
|
||||
"agent_id": AGENT_ID,
|
||||
**sanitized(fields),
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
os.chmod(path, 0o640)
|
||||
|
||||
|
||||
def sd_notify(message: str) -> None:
|
||||
target = os.environ.get("NOTIFY_SOCKET", "")
|
||||
if not target:
|
||||
return
|
||||
if target.startswith("@"):
|
||||
address = "\0" + target[1:]
|
||||
else:
|
||||
address = target
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(address)
|
||||
sock.sendall(message.encode("utf-8"))
|
||||
except OSError:
|
||||
# Liveness JSON remains the diagnostic source; systemd will enforce
|
||||
# WatchdogSec if notify transport is persistently broken.
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def write_liveness(
|
||||
state: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
stage: str | None = None,
|
||||
error: str | None = None,
|
||||
visible_count: int | None = None,
|
||||
) -> None:
|
||||
STATE_ROOT.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
value = {
|
||||
"schema": "aios.online-agent.worker-liveness.v1",
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_type": "release-preparation-worker",
|
||||
"agent_version": AGENT_VERSION,
|
||||
"pid": os.getpid(),
|
||||
"state": state,
|
||||
"last_seen_epoch": time.time(),
|
||||
"last_seen_at": now(),
|
||||
"poll_seconds": POLL_SECONDS,
|
||||
"heartbeat_seconds": HEARTBEAT_SECONDS,
|
||||
"api_base": TASK_API,
|
||||
"capabilities": sorted(EXPECTED_CAPABILITIES),
|
||||
"task_id": task_id,
|
||||
"stage": stage,
|
||||
"error": error,
|
||||
"visible_count": visible_count,
|
||||
}
|
||||
LIVENESS_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
temporary = LIVENESS_PATH.with_name(
|
||||
"." + LIVENESS_PATH.name + f".{os.getpid()}.tmp"
|
||||
)
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, LIVENESS_PATH)
|
||||
|
||||
|
||||
def load_credential(name: str) -> str:
|
||||
directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||
path = directory / name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseWorkerError(
|
||||
"CREDENTIAL_UNAVAILABLE:" + name,
|
||||
phase="startup",
|
||||
)
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 24 or any(ch.isspace() for ch in value):
|
||||
raise ReleaseWorkerError(
|
||||
"CREDENTIAL_INVALID:" + name,
|
||||
phase="startup",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def http_json(
|
||||
base: str,
|
||||
method: str,
|
||||
route: str,
|
||||
token: str,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
data = None if body is None else canonical(body)
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
method=method,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw, status = response.read(), response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw, status = exc.read(), exc.code
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise ReleaseWorkerError(
|
||||
"HTTP_TRANSPORT_UNAVAILABLE",
|
||||
phase="network",
|
||||
) from exc
|
||||
try:
|
||||
value = json.loads(raw.decode())
|
||||
except Exception as exc:
|
||||
raise ReleaseWorkerError(
|
||||
"HTTP_RESPONSE_INVALID",
|
||||
phase="network",
|
||||
) from exc
|
||||
return status, value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseRequest:
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
version: str
|
||||
projection_id: str | None
|
||||
|
||||
@classmethod
|
||||
def from_task(cls, task: Mapping[str, Any]) -> "ReleaseRequest":
|
||||
if not TASK_RE.fullmatch(str(task.get("task_id") or "")):
|
||||
raise ReleaseWorkerError("TASK_ID_INVALID", phase="validation")
|
||||
caps = task.get("required_capabilities")
|
||||
if (
|
||||
not isinstance(caps, list)
|
||||
or frozenset(caps) != EXPECTED_CAPABILITIES
|
||||
or len(caps) != 1
|
||||
):
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_CAPABILITY_SCOPE_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
metadata = task.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
metadata = {}
|
||||
constraints = task.get("constraints")
|
||||
if not isinstance(constraints, Mapping):
|
||||
constraints = {}
|
||||
release = constraints.get("release_candidate")
|
||||
if not isinstance(release, Mapping):
|
||||
release = metadata.get("release_candidate")
|
||||
if not isinstance(release, Mapping):
|
||||
release = task.get("release_candidate")
|
||||
if not isinstance(release, Mapping):
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_CANDIDATE_PAYLOAD_MISSING",
|
||||
phase="validation",
|
||||
)
|
||||
allowed = {
|
||||
"candidate_id",
|
||||
"source_commit",
|
||||
"version",
|
||||
"projection_id",
|
||||
}
|
||||
if set(release) - allowed:
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_CANDIDATE_PAYLOAD_FIELDS_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
candidate_id = str(release.get("candidate_id") or "")
|
||||
source_commit = str(release.get("source_commit") or "")
|
||||
version = str(release.get("version") or "")
|
||||
projection_id = release.get("projection_id")
|
||||
if CANDIDATE_RE.fullmatch(candidate_id) is None:
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_CANDIDATE_ID_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
if OID_RE.fullmatch(source_commit) is None:
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_SOURCE_COMMIT_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
if VERSION_RE.fullmatch(version) is None:
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_VERSION_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
if projection_id is not None:
|
||||
projection_id = str(projection_id)
|
||||
if not re.fullmatch(r"projection-[0-9a-f]{32}", projection_id):
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_PROJECTION_ID_INVALID",
|
||||
phase="validation",
|
||||
)
|
||||
return cls(
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
version=version,
|
||||
projection_id=projection_id,
|
||||
)
|
||||
|
||||
def body(self) -> dict[str, str]:
|
||||
return {
|
||||
"candidate_id": self.candidate_id,
|
||||
"source_commit": self.source_commit,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
def __init__(
|
||||
self,
|
||||
agent_token: str,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
) -> None:
|
||||
self.agent_token = agent_token
|
||||
self.task_id = task_id
|
||||
self.lease_token = lease_token
|
||||
self.stage = "claimed"
|
||||
self.failure: str | None = None
|
||||
self.stop = threading.Event()
|
||||
self.thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="release-worker-heartbeat",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def first(self) -> None:
|
||||
status, value = http_json(
|
||||
TASK_API,
|
||||
"POST",
|
||||
f"/v1/tasks/{self.task_id}/heartbeat",
|
||||
self.agent_token,
|
||||
{
|
||||
"lease_token": self.lease_token,
|
||||
"lease_seconds": LEASE_SECONDS,
|
||||
"progress": {
|
||||
"stage": self.stage,
|
||||
"worker_version": AGENT_VERSION,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_HEARTBEAT_FAILED",
|
||||
phase="heartbeat",
|
||||
)
|
||||
audit(
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=self.task_id,
|
||||
stage=self.stage,
|
||||
api_status=status,
|
||||
)
|
||||
write_liveness(
|
||||
"BUSY",
|
||||
task_id=self.task_id,
|
||||
stage=self.stage,
|
||||
)
|
||||
sd_notify("WATCHDOG=1")
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self.stop.wait(HEARTBEAT_SECONDS):
|
||||
try:
|
||||
status, value = http_json(
|
||||
TASK_API,
|
||||
"POST",
|
||||
f"/v1/tasks/{self.task_id}/heartbeat",
|
||||
self.agent_token,
|
||||
{
|
||||
"lease_token": self.lease_token,
|
||||
"lease_seconds": LEASE_SECONDS,
|
||||
"progress": {
|
||||
"stage": self.stage,
|
||||
"worker_version": AGENT_VERSION,
|
||||
},
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
self.failure = str(value.get("error") or status)
|
||||
return
|
||||
audit(
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=self.task_id,
|
||||
stage=self.stage,
|
||||
api_status=status,
|
||||
)
|
||||
write_liveness(
|
||||
"BUSY",
|
||||
task_id=self.task_id,
|
||||
stage=self.stage,
|
||||
)
|
||||
sd_notify("WATCHDOG=1")
|
||||
except Exception as exc:
|
||||
self.failure = type(exc).__name__
|
||||
return
|
||||
|
||||
def ensure(self) -> None:
|
||||
if self.failure:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_LEASE_LOST",
|
||||
phase="heartbeat",
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop.set()
|
||||
if self.thread.ident is not None:
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
|
||||
def release_admission(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
release_token: str,
|
||||
):
|
||||
required = task.get("required_capabilities")
|
||||
if not isinstance(required, list):
|
||||
required = []
|
||||
|
||||
def release_api_health() -> bool:
|
||||
status, value = http_json(
|
||||
RELEASE_API,
|
||||
"GET",
|
||||
"/health",
|
||||
"",
|
||||
timeout=10,
|
||||
)
|
||||
return status == 200 and str(value.get("status") or "").lower() in {"ok", "healthy"}
|
||||
|
||||
def authenticated_probe() -> bool:
|
||||
status, _ = http_json(
|
||||
RELEASE_API,
|
||||
"POST",
|
||||
"/createReleaseCandidate",
|
||||
release_token,
|
||||
{},
|
||||
timeout=10,
|
||||
)
|
||||
# Authentication + credential projection succeeded; the empty release
|
||||
# request is intentionally rejected by request validation.
|
||||
return status == 400
|
||||
|
||||
probes = {
|
||||
"credential_projection": authenticated_probe,
|
||||
"trusted_builder": release_api_health,
|
||||
"authenticated_probe": authenticated_probe,
|
||||
"release_api_health": release_api_health,
|
||||
"worker_liveness": lambda: True,
|
||||
}
|
||||
return runtime_admission(
|
||||
worker_capabilities=EXPECTED_CAPABILITIES,
|
||||
required_capabilities=required,
|
||||
requested_operations=("prepare_release",),
|
||||
probes=probes,
|
||||
)
|
||||
|
||||
|
||||
def result_payload(
|
||||
task_id: str,
|
||||
release: ReleaseRequest,
|
||||
builder: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
builder_candidate = builder.get("candidate_id")
|
||||
if builder_candidate not in {None, release.candidate_id}:
|
||||
raise ReleaseWorkerError(
|
||||
"BUILDER_CANDIDATE_BINDING_INVALID",
|
||||
phase="release",
|
||||
)
|
||||
immutable = {
|
||||
"candidate_id": release.candidate_id,
|
||||
"source_commit": release.source_commit,
|
||||
"version": release.version,
|
||||
}
|
||||
return {
|
||||
"status": "SUCCEEDED",
|
||||
"classification": "RELEASE_CANDIDATE_CREATED",
|
||||
"execution_mode": "release_candidate",
|
||||
"adapter": "release_candidate",
|
||||
"task_id": task_id,
|
||||
"agent_id": AGENT_ID,
|
||||
"release_candidate": immutable,
|
||||
"release_candidate_request_sha256": sha256(canonical(immutable)),
|
||||
"builder_response": sanitized(dict(builder)),
|
||||
"projection_id": release.projection_id,
|
||||
"writer_lease": "NONE",
|
||||
"changed_files": [],
|
||||
"direct_main_write": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"runtime_switch": False,
|
||||
"owner_approval_submitted": False,
|
||||
"credential_material_exposed": False,
|
||||
}
|
||||
|
||||
|
||||
def execute_task(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
agent_token: str,
|
||||
release_token: str,
|
||||
) -> bool:
|
||||
task_id = str(task.get("task_id") or "")
|
||||
release = ReleaseRequest.from_task(task)
|
||||
admission = release_admission(
|
||||
task,
|
||||
release_token=release_token,
|
||||
)
|
||||
if not admission.admitted:
|
||||
audit(
|
||||
"TASK_ADMISSION_BLOCKED",
|
||||
task_id=task_id,
|
||||
admission=admission.as_dict(),
|
||||
)
|
||||
write_liveness(
|
||||
"BLOCKED",
|
||||
task_id=task_id,
|
||||
stage="execution_ready",
|
||||
error=admission.failure_class.value if admission.failure_class else "ADMISSION_FAILED",
|
||||
)
|
||||
return False
|
||||
audit(
|
||||
"TASK_ADMISSION_PASSED",
|
||||
task_id=task_id,
|
||||
admission=admission.as_dict(),
|
||||
)
|
||||
status, claimed = http_json(
|
||||
TASK_API,
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/claim",
|
||||
agent_token,
|
||||
{"lease_seconds": LEASE_SECONDS},
|
||||
)
|
||||
if status in {409, 423}:
|
||||
return False
|
||||
if status != 200:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_CLAIM_FAILED",
|
||||
phase="claim",
|
||||
)
|
||||
lease_token = str(claimed.get("lease_token") or "")
|
||||
if len(lease_token) < 16:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_LEASE_TOKEN_INVALID",
|
||||
phase="claim",
|
||||
)
|
||||
attempt = int(claimed.get("attempt") or 0)
|
||||
audit(
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
attempt=attempt,
|
||||
)
|
||||
|
||||
heartbeat = Heartbeat(agent_token, task_id, lease_token)
|
||||
heartbeat.first()
|
||||
heartbeat.thread.start()
|
||||
try:
|
||||
heartbeat.stage = "release_candidate"
|
||||
heartbeat.ensure()
|
||||
status, builder = http_json(
|
||||
RELEASE_API,
|
||||
"POST",
|
||||
"/createReleaseCandidate",
|
||||
release_token,
|
||||
release.body(),
|
||||
timeout=120,
|
||||
)
|
||||
if status not in {200, 201, 202}:
|
||||
raise ReleaseWorkerError(
|
||||
"RELEASE_CANDIDATE_CREATE_FAILED:"
|
||||
+ str(builder.get("error") or status),
|
||||
phase="release",
|
||||
)
|
||||
heartbeat.ensure()
|
||||
result = result_payload(task_id, release, builder)
|
||||
idempotency_key = (
|
||||
f"{task_id}-attempt-{attempt}-release-result-v1"
|
||||
)
|
||||
status, submitted = http_json(
|
||||
TASK_API,
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/result",
|
||||
agent_token,
|
||||
{
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": idempotency_key,
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
if status not in {200, 201, 202}:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_RESULT_SUBMIT_FAILED",
|
||||
phase="result",
|
||||
)
|
||||
audit(
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
attempt=attempt,
|
||||
release_candidate_id=release.candidate_id,
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
heartbeat.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not RELEASE_API:
|
||||
raise SystemExit("FATAL:AIOS_RELEASE_API_URL_REQUIRED")
|
||||
agent_token = load_credential("agent_token")
|
||||
release_token = load_credential("release_candidate_token")
|
||||
STATE_ROOT.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
audit(
|
||||
"WORKER_STARTED",
|
||||
capabilities=sorted(EXPECTED_CAPABILITIES),
|
||||
release_api=RELEASE_API,
|
||||
)
|
||||
write_liveness("STARTING", stage="startup")
|
||||
sd_notify("READY=1\nSTATUS=release worker ready")
|
||||
while True:
|
||||
try:
|
||||
status, response = http_json(
|
||||
TASK_API,
|
||||
"POST",
|
||||
"/v1/tasks/poll",
|
||||
agent_token,
|
||||
{"limit": 100},
|
||||
)
|
||||
if status != 200:
|
||||
raise ReleaseWorkerError(
|
||||
"TASK_POLL_FAILED",
|
||||
phase="poll",
|
||||
)
|
||||
tasks = response.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
tasks = []
|
||||
write_liveness(
|
||||
"IDLE",
|
||||
stage="poll",
|
||||
visible_count=len(tasks),
|
||||
)
|
||||
sd_notify("WATCHDOG=1\nSTATUS=release worker poll ok")
|
||||
handled = False
|
||||
for raw in tasks:
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
caps = raw.get("required_capabilities")
|
||||
if (
|
||||
not isinstance(caps, list)
|
||||
or frozenset(caps) != EXPECTED_CAPABILITIES
|
||||
):
|
||||
continue
|
||||
try:
|
||||
handled = execute_task(
|
||||
raw,
|
||||
agent_token=agent_token,
|
||||
release_token=release_token,
|
||||
) or handled
|
||||
except ReleaseWorkerError as exc:
|
||||
failure = classify_failure(exc.code, phase=exc.phase)
|
||||
repair = repair_for_failure(failure)
|
||||
audit(
|
||||
"TASK_EXECUTION_FAILED",
|
||||
task_id=raw.get("task_id"),
|
||||
phase=exc.phase,
|
||||
error=exc.code,
|
||||
failure_class=failure.value,
|
||||
repair=repair.as_dict(),
|
||||
)
|
||||
time.sleep(0 if handled else POLL_SECONDS)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
audit(
|
||||
"WORKER_LOOP_ERROR",
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
write_liveness(
|
||||
"DEGRADED",
|
||||
stage="loop",
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(*parts: str) -> str:
|
||||
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerIdentity:
|
||||
pid: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
class DomainPurposeSigner(Protocol):
|
||||
"""A signer deliberately limited to the builder's one domain purpose."""
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
...
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
...
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class CredentialPathEd25519Signer:
|
||||
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
||||
|
||||
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
||||
self._credential_path = Path(credential_path)
|
||||
self._key_id = str(key_id)
|
||||
self._key_generation = key_generation
|
||||
self._allowed_purpose = str(allowed_purpose)
|
||||
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
||||
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
||||
if not self._credential_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
try:
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
||||
if not isinstance(self._private_key, Ed25519PrivateKey):
|
||||
raise TypeError("signing key is not Ed25519")
|
||||
except Exception as exc: # do not expose parse/key material details
|
||||
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
return self._key_generation
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
if purpose != self._allowed_purpose:
|
||||
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
||||
return self._private_key.sign(payload)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderKeyring:
|
||||
builder_hmac: bytes
|
||||
signing_private_key_path: Path | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.builder_hmac) < 32:
|
||||
raise BuilderError("BUILDER_HMAC_INVALID")
|
||||
|
||||
@classmethod
|
||||
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
||||
root = Path(credentials_directory)
|
||||
hmac_path = root / "builder_hmac"
|
||||
signing_path = root / "signing_private_key"
|
||||
try:
|
||||
secret = hmac_path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
||||
if not signing_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustedCandidateBuilderConfig:
|
||||
policy_schema: str
|
||||
repository: str
|
||||
request_schema: str
|
||||
response_schema: str
|
||||
operation: str
|
||||
readiness_operation: str
|
||||
maximum_request_age_seconds: int
|
||||
request_timeout_seconds: int
|
||||
allowed_peer_uid: int
|
||||
allowed_peer_cgroups: tuple[str, ...]
|
||||
allowed_peer_executable_prefix: str
|
||||
allowed_peer_runtime_prefix: str
|
||||
allowed_peer_runtime_suffix: str
|
||||
required_checks: tuple[str, ...]
|
||||
evidence_layout: Mapping[str, str]
|
||||
recipes: Mapping[str, Mapping[str, str]]
|
||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
||||
policy_sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
required = {
|
||||
"schema", "repository", "request_schema", "response_schema", "operation",
|
||||
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
||||
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
||||
):
|
||||
raise BuilderError("POLICY_SCOPE_INVALID")
|
||||
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
||||
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
||||
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
||||
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
||||
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
||||
raise BuilderError("POLICY_PEER_UID_INVALID")
|
||||
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
||||
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
||||
raise BuilderError("POLICY_CGROUPS_INVALID")
|
||||
for field in (
|
||||
"allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix",
|
||||
"allowed_peer_runtime_suffix",
|
||||
):
|
||||
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
||||
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
||||
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
||||
raise BuilderError("POLICY_CHECKS_INVALID")
|
||||
layout = policy["evidence_layout"]
|
||||
evidence_names = {
|
||||
"artifact", "manifest", "package_closure", "tests", "canary",
|
||||
"gitea", "rollback", "policy", "secret_scan",
|
||||
}
|
||||
if (
|
||||
not isinstance(layout, Mapping)
|
||||
or set(layout) != evidence_names
|
||||
or not all(
|
||||
isinstance(value, str)
|
||||
and value.startswith("{version}/")
|
||||
and ".." not in Path(value).parts
|
||||
and not Path(value).is_absolute()
|
||||
for value in layout.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
||||
return cls(
|
||||
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
||||
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
||||
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
||||
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
||||
request_timeout_seconds=policy["request_timeout_seconds"],
|
||||
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
||||
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
||||
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
||||
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
||||
required_checks=tuple(checks), evidence_layout=dict(layout),
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(name)
|
||||
if recipe is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
if recipe["mode"] == "governed-derived-only":
|
||||
derived = self.governed_recipes.get(name)
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
||||
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
||||
value = derived.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
return dict(derived)
|
||||
return recipe
|
||||
|
||||
def resolve_recipe(self, version: str, candidate_id: str, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.fixed_recipe(version)
|
||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": candidate_id, "source_commit": source_commit}:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return recipe
|
||||
|
||||
|
||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
||||
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
||||
|
||||
|
||||
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping):
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
TrustedCandidateBuilderConfig.from_policy(decoded)
|
||||
return decoded
|
||||
|
||||
|
||||
class ServerOwnedEvidenceSource:
|
||||
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
authority_git_dir: Path,
|
||||
evidence_root: Path,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
git_runner: Callable[..., Any] = subprocess.run,
|
||||
):
|
||||
self._config = config
|
||||
self._authority_git_dir = Path(authority_git_dir)
|
||||
self._evidence_root = Path(evidence_root)
|
||||
self._expected_owner_uid = expected_owner_uid
|
||||
self._git_runner = git_runner
|
||||
|
||||
def _regular_path(self, relative: str) -> Path:
|
||||
root = self._evidence_root.resolve(strict=True)
|
||||
candidate = self._evidence_root / relative
|
||||
current = self._evidence_root
|
||||
for part in Path(relative).parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = resolved.stat(follow_symlinks=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_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_OWNER_DENIED")
|
||||
return resolved
|
||||
|
||||
def _digest(self, 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 _json(self, path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
if path.stat().st_size > 2 * 1024 * 1024:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
check=True, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
ancestry_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
tree_oid = tree_result.stdout.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
||||
or receipt.get("version") != recipe_name
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
candidate = receipt.get("candidate")
|
||||
release_plan = receipt.get("release_plan")
|
||||
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
candidate.get("candidate_id") != recipe["candidate_id"]
|
||||
or candidate.get("source_commit") != recipe["source_commit"]
|
||||
or candidate.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
git_consistency = candidate.get("git_consistency")
|
||||
if (
|
||||
not isinstance(git_consistency, Mapping)
|
||||
or git_consistency.get("consistent") is not True
|
||||
or git_consistency.get("source_commit") != recipe["source_commit"]
|
||||
or git_consistency.get("tree_oid") != tree_oid
|
||||
or git_consistency.get("repository") != self._config.repository
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
paths = {
|
||||
name: self._regular_path(template.format(version=recipe_name))
|
||||
for name, template in self._config.evidence_layout.items()
|
||||
}
|
||||
deployment = release_plan.get("deployment")
|
||||
if not isinstance(deployment, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
digests = {name: self._digest(path) for name, path in paths.items()}
|
||||
if (
|
||||
digests["artifact"] != candidate.get("artifact_sha256")
|
||||
or digests["manifest"] != candidate.get("manifest_sha256")
|
||||
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
||||
or digests["policy"] != release_plan.get("policy_sha256")
|
||||
or digests["policy"] != deployment.get("policy_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
||||
|
||||
tests = self._json(paths["tests"])
|
||||
canary = self._json(paths["canary"])
|
||||
gitea = self._json(paths["gitea"])
|
||||
rollback = self._json(paths["rollback"])
|
||||
secret_scan = self._json(paths["secret_scan"])
|
||||
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
||||
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
||||
if (
|
||||
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
||||
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
gitea != candidate.get("gitea_consistency")
|
||||
or gitea.get("consistent") is not True
|
||||
or gitea.get("repository") != self._config.repository
|
||||
or gitea.get("source_commit") != recipe["source_commit"]
|
||||
or gitea.get("tree_oid") != tree_oid
|
||||
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
||||
if (
|
||||
candidate.get("rollback_ready") is not True
|
||||
or rollback.get("ready") is not True
|
||||
or rollback.get("source_commit") != recipe["source_commit"]
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
secret_scan.get("passed") is not True
|
||||
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
||||
checks = {name: True for name in self._config.required_checks}
|
||||
return {
|
||||
"candidate": dict(candidate),
|
||||
"release_plan": dict(release_plan),
|
||||
"checks": checks,
|
||||
"evidence_digests": digests,
|
||||
}
|
||||
|
||||
|
||||
class DeterministicEvidenceCollector:
|
||||
"""Validates a server-owned source; request fields never influence collection."""
|
||||
|
||||
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
||||
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
||||
self._config = config
|
||||
self._evidence_source = evidence_source
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
if self._evidence_source is None:
|
||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||
bundle = self._evidence_source(recipe_name, recipe)
|
||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
raw_checks = bundle.get("checks")
|
||||
if (
|
||||
not isinstance(raw_checks, Mapping)
|
||||
or set(raw_checks) != set(self._config.required_checks)
|
||||
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
evidence_digests = bundle.get("evidence_digests")
|
||||
if (
|
||||
not isinstance(evidence_digests, Mapping)
|
||||
or set(evidence_digests) != set(self._config.evidence_layout)
|
||||
or any(
|
||||
not isinstance(value, str)
|
||||
or len(value) != 64
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
for value in evidence_digests.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
||||
try:
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
||||
|
||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
||||
checks = dict(raw_checks)
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
||||
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
||||
return {
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
"checks": checks,
|
||||
"evidence_digests": dict(evidence_digests),
|
||||
}
|
||||
|
||||
|
||||
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
||||
if len(raw) != struct.calcsize("3i"):
|
||||
raise BuilderError("SO_PEERCRED_INVALID")
|
||||
return PeerIdentity(*struct.unpack("3i", raw))
|
||||
|
||||
|
||||
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
||||
try:
|
||||
return tuple(
|
||||
line.rsplit(":", 1)[1].strip()
|
||||
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
||||
)
|
||||
except OSError as exc:
|
||||
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
||||
try:
|
||||
executable = os.path.realpath(f"/proc/{pid}/exe")
|
||||
argv = tuple(
|
||||
item.decode("utf-8", "strict")
|
||||
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||
if item
|
||||
)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
||||
if not executable or not argv:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
||||
return executable, argv
|
||||
|
||||
|
||||
class TrustedCandidateBuilder:
|
||||
def __init__(
|
||||
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
||||
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
||||
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
||||
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
||||
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
||||
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
||||
) -> None:
|
||||
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
||||
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
||||
self.process_identity_resolver = process_identity_resolver
|
||||
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
||||
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
||||
|
||||
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
||||
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
||||
return {}
|
||||
if self._nonce_ledger_path.is_symlink():
|
||||
raise BuilderError("NONCE_LEDGER_INVALID")
|
||||
try:
|
||||
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
||||
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
||||
|
||||
def _persist_nonce_ledger(self) -> None:
|
||||
if self._nonce_ledger_path is None:
|
||||
return
|
||||
try:
|
||||
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
||||
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
||||
with temporary.open("wb") as handle:
|
||||
handle.write(_canonical_json(payload))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self._nonce_ledger_path)
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except OSError as exc:
|
||||
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
||||
|
||||
def _verify_peer(self, connection: socket.socket) -> None:
|
||||
peer = self.peer_identity_resolver(connection)
|
||||
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
||||
raise BuilderError("PEER_IDENTITY_DENIED")
|
||||
resolved = self.cgroup_resolver(peer.pid)
|
||||
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
||||
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
||||
raise BuilderError("PEER_CGROUP_DENIED")
|
||||
executable, argv = self.process_identity_resolver(peer.pid)
|
||||
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
runtime_arguments = [
|
||||
value
|
||||
for value in argv
|
||||
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
||||
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
||||
]
|
||||
if len(runtime_arguments) != 1:
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
|
||||
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
||||
if (
|
||||
set(request) != expected_keys
|
||||
or request.get("schema") != self.config.request_schema
|
||||
or request.get("operation") not in {
|
||||
self.config.operation,
|
||||
self.config.readiness_operation,
|
||||
}
|
||||
):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
supplied_mac = request.get("hmac")
|
||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
||||
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
||||
raise BuilderError("REQUEST_HMAC_INVALID")
|
||||
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
||||
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
self._verify_peer(connection)
|
||||
request_bytes = _canonical_json(dict(request))
|
||||
replay = self._nonce_ledger.get(nonce)
|
||||
if replay is not None:
|
||||
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
||||
return dict(replay[1])
|
||||
raise BuilderError("REPLAY_CONFLICT")
|
||||
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
||||
raise BuilderError("REQUEST_STALE")
|
||||
recipe_name = request.get("recipe")
|
||||
if not isinstance(recipe_name, str):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
||||
if recipe_name != recipe.get("version"):
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
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:
|
||||
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:
|
||||
result = {
|
||||
"schema": self.config.response_schema,
|
||||
"status": "READY",
|
||||
"repository": self.config.repository,
|
||||
"recipe": recipe_name,
|
||||
"candidate_id": recipe["candidate_id"],
|
||||
"source_commit": recipe["source_commit"],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "builder_attestation",
|
||||
"policy_sha256": self.config.policy_sha256,
|
||||
"collector_ready": True,
|
||||
"signer_ready": True,
|
||||
}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (
|
||||
hashlib.sha256(request_bytes).digest(),
|
||||
dict(result),
|
||||
)
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
attestation = {
|
||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "builder_attestation",
|
||||
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
||||
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
||||
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
||||
"test_evidence_sha256": evidence_digests["tests"],
|
||||
"canary_evidence_sha256": evidence_digests["canary"],
|
||||
"gitea_evidence_sha256": evidence_digests["gitea"],
|
||||
"rollback_evidence_sha256": evidence_digests["rollback"],
|
||||
"policy_evidence_sha256": evidence_digests["policy"],
|
||||
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
||||
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
||||
}
|
||||
canonical_attestation = _canonical_json(attestation)
|
||||
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
||||
signature = base64.b64encode(self.signer.sign_for_purpose("builder_attestation", canonical_attestation)).decode("ascii")
|
||||
result = {
|
||||
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
||||
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
||||
"purpose": recipe["purpose"], "checks": dict(checks),
|
||||
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
||||
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
||||
}
|
||||
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
|
||||
def serve_forever(self, listener: socket.socket) -> None:
|
||||
while True:
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
if not raw or len(raw) > 65536:
|
||||
raise BuilderError("REQUEST_SIZE_INVALID")
|
||||
result = self.handle_request(json.loads(raw), connection=connection)
|
||||
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
||||
connection.sendall(_canonical_json(result) + b"\n")
|
||||
|
||||
|
||||
class UnixTrustedCandidateBuilderClient:
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
||||
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
||||
|
||||
def create_request(self, version: str, candidate_id: str, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
||||
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
||||
selected_operation = operation or self.config.operation
|
||||
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
||||
raise BuilderError("REQUEST_OPERATION_INVALID")
|
||||
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
||||
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
||||
return request
|
||||
|
||||
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if self.socket_path is None:
|
||||
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(self.config.request_timeout_seconds)
|
||||
connection.connect(os.fspath(self.socket_path))
|
||||
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
||||
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
||||
return result
|
||||
|
||||
def build(self, version: str, candidate_id: str, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
||||
result = self._exchange(
|
||||
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
||||
)
|
||||
if result.get("status") != "BUILT":
|
||||
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
||||
return result
|
||||
|
||||
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
||||
recipe = self.config.fixed_recipe("V2R2C2")
|
||||
result = self._exchange(
|
||||
self.create_request(
|
||||
"V2R2C2",
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
nonce=nonce or secrets.token_hex(32),
|
||||
operation=self.config.readiness_operation,
|
||||
)
|
||||
)
|
||||
if result.get("status") != "READY":
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
||||
result = dict(client.probe())
|
||||
required_true = {"collector_ready", "signer_ready"}
|
||||
if any(result.get(name) is not True for name in required_true):
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket-fd", required=True, type=int)
|
||||
parser.add_argument("--policy", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
||||
signer = CredentialPathEd25519Signer(
|
||||
keyring.signing_private_key_path,
|
||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
||||
allowed_purpose="builder_attestation",
|
||||
)
|
||||
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
collector = DeterministicEvidenceCollector(
|
||||
config,
|
||||
evidence_source=ServerOwnedEvidenceSource(
|
||||
config,
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
||||
),
|
||||
)
|
||||
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -40,6 +41,8 @@ from silent_release_controller import (
|
||||
|
||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$")
|
||||
NONCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{15,255}$")
|
||||
BUILDER_ATTESTATION_TRUST_DOMAIN = "aios.trusted-builder.attestation.v1"
|
||||
BUILDER_ATTESTATION_PURPOSE = "builder_attestation"
|
||||
ALLOWED_CAPABILITIES = frozenset(
|
||||
{
|
||||
"runtime_switch",
|
||||
@@ -62,6 +65,10 @@ class CapabilityAuthorizationError(RuntimeError):
|
||||
"""The signed plan cannot authorize the requested operation."""
|
||||
|
||||
|
||||
class TrustedCandidateBuilderUnavailable(RuntimeError):
|
||||
"""The purpose-bound builder dependency cannot currently serve a build."""
|
||||
|
||||
|
||||
class StaleApprovalError(CapabilityAuthorizationError):
|
||||
"""Live release evidence no longer matches the signed approval."""
|
||||
|
||||
@@ -222,6 +229,10 @@ JOURNAL_GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
# Windows does not permit opening a directory with os.open. Durable
|
||||
# Linux host execution retains the directory fsync below.
|
||||
return
|
||||
descriptor = os.open(Path(path), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
@@ -715,6 +726,14 @@ class BuilderAttestation:
|
||||
canary_evidence_sha256: str
|
||||
issued_at: str
|
||||
signature: str
|
||||
trust_domain: str = BUILDER_ATTESTATION_TRUST_DOMAIN
|
||||
builder_key_generation: int | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signature_purpose: str | None = None
|
||||
gitea_evidence_sha256: str = ""
|
||||
rollback_evidence_sha256: str = ""
|
||||
policy_evidence_sha256: str = ""
|
||||
secret_scan_evidence_sha256: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier(
|
||||
@@ -731,7 +750,35 @@ class BuilderAttestation:
|
||||
"canary_evidence_sha256",
|
||||
):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
for name in (
|
||||
"gitea_evidence_sha256",
|
||||
"rollback_evidence_sha256",
|
||||
"policy_evidence_sha256",
|
||||
"secret_scan_evidence_sha256",
|
||||
):
|
||||
if getattr(self, name):
|
||||
_validate_sha(name, getattr(self, name))
|
||||
_parse_utc(self.issued_at)
|
||||
if self.trust_domain != BUILDER_ATTESTATION_TRUST_DOMAIN:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation trust domain is invalid"
|
||||
)
|
||||
metadata = (
|
||||
self.builder_key_generation,
|
||||
self.signature_algorithm,
|
||||
self.signature_purpose,
|
||||
)
|
||||
if any(value is not None for value in metadata):
|
||||
if (
|
||||
not isinstance(self.builder_key_generation, int)
|
||||
or isinstance(self.builder_key_generation, bool)
|
||||
or self.builder_key_generation < 0
|
||||
or self.signature_algorithm != "Ed25519"
|
||||
or self.signature_purpose != BUILDER_ATTESTATION_PURPOSE
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is invalid"
|
||||
)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
@@ -754,20 +801,290 @@ class BuilderAttestation:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Mapping[str, Any]) -> "BuilderAttestation":
|
||||
def from_dict(
|
||||
cls,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
require_fingerprint: bool = False,
|
||||
) -> "BuilderAttestation":
|
||||
values = dict(payload)
|
||||
values.pop("fingerprint", None)
|
||||
return cls(**values)
|
||||
supplied_fingerprint = values.pop("fingerprint", None)
|
||||
# Older local records did not spell out the domain. They are only
|
||||
# usable when re-signed by the current builder; their original
|
||||
# signatures cannot validate against this domain-separated payload.
|
||||
values.setdefault("trust_domain", BUILDER_ATTESTATION_TRUST_DOMAIN)
|
||||
value = cls(**values)
|
||||
if supplied_fingerprint is None:
|
||||
if require_fingerprint:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is required"
|
||||
)
|
||||
return value
|
||||
expected = _validate_sha(
|
||||
"builder attestation digest", str(supplied_fingerprint)
|
||||
)
|
||||
if not hmac.compare_digest(expected, value.fingerprint):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation digest is invalid"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderAttestationKeyRecord:
|
||||
"""One purpose-bound public verification key from the host keyring."""
|
||||
|
||||
key_id: str
|
||||
public_key: bytes
|
||||
algorithm: str
|
||||
purpose: str
|
||||
generation: int
|
||||
not_before: str
|
||||
expires_at: str
|
||||
status: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_identifier("builder_key_id", self.key_id)
|
||||
if self.algorithm != "Ed25519":
|
||||
raise ValueError("unsupported builder signature algorithm")
|
||||
if self.purpose != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder key purpose is invalid")
|
||||
if not isinstance(self.generation, int) or isinstance(
|
||||
self.generation, bool
|
||||
) or self.generation < 0:
|
||||
raise ValueError("builder key generation is invalid")
|
||||
if self.status not in {"ACTIVE", "REVOKED"}:
|
||||
raise ValueError("builder key status is invalid")
|
||||
if len(self.public_key) != 32:
|
||||
raise ValueError("Ed25519 public key must contain 32 bytes")
|
||||
Ed25519PublicKey.from_public_bytes(self.public_key)
|
||||
if _parse_utc(self.not_before) >= _parse_utc(self.expires_at):
|
||||
raise ValueError("builder key validity window is invalid")
|
||||
|
||||
@property
|
||||
def public_key_sha256(self) -> str:
|
||||
return hashlib.sha256(self.public_key).hexdigest()
|
||||
|
||||
def validate_at(self, instant: datetime) -> None:
|
||||
if self.status != "ACTIVE":
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is unavailable or revoked"
|
||||
)
|
||||
if instant.tzinfo is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation verification time is invalid"
|
||||
)
|
||||
if not _parse_utc(self.not_before) <= instant <= _parse_utc(
|
||||
self.expires_at
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is outside its validity window"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, payload: Mapping[str, Any]
|
||||
) -> "BuilderAttestationKeyRecord":
|
||||
values = dict(payload)
|
||||
encoded = values.pop("public_key", values.pop("public_key_b64", None))
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError("builder public key is missing")
|
||||
try:
|
||||
public_key = base64.b64decode(encoded, validate=True)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("builder public key encoding is invalid") from exc
|
||||
supplied_digest = values.pop(
|
||||
"public_key_sha256",
|
||||
values.pop("key_sha256", values.pop("digest", None)),
|
||||
)
|
||||
if not isinstance(supplied_digest, str):
|
||||
raise ValueError("builder public key digest is missing")
|
||||
digest = _validate_sha("builder public key digest", supplied_digest)
|
||||
if not hmac.compare_digest(
|
||||
digest, hashlib.sha256(public_key).hexdigest()
|
||||
):
|
||||
raise ValueError("builder public key digest is invalid")
|
||||
allowed = {
|
||||
"algorithm",
|
||||
"purpose",
|
||||
"key_id",
|
||||
"generation",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"status",
|
||||
}
|
||||
unknown = set(values).difference(allowed)
|
||||
if unknown:
|
||||
raise ValueError("builder key record contains unknown fields")
|
||||
return cls(public_key=public_key, **values)
|
||||
|
||||
|
||||
class BuilderAttestationKeyRing:
|
||||
"""Validated, public-only keyring for builder attestations.
|
||||
|
||||
The file format deliberately cannot carry Owner approval keys: both the
|
||||
keyring and every record must declare the builder-attestation purpose.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
records: Iterable[BuilderAttestationKeyRecord],
|
||||
*,
|
||||
require_attestation_metadata: bool = False,
|
||||
):
|
||||
self._records: dict[str, BuilderAttestationKeyRecord] = {}
|
||||
self._require_attestation_metadata = require_attestation_metadata
|
||||
for record in records:
|
||||
if record.key_id in self._records:
|
||||
raise ValueError("duplicate builder key id")
|
||||
self._records[record.key_id] = record
|
||||
|
||||
@classmethod
|
||||
def from_public_keys(
|
||||
cls, public_keys: Mapping[str, bytes]
|
||||
) -> "BuilderAttestationKeyRing":
|
||||
"""Compatibility adapter for explicit in-process builder key maps.
|
||||
|
||||
This is intentionally not used for host configuration; host keyrings
|
||||
must use :meth:`from_file` so purpose, digest, validity and status are
|
||||
checked before the API accepts traffic.
|
||||
"""
|
||||
return cls(
|
||||
BuilderAttestationKeyRecord(
|
||||
key_id=_validate_identifier("builder_key_id", key_id),
|
||||
public_key=bytes(public_key),
|
||||
algorithm="Ed25519",
|
||||
purpose=BUILDER_ATTESTATION_PURPOSE,
|
||||
generation=0,
|
||||
not_before="1970-01-01T00:00:00Z",
|
||||
expires_at="9999-12-31T23:59:59Z",
|
||||
status="ACTIVE",
|
||||
)
|
||||
for key_id, public_key in public_keys.items()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "BuilderAttestationKeyRing":
|
||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if payload.get("algorithm") != "Ed25519":
|
||||
raise ValueError("unsupported builder keyring algorithm")
|
||||
if payload.get("purpose") != BUILDER_ATTESTATION_PURPOSE:
|
||||
raise ValueError("builder keyring purpose is invalid")
|
||||
raw_records = payload.get("keys")
|
||||
if not isinstance(raw_records, list) or not raw_records:
|
||||
raise ValueError("builder keyring keys are invalid")
|
||||
allowed = {"algorithm", "purpose", "keys", "keyring_sha256"}
|
||||
if set(payload).difference(allowed):
|
||||
raise ValueError("builder keyring contains unknown fields")
|
||||
if "keyring_sha256" not in payload:
|
||||
raise ValueError("builder keyring digest is missing")
|
||||
expected = _validate_sha(
|
||||
"builder keyring digest", str(payload["keyring_sha256"])
|
||||
)
|
||||
canonical = _canonical_json(
|
||||
{
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": raw_records,
|
||||
}
|
||||
)
|
||||
if not hmac.compare_digest(
|
||||
expected, hashlib.sha256(canonical).hexdigest()
|
||||
):
|
||||
raise ValueError("builder keyring digest is invalid")
|
||||
if not all(isinstance(record, Mapping) for record in raw_records):
|
||||
raise ValueError("builder keyring record is invalid")
|
||||
return cls(
|
||||
(
|
||||
BuilderAttestationKeyRecord.from_dict(record)
|
||||
for record in raw_records
|
||||
),
|
||||
require_attestation_metadata=True,
|
||||
)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
key_id: str,
|
||||
signature: bytes,
|
||||
message: bytes,
|
||||
*,
|
||||
at: datetime,
|
||||
generation: int | None,
|
||||
algorithm: str | None,
|
||||
purpose: str | None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
record.validate_at(at)
|
||||
metadata = (generation, algorithm, purpose)
|
||||
if self._require_attestation_metadata and any(
|
||||
value is None for value in metadata
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata is required"
|
||||
)
|
||||
if any(value is not None for value in metadata) and metadata != (
|
||||
record.generation,
|
||||
record.algorithm,
|
||||
record.purpose,
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signing metadata does not match key"
|
||||
)
|
||||
try:
|
||||
Ed25519PublicKey.from_public_bytes(record.public_key).verify(
|
||||
signature, message
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
return record
|
||||
|
||||
def require_active_identity(
|
||||
self,
|
||||
*,
|
||||
key_id: str,
|
||||
generation: int,
|
||||
algorithm: str,
|
||||
purpose: str,
|
||||
at: datetime | None = None,
|
||||
) -> BuilderAttestationKeyRecord:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
if (
|
||||
record.generation != generation
|
||||
or record.algorithm != algorithm
|
||||
or record.purpose != purpose
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key metadata is invalid"
|
||||
)
|
||||
record.validate_at(at or datetime.now(timezone.utc))
|
||||
return record
|
||||
|
||||
|
||||
class BuilderAttestationVerifier:
|
||||
"""Public-key-only verifier; no builder private material is accepted."""
|
||||
|
||||
def __init__(self, public_keys: Mapping[str, bytes]):
|
||||
self.public_keys = {
|
||||
_validate_identifier("builder_key_id", key_id): bytes(value)
|
||||
for key_id, value in public_keys.items()
|
||||
}
|
||||
def __init__(
|
||||
self,
|
||||
public_keys: BuilderAttestationKeyRing | Mapping[str, bytes],
|
||||
):
|
||||
if isinstance(public_keys, BuilderAttestationKeyRing):
|
||||
self.keyring = public_keys
|
||||
elif isinstance(public_keys, Mapping):
|
||||
self.keyring = BuilderAttestationKeyRing.from_public_keys(
|
||||
public_keys
|
||||
)
|
||||
else:
|
||||
raise TypeError("builder verifier requires a builder keyring")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
@@ -793,22 +1110,54 @@ class BuilderAttestationVerifier:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation does not bind candidate evidence"
|
||||
)
|
||||
key = self.public_keys.get(attestation.builder_key_id)
|
||||
if key is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation key is not trusted"
|
||||
)
|
||||
try:
|
||||
signature = base64.b64decode(
|
||||
attestation.signature, validate=True
|
||||
)
|
||||
Ed25519PublicKey.from_public_bytes(key).verify(
|
||||
signature, attestation.canonical_bytes()
|
||||
key_record = self.keyring.verify(
|
||||
attestation.builder_key_id,
|
||||
signature,
|
||||
attestation.canonical_bytes(),
|
||||
at=_parse_utc(attestation.issued_at),
|
||||
generation=attestation.builder_key_generation,
|
||||
algorithm=attestation.signature_algorithm,
|
||||
purpose=attestation.signature_purpose,
|
||||
)
|
||||
except (InvalidSignature, ValueError, TypeError) as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation signature is invalid"
|
||||
) from exc
|
||||
if key_record.generation >= 1:
|
||||
expected_gitea = hashlib.sha256(
|
||||
_canonical_json(candidate.gitea_consistency)
|
||||
).hexdigest()
|
||||
expected_rollback = hashlib.sha256(
|
||||
_canonical_json(
|
||||
{
|
||||
"ready": candidate.rollback_ready,
|
||||
"rollback_sha256": release_plan.deployment.rollback_sha256,
|
||||
"source_commit": candidate.source_commit,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
production_evidence = (
|
||||
attestation.gitea_evidence_sha256,
|
||||
attestation.rollback_evidence_sha256,
|
||||
attestation.policy_evidence_sha256,
|
||||
attestation.secret_scan_evidence_sha256,
|
||||
)
|
||||
if any(not value for value in production_evidence):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is incomplete"
|
||||
)
|
||||
if (
|
||||
attestation.gitea_evidence_sha256 != expected_gitea
|
||||
or attestation.rollback_evidence_sha256 != expected_rollback
|
||||
or attestation.policy_evidence_sha256 != release_plan.policy_sha256
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"builder attestation production evidence is invalid"
|
||||
)
|
||||
return attestation
|
||||
|
||||
|
||||
@@ -3181,9 +3530,12 @@ class SilentReleaseActionService:
|
||||
builder_attestation_verifier: BuilderAttestationVerifier | None = None,
|
||||
trusted_policy: TrustedReleasePolicy | None = None,
|
||||
orchestrator: ReleaseOrchestrator | None = None,
|
||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
||||
allow_legacy_candidate_input: bool = False,
|
||||
expected_repository: str = "",
|
||||
policy_sha256: str = "",
|
||||
max_builder_attempts: int = 3,
|
||||
builder_attempt_timeout_seconds: int = 180,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.execution_enabled = bool(execution_enabled)
|
||||
@@ -3194,11 +3546,28 @@ class SilentReleaseActionService:
|
||||
self.builder_attestation_verifier = builder_attestation_verifier
|
||||
self.trusted_policy = trusted_policy
|
||||
self.orchestrator = orchestrator
|
||||
self.builder_readiness_probe = builder_readiness_probe
|
||||
self.allow_legacy_candidate_input = bool(
|
||||
allow_legacy_candidate_input
|
||||
)
|
||||
self.expected_repository = expected_repository
|
||||
self.policy_sha256 = policy_sha256
|
||||
if (
|
||||
not isinstance(max_builder_attempts, int)
|
||||
or isinstance(max_builder_attempts, bool)
|
||||
or not 1 <= max_builder_attempts <= 10
|
||||
):
|
||||
raise ValueError("max_builder_attempts must be between 1 and 10")
|
||||
self.max_builder_attempts = max_builder_attempts
|
||||
if (
|
||||
not isinstance(builder_attempt_timeout_seconds, int)
|
||||
or isinstance(builder_attempt_timeout_seconds, bool)
|
||||
or not 1 <= builder_attempt_timeout_seconds <= 3600
|
||||
):
|
||||
raise ValueError(
|
||||
"builder_attempt_timeout_seconds must be between 1 and 3600"
|
||||
)
|
||||
self.builder_attempt_timeout_seconds = builder_attempt_timeout_seconds
|
||||
self.candidates = ReleaseCandidateStore(
|
||||
self.root / "candidates"
|
||||
)
|
||||
@@ -3235,10 +3604,26 @@ class SilentReleaseActionService:
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
def get_builder_readiness(self) -> dict[str, Any]:
|
||||
"""Report dependency readiness without conflating it with API liveness."""
|
||||
if self.candidate_builder is None:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if self.builder_readiness_probe is None:
|
||||
# Explicit in-process test/adaptor construction remains usable;
|
||||
# production wiring provides a probe backed by its credentials.
|
||||
return {"status": "READY"}
|
||||
try:
|
||||
value = dict(self.builder_readiness_probe())
|
||||
except Exception:
|
||||
return {"status": "UNAVAILABLE"}
|
||||
if value.get("status") != "READY":
|
||||
return {"status": "UNAVAILABLE"}
|
||||
return _sanitize(value)
|
||||
|
||||
def create_release_intent(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
allowed = {"event_id", "source_commit"}
|
||||
allowed = {"event_id", "source_commit", "version"}
|
||||
unknown = set(payload).difference(allowed)
|
||||
if unknown:
|
||||
raise CapabilityAuthorizationError(
|
||||
@@ -3250,12 +3635,17 @@ class SilentReleaseActionService:
|
||||
source_commit = _validate_oid(
|
||||
"source_commit", str(payload.get("source_commit") or "")
|
||||
)
|
||||
version = str(payload.get("version") or "")
|
||||
if version:
|
||||
_validate_identifier("version", version)
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
path = _safe_child(self.intents_root, intent_id)
|
||||
was_existing = path.exists()
|
||||
record = {
|
||||
"created_at": utc_now(),
|
||||
"attempt_count": 0,
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
@@ -3265,42 +3655,153 @@ class SilentReleaseActionService:
|
||||
else "BUILDER_UNAVAILABLE"
|
||||
),
|
||||
}
|
||||
if path.exists():
|
||||
if version:
|
||||
record["version"] = version
|
||||
if was_existing:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
existing.get("event_id") != event_id
|
||||
or existing.get("source_commit") != source_commit
|
||||
or str(existing.get("version") or "") != version
|
||||
):
|
||||
raise SagaConflict(
|
||||
"release intent id is bound to different evidence"
|
||||
)
|
||||
return {
|
||||
**_sanitize(existing),
|
||||
"idempotent": True,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
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)
|
||||
record = dict(existing)
|
||||
if self.candidate_builder is None:
|
||||
record.update(
|
||||
{
|
||||
"retryable": False,
|
||||
"status": "BUILDER_UNAVAILABLE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=was_existing)
|
||||
|
||||
record.update(
|
||||
{
|
||||
"attempt_count": int(record.get("attempt_count") or 0) + 1,
|
||||
"attempt_started_at": utc_now(),
|
||||
"retryable": False,
|
||||
"status": "BUILDING",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
result = dict(record)
|
||||
if self.candidate_builder is not None:
|
||||
try:
|
||||
candidate = self.create_release_candidate(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"intent_id": intent_id,
|
||||
"source_commit": source_commit,
|
||||
**({"version": version} if version else {}),
|
||||
}
|
||||
)
|
||||
result.update(
|
||||
except TrustedCandidateBuilderUnavailable:
|
||||
record.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"status": candidate["saga_state"],
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except CapabilityAuthorizationError:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||
"retryable": False,
|
||||
"status": "BUILDER_TERMINAL_FAILURE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except (ConnectionError, OSError, TimeoutError):
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_UNAVAILABLE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except Exception:
|
||||
# The durable intent is recoverable without serializing an
|
||||
# arbitrary exception (which may contain a credential or host
|
||||
# path). Repeated requests consume the small fixed retry budget.
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_FAILURE",
|
||||
"retryable": record["attempt_count"]
|
||||
< self.max_builder_attempts,
|
||||
"status": (
|
||||
"BUILDER_RETRYABLE_FAILURE"
|
||||
if record["attempt_count"] < self.max_builder_attempts
|
||||
else "BUILDER_TERMINAL_FAILURE"
|
||||
),
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
result = dict(record)
|
||||
result.update(
|
||||
{
|
||||
"candidate_id": candidate["candidate_id"],
|
||||
"retryable": False,
|
||||
"status": candidate["saga_state"],
|
||||
}
|
||||
)
|
||||
_durable_json(path, result)
|
||||
return self._intent_response(result, idempotent=False)
|
||||
|
||||
def _intent_response(
|
||||
self, record: Mapping[str, Any], *, idempotent: bool
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**_sanitize(result),
|
||||
"idempotent": False,
|
||||
**_sanitize(dict(record)),
|
||||
"idempotent": idempotent,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"shadow_mode": not self.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
@@ -3321,6 +3822,7 @@ class SilentReleaseActionService:
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if self.candidate_builder is not None:
|
||||
values = dict(payload)
|
||||
forbidden_evidence = {
|
||||
"artifact_sha256",
|
||||
"canary",
|
||||
@@ -3333,13 +3835,69 @@ class SilentReleaseActionService:
|
||||
"tests",
|
||||
"tree_oid",
|
||||
}
|
||||
if forbidden_evidence.intersection(payload):
|
||||
if forbidden_evidence.intersection(values):
|
||||
raise CapabilityAuthorizationError(
|
||||
"candidate evidence must come from the trusted builder"
|
||||
)
|
||||
worker_fields = {"candidate_id", "source_commit", "version"}
|
||||
intent_fields = {"event_id", "intent_id", "source_commit", "version"}
|
||||
supplied = set(values)
|
||||
is_worker_request = worker_fields.issubset(supplied)
|
||||
if is_worker_request:
|
||||
if supplied != worker_fields:
|
||||
raise ValueError("release worker request contains unknown fields")
|
||||
_validate_identifier(
|
||||
"candidate_id", str(values.get("candidate_id") or "")
|
||||
)
|
||||
_validate_identifier(
|
||||
"version", str(values.get("version") or "")
|
||||
)
|
||||
else:
|
||||
if not supplied.issubset(intent_fields):
|
||||
raise ValueError("trusted builder intent contains unknown fields")
|
||||
# Preserve the established internal intent contract
|
||||
# (source_commit + version) while allowing newer callers to
|
||||
# add an event/intent identifier. Neither form can supply
|
||||
# acceptance evidence; the host builder owns all collectors.
|
||||
if values.get("event_id"):
|
||||
_validate_identifier(
|
||||
"event_id", str(values["event_id"])
|
||||
)
|
||||
if values.get("intent_id"):
|
||||
_validate_identifier(
|
||||
"intent_id", str(values["intent_id"])
|
||||
)
|
||||
if values.get("version"):
|
||||
_validate_identifier(
|
||||
"version", str(values["version"])
|
||||
)
|
||||
_validate_oid(
|
||||
"source_commit", str(values.get("source_commit") or "")
|
||||
)
|
||||
readiness = self.get_builder_readiness()
|
||||
if readiness.get("status") != "READY":
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
"trusted builder dependency is unavailable"
|
||||
)
|
||||
bundle = self.candidate_builder.build(dict(payload)).validate(
|
||||
self.builder_attestation_verifier
|
||||
)
|
||||
requested_source = str(payload.get("source_commit") or "")
|
||||
if (
|
||||
requested_source
|
||||
and bundle.candidate.source_commit != requested_source
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder source commit does not match intent"
|
||||
)
|
||||
if (
|
||||
is_worker_request
|
||||
and bundle.candidate.candidate_id
|
||||
!= str(values["candidate_id"])
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder candidate id does not match request"
|
||||
)
|
||||
if self.trusted_policy is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted release policy is not configured"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"--provider",
|
||||
"oneapi",
|
||||
"-m",
|
||||
"deepseek-v4-flash",
|
||||
"{model_id}",
|
||||
"--no-restore-cwd"
|
||||
],
|
||||
"credential_env": {
|
||||
@@ -21,6 +21,16 @@
|
||||
},
|
||||
"input_mode": "stdin_text",
|
||||
"max_output_bytes": 131072,
|
||||
"model_governance": {
|
||||
"max_output_tokens": 512,
|
||||
"model_capabilities": [
|
||||
"text"
|
||||
],
|
||||
"provider_ids": [
|
||||
"oneapi"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
"output_mode": "stdout_text",
|
||||
"runtime": "hermes",
|
||||
"schema": "aios.external-agent-runtime.profile.v1",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"--message",
|
||||
"{prompt}",
|
||||
"--model",
|
||||
"openai/deepseek-v4-flash",
|
||||
"{model_id}",
|
||||
"--timeout",
|
||||
"180"
|
||||
],
|
||||
@@ -30,6 +30,17 @@
|
||||
},
|
||||
"input_mode": "stdin_text",
|
||||
"max_output_bytes": 131072,
|
||||
"model_governance": {
|
||||
"input_reservation_floor_tokens": 16384,
|
||||
"max_output_tokens": 256,
|
||||
"model_capabilities": [
|
||||
"text"
|
||||
],
|
||||
"provider_ids": [
|
||||
"openai"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
"output_mode": "stdout_json",
|
||||
"runtime": "openclaw",
|
||||
"schema": "aios.external-agent-runtime.profile.v1",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": "aios.trusted-candidate-builder-policy.v1",
|
||||
"repository": "aios/aios",
|
||||
"request_schema": "aios.trusted-candidate-builder.request.v1",
|
||||
"response_schema": "aios.trusted-candidate-builder.response.v1",
|
||||
"operation": "BuildTrustedCandidateV1",
|
||||
"readiness_operation": "ProbeTrustedCandidateBuilderV1",
|
||||
"maximum_request_age_seconds": 60,
|
||||
"request_timeout_seconds": 30,
|
||||
"allowed_peer_uid": 1000,
|
||||
"allowed_peer_cgroups": [
|
||||
"/system.slice/aios-gitops-model-api.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",
|
||||
"required_checks": [
|
||||
"authoritative_git",
|
||||
"artifact",
|
||||
"canary",
|
||||
"gitea",
|
||||
"manifest",
|
||||
"package_closure",
|
||||
"rollback",
|
||||
"secret_scan",
|
||||
"tests"
|
||||
],
|
||||
"evidence_layout": {
|
||||
"artifact": "{version}/artifact.tar.gz",
|
||||
"manifest": "{version}/manifest.json",
|
||||
"package_closure": "{version}/checksums.sha256",
|
||||
"tests": "{version}/tests.json",
|
||||
"canary": "{version}/canary.json",
|
||||
"gitea": "{version}/gitea.json",
|
||||
"rollback": "{version}/rollback.json",
|
||||
"policy": "{version}/silent-release-policy.json",
|
||||
"secret_scan": "{version}/secret-scan.json"
|
||||
},
|
||||
"recipes": {
|
||||
"V2R2C2": {
|
||||
"mode": "fixed",
|
||||
"task_id": "r42-release-candidate-v2r2c2-live-20260819",
|
||||
"candidate_id": "r42-release-candidate-v2r2c2-20260819",
|
||||
"base_oid": "71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
"source_commit": "71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
"version": "V2R2C2",
|
||||
"purpose": "release:candidate:trusted-build"
|
||||
},
|
||||
"V2R2C3": {
|
||||
"mode": "governed-derived-only",
|
||||
"derives_from": "V2R2C2",
|
||||
"version": "V2R2C3",
|
||||
"purpose": "release:candidate:trusted-build"
|
||||
}
|
||||
},
|
||||
"governed_recipes": {}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"capabilities": [
|
||||
"agent.runtime.hermes",
|
||||
"text"
|
||||
],
|
||||
"enabled": true,
|
||||
"evaluation_sha256": "43ae0db1640daeb6d33e2787769732844f366d36070065d3a2239905bd9a823b",
|
||||
"evaluation_state": "APPROVED",
|
||||
"input_cost_micros_per_1k": 0,
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 512,
|
||||
"model_id": "deepseek-v4-flash",
|
||||
"output_cost_micros_per_1k": 0,
|
||||
"priority": 10,
|
||||
"provider_id": "oneapi",
|
||||
"version": "profile-sha:54615e533bfdb6a2e8b870ae74bb8cd7fc45177495bcf6e8c45a50837cefb85a"
|
||||
},
|
||||
{
|
||||
"capabilities": [
|
||||
"agent.runtime.openclaw",
|
||||
"text"
|
||||
],
|
||||
"enabled": true,
|
||||
"evaluation_sha256": "7b1c89634eb9c0e7cb5a3d75323d904ebbd91b3ae5494ce938bd8316a4cf5b12",
|
||||
"evaluation_state": "APPROVED",
|
||||
"input_cost_micros_per_1k": 0,
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 256,
|
||||
"model_id": "openai/deepseek-v4-flash",
|
||||
"output_cost_micros_per_1k": 0,
|
||||
"priority": 10,
|
||||
"provider_id": "openai",
|
||||
"version": "profile-sha:2a31c480226aa78d44ed9692bddcedc99c3aa03c23c0dd5f031a5cbbb8bbd7ba"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"allowed_models": [
|
||||
"deepseek-v4-flash",
|
||||
"openai/deepseek-v4-flash"
|
||||
],
|
||||
"daily_cost_budget_micros": 0,
|
||||
"daily_token_budget": 250000,
|
||||
"denied_providers": [],
|
||||
"fallback_limit": 1,
|
||||
"max_input_tokens_per_request": 16384,
|
||||
"max_output_tokens_per_request": 512,
|
||||
"project_id": null,
|
||||
"required_capabilities": [
|
||||
"text"
|
||||
],
|
||||
"scope_type": "legacy",
|
||||
"tenant_id": null
|
||||
},
|
||||
{
|
||||
"allowed_models": [
|
||||
"deepseek-v4-flash",
|
||||
"openai/deepseek-v4-flash"
|
||||
],
|
||||
"daily_cost_budget_micros": 0,
|
||||
"daily_token_budget": 250000,
|
||||
"denied_providers": [],
|
||||
"fallback_limit": 1,
|
||||
"max_input_tokens_per_request": 16384,
|
||||
"max_output_tokens_per_request": 512,
|
||||
"project_id": "staging",
|
||||
"required_capabilities": [
|
||||
"text"
|
||||
],
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "model-governance-live"
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"cooldown_seconds": 60,
|
||||
"credential_ref": "runtime-profile:hermes",
|
||||
"enabled": true,
|
||||
"failure_domain": "oneapi",
|
||||
"max_failures": 2,
|
||||
"provider_id": "oneapi"
|
||||
},
|
||||
{
|
||||
"cooldown_seconds": 60,
|
||||
"credential_ref": "runtime-profile:openclaw",
|
||||
"enabled": true,
|
||||
"failure_domain": "oneapi",
|
||||
"max_failures": 2,
|
||||
"provider_id": "openai"
|
||||
}
|
||||
],
|
||||
"schema": "aios.model-governance.bootstrap.v1"
|
||||
}
|
||||
@@ -33,7 +33,10 @@ InaccessiblePaths=/data/agent/secrets
|
||||
ReadOnlyPaths=/etc/aios/external-agent-runtime.d
|
||||
ReadOnlyPaths=/opt/aios-hermes-agent
|
||||
ReadOnlyPaths=/opt/aios/openclaw-engineering
|
||||
Environment=AIOS_MODEL_GOVERNANCE_DB=/var/lib/aios-model-governance/state.sqlite3
|
||||
Environment=AIOS_MODEL_GOVERNANCE_ENABLED=1
|
||||
ReadWritePaths=/var/lib/aios-external-agent-executor
|
||||
ReadWritePaths=/var/lib/aios-model-governance
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=AIOS disabled-by-default HTTPS edge for GPT read-only Online Task ingress
|
||||
After=network-online.target aios-gpt-online-task-ingress.service
|
||||
Wants=network-online.target aios-gpt-online-task-ingress.service
|
||||
ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
LoadCredential=tls_cert:/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
LoadCredential=tls_key:/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
Environment=AIOS_GPT_EDGE_TLS_CERT_FILE=%d/tls_cert
|
||||
Environment=AIOS_GPT_EDGE_TLS_KEY_FILE=%d/tls_key
|
||||
Environment=AIOS_GPT_EDGE_HOST=127.0.0.1
|
||||
Environment=AIOS_GPT_EDGE_PORT=18443
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_https_edge.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home /var/lib/aios-agent-task
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,45 @@
|
||||
[Unit]
|
||||
Description=AIOS external GPT read-only Online Task ingress
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service aios-agent-task-api.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
StateDirectory=aios-gpt-online-task-ingress
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=gpt_ingress_token:/etc/aios-gitops/gpt-online-task-ingress.token
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_GPT_INGRESS_TOKEN_FILE=%d/gpt_ingress_token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_GPT_INGRESS_STATE_ROOT=%S/aios-gpt-online-task-ingress
|
||||
Environment=AIOS_GPT_INGRESS_HOST=127.0.0.1
|
||||
Environment=AIOS_GPT_INGRESS_PORT=18945
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_online_task_ingress.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,47 @@
|
||||
[Unit]
|
||||
Description=AIOS durable Owner authorization broker
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=aios-executor-exchange
|
||||
RuntimeDirectory=aios-owner-authorization-broker
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-owner-authorization-broker
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY=/etc/aios-gitops/owner-durable-authorization.json
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_REVOCATIONS=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=%t/aios-owner-authorization-broker/broker.sock
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_durable_owner_authorization.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization.json
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
InaccessiblePaths=/data/aios-gitops /home
|
||||
UMask=0007
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,50 @@
|
||||
[Unit]
|
||||
Description=AIOS release-candidate Online Agent worker
|
||||
After=network.target aios-agent-task-api.service
|
||||
Wants=aios-agent-task-api.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
WatchdogSec=90
|
||||
User=root
|
||||
Group=root
|
||||
StateDirectory=aios-online-agent-release
|
||||
StateDirectoryMode=0750
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-release-online-agent-worker/agent_token
|
||||
LoadCredentialEncrypted=release_candidate_token:/etc/credstore.encrypted/aios-release-online-agent-worker/release_candidate_token
|
||||
Environment=AIOS_RELEASE_WORKER_AGENT_ID=online-release-worker-cn-network-mkt-20260819
|
||||
Environment=AIOS_RELEASE_WORKER_AGENT_VERSION=2026-08-19.1-release-candidate
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_RELEASE_WORKER_STATE_ROOT=/var/lib/aios-online-agent-release
|
||||
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-release
|
||||
Environment=AIOS_RELEASE_WORKER_POLL_SECONDS=3
|
||||
Environment=AIOS_RELEASE_WORKER_LEASE_SECONDS=60
|
||||
Environment=AIOS_RELEASE_WORKER_HEARTBEAT_SECONDS=15
|
||||
Environment=AIOS_RELEASE_API_URL=http://127.0.0.1:18890
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_release_online_agent_worker.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
InaccessiblePaths=/data/aios-gitops /home
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder
|
||||
Requires=aios-trusted-candidate-builder.socket
|
||||
After=aios-trusted-candidate-builder.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
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
|
||||
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_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_KEY_ID=builder-attestation-ed25519-g1
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/trusted-candidate-builder/current/app/aios_trusted_candidate_builder.py --socket-fd 3 --policy ${AIOS_TRUSTED_BUILDER_POLICY}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StateDirectory=aios-trusted-candidate-builder
|
||||
StateDirectoryMode=0700
|
||||
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/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted candidate builder AF_UNIX socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/aios-trusted-candidate-builder.sock
|
||||
SocketUser=root
|
||||
SocketGroup=gitops
|
||||
SocketMode=0660
|
||||
RemoveOnStop=yes
|
||||
Accept=no
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -0,0 +1 @@
|
||||
d /var/lib/aios-model-governance 0750 aios-engineering-executor aios-executor-exchange -
|
||||
@@ -0,0 +1,123 @@
|
||||
# External Agent Attempt-Scoped Request Identity
|
||||
|
||||
## Problem
|
||||
|
||||
External Agent execution previously derived its durable request ID from:
|
||||
|
||||
```text
|
||||
task_id
|
||||
job_id
|
||||
execution_digest
|
||||
runtime
|
||||
```
|
||||
|
||||
The Online Task attempt was missing.
|
||||
|
||||
That is incorrect for retryable external side effects. A failed Model Governance route reservation is
|
||||
durable and terminal for that request ID. Reusing the same external request ID on a later official
|
||||
Task attempt therefore reuses the old route decision/reservation and can fail settlement with:
|
||||
|
||||
```text
|
||||
MODEL_RESERVATION_STATE_INVALID
|
||||
```
|
||||
|
||||
Archiving an exchange failed marker does not fix the root cause because the Model Governance
|
||||
reservation remains intentionally durable.
|
||||
|
||||
## Contract
|
||||
|
||||
External request identity is now:
|
||||
|
||||
```text
|
||||
hash(
|
||||
task_id,
|
||||
job_id,
|
||||
execution_digest,
|
||||
runtime,
|
||||
attempt
|
||||
)
|
||||
```
|
||||
|
||||
The attempt is a **transient execution coordinate**. It is not added to the signed governed execution
|
||||
envelope and does not alter the Task/Job binding or execution digest.
|
||||
|
||||
The worker gets the authoritative attempt from the successful claim response and passes it through:
|
||||
|
||||
```text
|
||||
process_claim
|
||||
→ RuntimeAdapter.execute(..., attempt=N)
|
||||
→ ExternalAgentExecutor
|
||||
→ ExternalAgentExchange
|
||||
→ external request JSON
|
||||
→ external result JSON
|
||||
```
|
||||
|
||||
The external result must bind the same attempt.
|
||||
|
||||
## Idempotency
|
||||
|
||||
This gives the intended two-level behavior:
|
||||
|
||||
```text
|
||||
same Task/Job/execution/runtime + same attempt
|
||||
→ same external request ID
|
||||
→ same external result/reservation can be replayed safely
|
||||
|
||||
same Task/Job/execution/runtime + next official attempt
|
||||
→ different external request ID
|
||||
→ different Model Governance request ID
|
||||
→ new reservation
|
||||
```
|
||||
|
||||
Old FAILED reservations are retained for audit. No Model Governance DB cleanup is required or
|
||||
permitted.
|
||||
|
||||
## Model Governance
|
||||
|
||||
Model Governance already derives its route request ID from:
|
||||
|
||||
```text
|
||||
external_request_id + ":model"
|
||||
```
|
||||
|
||||
Therefore making the external request ID attempt-scoped automatically makes route decisions and
|
||||
reservations attempt-scoped without creating another state authority.
|
||||
|
||||
## Recovery
|
||||
|
||||
For the current Hermes live Task:
|
||||
|
||||
```text
|
||||
attempt 6
|
||||
→ old request/reservation remains FAILED
|
||||
|
||||
official NEEDS_REWORK
|
||||
→ bounded retry extension 6 → 7
|
||||
|
||||
attempt 7
|
||||
→ new external request ID
|
||||
→ new route request ID
|
||||
→ new reservation
|
||||
→ provider execution
|
||||
→ settlement
|
||||
```
|
||||
|
||||
No replacement Task/Job, direct DB rewrite, failed-reservation deletion or marker deletion is
|
||||
required.
|
||||
|
||||
## Worker Availability
|
||||
|
||||
The repair must preserve the already verified Worker Availability runtime. Runtime switches use the
|
||||
maintenance inhibit and must finish with the guard healthy and all three workers fresh.
|
||||
|
||||
## Verification
|
||||
|
||||
Required regression coverage:
|
||||
|
||||
- request ID stable within one attempt;
|
||||
- request ID different across attempts;
|
||||
- request schema requires a valid attempt;
|
||||
- external result binds attempt;
|
||||
- old failed Model Governance reservation remains FAILED;
|
||||
- next attempt gets a new reservation and can settle;
|
||||
- existing external-agent and Model Governance runtime tests still pass.
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Model Governance Policy / Profile Floor Compatibility
|
||||
|
||||
## Problem
|
||||
|
||||
The root-owned OpenClaw runtime profile reserves a minimum of 16,384 input tokens so Model
|
||||
Governance can cover OpenClaw-injected workspace, skills and system context before provider
|
||||
execution.
|
||||
|
||||
The staging scope policies still limited input to 16,000 tokens per request. Route selection
|
||||
therefore failed before reservation with:
|
||||
|
||||
```text
|
||||
MODEL_INPUT_TOKEN_LIMIT_DENY
|
||||
```
|
||||
|
||||
This was a configuration invariant violation:
|
||||
|
||||
```text
|
||||
scope max_input_tokens_per_request
|
||||
<
|
||||
root-owned profile input_reservation_floor_tokens
|
||||
```
|
||||
|
||||
The registered OpenClaw model itself supports 32,768 input tokens, so the model capability was not
|
||||
the blocker.
|
||||
|
||||
## Invariant
|
||||
|
||||
For every Model Governance scope that allows a model/runtime whose root-owned profile declares an
|
||||
input reservation floor:
|
||||
|
||||
```text
|
||||
scope.max_input_tokens_per_request
|
||||
>=
|
||||
profile.model_governance.input_reservation_floor_tokens
|
||||
```
|
||||
|
||||
The profile floor remains root-owned. Task payloads cannot alter it.
|
||||
|
||||
## Staging policy
|
||||
|
||||
The two staging policies that allow `openai/deepseek-v4-flash` are updated from:
|
||||
|
||||
```text
|
||||
16000
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
16384
|
||||
```
|
||||
|
||||
This is the minimum compatible value. It intentionally remains below the model's 32,768-token
|
||||
technical input ceiling.
|
||||
|
||||
The daily token budget is unchanged.
|
||||
|
||||
## Governance path
|
||||
|
||||
The live SQLite policy must be changed only through the repository bootstrap and
|
||||
`aios_model_governance_admin.py`.
|
||||
|
||||
The admin reconciler:
|
||||
|
||||
- reads the governed repository config;
|
||||
- uses current policy revision as CAS;
|
||||
- calls `ModelGovernanceStore.set_scope_policy`;
|
||||
- appends normal Model Governance audit evidence;
|
||||
- verifies the audit chain.
|
||||
|
||||
Direct SQL updates remain forbidden.
|
||||
|
||||
## Exhausted OpenClaw recovery
|
||||
|
||||
The fixed OpenClaw live Task has exhausted its normal three attempts.
|
||||
|
||||
After the governed policy candidate is officially verified and the live policy is reconciled:
|
||||
|
||||
```text
|
||||
attempt 3 VERIFYING/FAILED result
|
||||
→ official NEEDS_REWORK
|
||||
→ Task becomes FAILED 3/3
|
||||
→ official bounded retry extension 3 → 4
|
||||
→ same Task / same Job returns QUEUED
|
||||
→ persistent OpenClaw worker auto-claims attempt 4
|
||||
```
|
||||
|
||||
AR1 attempt-scoped external request identity ensures attempt 4 receives a new external request and a
|
||||
new Model Governance reservation.
|
||||
|
||||
No historical reservation is deleted or rewritten.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Attempt 4 must prove:
|
||||
|
||||
```text
|
||||
scope max_input_tokens_per_request=16384
|
||||
profile input_reservation_floor_tokens=16384
|
||||
reserved_estimated_input_tokens>=16384
|
||||
actual input <= reserved input
|
||||
settlement=SETTLED
|
||||
usage_source=runtime
|
||||
audit_chain_valid=true
|
||||
```
|
||||
|
||||
If attempt 4 exposes a new failure, stop. No additional retry extension is authorized by this
|
||||
repair.
|
||||
@@ -0,0 +1,211 @@
|
||||
# AIOS Model Governance Runtime
|
||||
|
||||
## Status and purpose
|
||||
|
||||
This component turns the previously `PARTIAL` Model Governance interface map into an enforceable
|
||||
runtime policy for the already verified Hermes/OpenClaw Online Agent path.
|
||||
|
||||
It is intentionally a **single SQLite library**, not a new network service. The existing external
|
||||
Agent executor calls the library immediately before launching a model-backed runtime and settles the
|
||||
same reservation immediately after the result.
|
||||
|
||||
The authoritative execution chain remains:
|
||||
|
||||
```text
|
||||
Online Task scope / trace
|
||||
→ dedicated worker claim + heartbeat
|
||||
→ external Agent executor
|
||||
→ ModelGovernanceStore.route()
|
||||
→ root-owned runtime profile binds provider/model/version
|
||||
→ Hermes / OpenClaw process
|
||||
→ ModelGovernanceStore.record_success() / record_failure()
|
||||
→ durable Online Agent checkpoint/result
|
||||
```
|
||||
|
||||
P0 scope/trace/credential, P1 checkpoint/retry, P2 fleet scheduling and the existing external Agent
|
||||
credential boundary remain authoritative.
|
||||
|
||||
## Ten Model Governance contracts
|
||||
|
||||
### MODEL_PROVIDER_REGISTRY
|
||||
|
||||
`model_governance_providers` stores provider identity, failure domain, circuit policy and an opaque
|
||||
credential reference. Raw credentials are rejected.
|
||||
|
||||
Allowed reference prefixes:
|
||||
|
||||
- `systemd-credential:`
|
||||
- `runtime-profile:`
|
||||
- `credential-broker:`
|
||||
|
||||
### MODEL_SELECTION_POLICY
|
||||
|
||||
`model_governance_scope_policies` constrains eligible models/providers/capabilities. Routing is
|
||||
deterministic: explicit preferred models, then configured priority, then stable provider/model IDs.
|
||||
|
||||
### MODEL_VERSION_PINNING
|
||||
|
||||
A registry row always includes a model version. A scope can additionally pin a model ID + exact
|
||||
version. Every route decision persists both model ID and version plus model/policy revisions.
|
||||
|
||||
### MODEL_BUDGET_ACCOUNTING
|
||||
|
||||
A route first reserves estimated input/output tokens and estimated cost inside `BEGIN IMMEDIATE`.
|
||||
Settled usage and outstanding reservations both count against the daily scope budget, preventing two
|
||||
concurrent requests from oversubscribing the same budget.
|
||||
|
||||
Runtime-reported usage is preferred. When telemetry is absent, the reserved upper bound is charged
|
||||
rather than silently under-counting.
|
||||
|
||||
OpenClaw's verified result shape is supported directly:
|
||||
|
||||
```text
|
||||
meta.agentMeta.usage.input
|
||||
meta.agentMeta.usage.output
|
||||
meta.agentMeta.costUsd
|
||||
```
|
||||
|
||||
Hermes currently has no structured usage in the verified output, so it uses conservative
|
||||
reservation settlement.
|
||||
|
||||
### MODEL_FALLBACK_POLICY
|
||||
|
||||
Provider failures are durable. Repeated failures open a circuit for the configured cooldown. A
|
||||
fallback request excludes the failed provider and increments `fallback_index`; the scope policy
|
||||
bounds the number of fallback attempts.
|
||||
|
||||
### MODEL_SCOPE_ISOLATION
|
||||
|
||||
The signed `agent_runtime` execution envelope now carries authoritative Task scope + trace identity.
|
||||
|
||||
The projection layer always overwrites `runtime_input.scope` and `runtime_input.trace_id` from the
|
||||
Online Task, even if an execution request attempts to supply different values.
|
||||
|
||||
Tenant/project lookup may fall back only to the same tenant's policy. It never falls back to a
|
||||
system or legacy policy. `system` and `legacy` require their own explicit policies, avoiding fake
|
||||
tenant identities.
|
||||
|
||||
### MODEL_CREDENTIAL_BOUNDARY
|
||||
|
||||
Model governance stores credential references only. The root-owned external-runtime profile still
|
||||
owns the actual provider credential projection. Task payloads cannot select executable, argv,
|
||||
environment, provider credential, or credential path.
|
||||
|
||||
The external child process remains unable to reach the AIOS control plane.
|
||||
|
||||
### MODEL_TRACE_AUDIT
|
||||
|
||||
Every route, failure, provider/model/policy update and settlement appends to a SHA-256 hash-chained
|
||||
audit table with task ID, trace ID and scope key. `verify_audit_chain()` detects tampering.
|
||||
|
||||
### MODEL_EVALUATION_GATE
|
||||
|
||||
A model cannot enter the registry unless its state is `APPROVED` and it has a 64-hex evaluation
|
||||
evidence digest.
|
||||
|
||||
The staging bootstrap uses the already verified Hermes/OpenClaw E2E external-result SHA256 values as
|
||||
evaluation evidence, rather than a configuration-file hash.
|
||||
|
||||
### MODEL_ROUTING_RESILIENCE
|
||||
|
||||
Route request IDs are idempotent and bound to a canonical request hash. Conflicting replay is
|
||||
rejected. Provider health, reservations, usage settlement and policy/provider/model updates use
|
||||
transaction/CAS semantics.
|
||||
|
||||
## Runtime profile contract
|
||||
|
||||
A root-owned Hermes/OpenClaw profile may declare:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_governance": {
|
||||
"required": true,
|
||||
"provider_ids": ["provider-a"],
|
||||
"model_capabilities": ["text"],
|
||||
"max_output_tokens": 512
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `required=true`:
|
||||
|
||||
- `provider_ids` must be non-empty;
|
||||
- `max_output_tokens` must be positive;
|
||||
- argv/environment must contain at least one governance-controlled marker:
|
||||
`{provider_id}`, `{model_id}`, or `{model_version}`;
|
||||
- `AIOS_MODEL_GOVERNANCE_ENABLED` must be enabled;
|
||||
- a matching scope policy/model/provider must exist;
|
||||
- budget reservation must succeed before the child process is launched.
|
||||
|
||||
The task cannot provide these markers or their values.
|
||||
|
||||
## SQLite authority
|
||||
|
||||
Default database:
|
||||
|
||||
```text
|
||||
/var/lib/aios-model-governance/state.sqlite3
|
||||
```
|
||||
|
||||
The executor process already owns the only runtime mutation path. The directory is created by
|
||||
tmpfiles and is the only new writable path added to that executor.
|
||||
|
||||
No direct Task/Job/Git database writes are introduced.
|
||||
|
||||
## Bootstrap/reconciliation
|
||||
|
||||
`aios_model_governance_admin.py` applies a root-owned JSON bootstrap idempotently. It compares desired
|
||||
state to current rows and uses the current revision as the CAS input only when a change is actually
|
||||
required.
|
||||
|
||||
The repository staging bootstrap is not a Production policy. It exists only to reproduce and
|
||||
govern the already verified non-production Hermes/OpenClaw runtimes.
|
||||
|
||||
## Cost caveat
|
||||
|
||||
The current verified OneAPI-backed runtime evidence reports zero authoritative provider cost for
|
||||
OpenClaw and no structured cost for Hermes. The staging bootstrap therefore does **not** invent
|
||||
pricing and sets cost rates/budget to zero/unbounded.
|
||||
|
||||
Token budgets remain enforced. Real monetary budget enforcement becomes effective as soon as
|
||||
authoritative per-model pricing is entered into the root-owned registry.
|
||||
|
||||
## OpenClaw JIT/WASM compatibility
|
||||
|
||||
The previous live acceptance proved that OpenClaw Node/WASM requires
|
||||
`MemoryDenyWriteExecute=no`. Model Governance must preserve that already-evidenced runtime exception;
|
||||
it must not further weaken `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, loopback deny,
|
||||
credential isolation or repository isolation.
|
||||
|
||||
## Failure/recovery invariants
|
||||
|
||||
- Budget denial occurs before provider execution.
|
||||
- Missing scope policy fails closed.
|
||||
- Missing/disabled governance fails closed when a profile requires it.
|
||||
- Actual usage cannot exceed the reservation.
|
||||
- Provider failure releases that reservation into `FAILED` state before fallback.
|
||||
- Settlement is idempotent.
|
||||
- Route replay is idempotent only for the exact canonical request.
|
||||
- Audit corruption is detectable.
|
||||
- Online Agent checkpoint/result retry remains unchanged and remains the outer recovery authority.
|
||||
|
||||
## Verification
|
||||
|
||||
Required unit/integration coverage includes:
|
||||
|
||||
- registry/evaluation/CAS;
|
||||
- scope isolation and no system→tenant fallback;
|
||||
- model/version pinning;
|
||||
- atomic budget reservation;
|
||||
- settlement/replay;
|
||||
- missing telemetry conservative settlement;
|
||||
- provider circuit/fallback;
|
||||
- trace/audit tamper detection;
|
||||
- authoritative scope/trace projection;
|
||||
- task runtime-input scope override attack;
|
||||
- root-owned provider/model binding;
|
||||
- OpenClaw nested usage extraction;
|
||||
- real Hermes/OpenClaw staging live tasks with Model Governance evidence.
|
||||
|
||||
The governed implementation is not complete without the unified all-modified-files bundle for the
|
||||
candidate delta and the cumulative lineage bundle.
|
||||
@@ -0,0 +1,80 @@
|
||||
# OpenClaw Input Reservation Floor
|
||||
|
||||
## Problem
|
||||
|
||||
Model Governance originally estimated input reservation from the user-visible prompt only.
|
||||
|
||||
The verified OpenClaw runtime injects additional root-owned/system-controlled context including
|
||||
workspace, skills and system instructions. In live acceptance the prompt estimate reserved only
|
||||
153 input tokens while OpenClaw reported approximately 7,770 actual input tokens.
|
||||
|
||||
The settlement correctly failed closed with:
|
||||
|
||||
```text
|
||||
MODEL_USAGE_EXCEEDS_RESERVATION
|
||||
```
|
||||
|
||||
The provider call itself had succeeded. The defect was therefore pre-execution reservation
|
||||
underestimation, not provider connectivity.
|
||||
|
||||
## Contract
|
||||
|
||||
A root-owned external Agent profile may now declare:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_governance": {
|
||||
"input_reservation_floor_tokens": 16384
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The effective input reservation is:
|
||||
|
||||
```text
|
||||
max(
|
||||
byte-based prompt estimate,
|
||||
profile input_reservation_floor_tokens
|
||||
)
|
||||
```
|
||||
|
||||
Only the root-owned runtime profile can choose this floor. Online Task payloads cannot lower or
|
||||
override it.
|
||||
|
||||
The OpenClaw staging profile uses 16,384 tokens. This is greater than twice the observed 7,770-token
|
||||
live input high-water mark while remaining below the registered 32,768-token model input limit.
|
||||
|
||||
Hermes has no floor unless explicitly configured and therefore preserves its existing behavior.
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
The floor is not an unlimited post-execution top-up.
|
||||
|
||||
If actual runtime usage exceeds the reserved amount, settlement still fails with
|
||||
`MODEL_USAGE_EXCEEDS_RESERVATION`.
|
||||
|
||||
This keeps budget enforcement pre-execution and avoids silently authorizing usage after it has
|
||||
already happened.
|
||||
|
||||
## Evidence
|
||||
|
||||
External results expose:
|
||||
|
||||
```text
|
||||
prompt_estimated_input_tokens
|
||||
input_reservation_floor_tokens
|
||||
reserved_estimated_input_tokens
|
||||
```
|
||||
|
||||
alongside the normal settlement and audit evidence.
|
||||
|
||||
## Retry
|
||||
|
||||
The current OpenClaw attempt 1 failure remains preserved. After this code repair is governed and
|
||||
activated, official `NEEDS_REWORK` returns the same fixed Task to the normal retry path. Because AR1
|
||||
already made external request identity attempt-scoped, attempt 2 receives a new external request ID
|
||||
and a new reservation.
|
||||
|
||||
No retry-extension is required because the Task is currently attempt 1 of 3.
|
||||
|
||||
No Model Governance reservation is deleted or rewritten.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Canonical all-modified-files packager and secret scanning
|
||||
|
||||
## Problem
|
||||
|
||||
The historical all-modified-files evidence packager lived only as a copied handoff script.
|
||||
|
||||
Its private-key scanner matched the fragment:
|
||||
|
||||
```text
|
||||
BEGIN PRIVATE KEY
|
||||
```
|
||||
|
||||
instead of a complete PEM header. A regression test containing that literal therefore caused a
|
||||
false secret finding even though the candidate contained no credential.
|
||||
|
||||
A task had to use a temporary evidence-only compatibility packager to complete its bundle. That is
|
||||
acceptable as an emergency workaround but not as a durable unattended path.
|
||||
|
||||
## Repository source of truth
|
||||
|
||||
The canonical implementation is now:
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_all_modified_files_packager.py
|
||||
```
|
||||
|
||||
It preserves the historical output/bundle schemas:
|
||||
|
||||
```text
|
||||
aios.all-modified-files.bundle.v1
|
||||
aios.all-modified-files.bundle-summary.v1
|
||||
aios.modified-documents.index.v1
|
||||
```
|
||||
|
||||
and the host-visible status lines:
|
||||
|
||||
```text
|
||||
ALL_MODIFIED_FILES_BUNDLE=PASS
|
||||
ALL_MODIFIED_FILES_BUNDLE_PATH=...
|
||||
ALL_MODIFIED_FILES_BUNDLE_SHA256=...
|
||||
```
|
||||
|
||||
Future governed tasks should use this repository version instead of copying an unversioned scanner.
|
||||
|
||||
## Secret scanning invariant
|
||||
|
||||
Private-key detection requires a complete PEM header:
|
||||
|
||||
```text
|
||||
-----BEGIN [RSA|EC|DSA|OPENSSH ]PRIVATE KEY-----
|
||||
```
|
||||
|
||||
The following source/test/document text is not secret material and must remain packageable:
|
||||
|
||||
```text
|
||||
BEGIN PRIVATE KEY
|
||||
PRIVATE KEY
|
||||
PEM private-key header
|
||||
```
|
||||
|
||||
Bearer and key/value credential detection remains fail-closed:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <20+ token chars>
|
||||
password=<12+ non-space chars>
|
||||
passwd=<12+ non-space chars>
|
||||
secret=<12+ non-space chars>
|
||||
api_key=<12+ non-space chars>
|
||||
api-key=<12+ non-space chars>
|
||||
```
|
||||
|
||||
Tests construct the real PEM header dynamically so the scanner's own test source does not contain a
|
||||
literal credential-looking header.
|
||||
|
||||
## Bundle integrity
|
||||
|
||||
The packager continues to enforce:
|
||||
|
||||
- exact base→candidate ancestry;
|
||||
- before/after file capture;
|
||||
- rename/copy/delete handling;
|
||||
- path traversal denial;
|
||||
- execution evidence symlink denial;
|
||||
- secret scan over repository before/after and execution evidence;
|
||||
- document index;
|
||||
- SHA-256 closure;
|
||||
- ZIP without symlinks.
|
||||
|
||||
No secret finding is downgraded to a warning.
|
||||
|
||||
## Migration
|
||||
|
||||
The old handoff script may remain for historical reproducibility, but new handoffs should call the
|
||||
repository packager from the verified candidate lineage.
|
||||
|
||||
A temporary compatibility scanner is no longer needed once this candidate is in the lineage.
|
||||
@@ -0,0 +1,210 @@
|
||||
# AIOS Autonomous Control Plane — Admission + Invariant + Repair
|
||||
|
||||
## Why this exists
|
||||
|
||||
Recent Agent registration, Task claim, release-worker, credential and retry failures shared one
|
||||
failure mode:
|
||||
|
||||
> The control plane accepted an intent before the runtime had proved it could execute the intent.
|
||||
|
||||
This design does not add another fleet of daemons. It adds one shared library contract that Task API,
|
||||
workers and governed host recovery consume.
|
||||
|
||||
## Three layers
|
||||
|
||||
```text
|
||||
Intent
|
||||
↓
|
||||
Runtime Admission
|
||||
↓
|
||||
System Invariants
|
||||
↓
|
||||
Repair Policy
|
||||
↓
|
||||
Execution
|
||||
```
|
||||
|
||||
### Runtime Admission
|
||||
|
||||
Admission occurs before Task claim.
|
||||
|
||||
A capability such as `release:candidate` is not considered executable merely because its name
|
||||
matches. The capability contract also binds:
|
||||
|
||||
- supported contract version;
|
||||
- allowed permission;
|
||||
- forbidden operations;
|
||||
- runtime dependencies;
|
||||
- health requirements.
|
||||
|
||||
For `release:candidate`:
|
||||
|
||||
```text
|
||||
permission:
|
||||
prepare_release
|
||||
|
||||
forbidden:
|
||||
production_deploy
|
||||
submit_owner_approval
|
||||
tag_publish
|
||||
baseline_registry_finalize
|
||||
runtime_switch
|
||||
|
||||
dependencies:
|
||||
credential_projection
|
||||
trusted_builder
|
||||
|
||||
health:
|
||||
authenticated_probe
|
||||
release_api_health
|
||||
worker_liveness
|
||||
```
|
||||
|
||||
Only a fully passing decision reaches:
|
||||
|
||||
```text
|
||||
EXECUTION_READY
|
||||
```
|
||||
|
||||
A failed decision remains:
|
||||
|
||||
```text
|
||||
EXECUTION_READY_FAILED
|
||||
```
|
||||
|
||||
and the worker does not call `/claim`.
|
||||
|
||||
Therefore an infrastructure defect discovered by admission does not consume a business attempt.
|
||||
|
||||
### System Invariants
|
||||
|
||||
The same admission checks are exposed as a machine-readable invariant snapshot.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
capability_contract_registry
|
||||
task_capabilities_registered
|
||||
worker_capabilities_registered
|
||||
capability_match
|
||||
forbidden_operations_absent
|
||||
credential_projection
|
||||
trusted_builder
|
||||
authenticated_probe
|
||||
release_api_health
|
||||
worker_liveness
|
||||
```
|
||||
|
||||
A runtime can expose:
|
||||
|
||||
```text
|
||||
HEALTHY
|
||||
DEGRADED
|
||||
```
|
||||
|
||||
without relying on a human reconstructing state from logs.
|
||||
|
||||
### Repair Policy
|
||||
|
||||
Failure classification maps to bounded recovery:
|
||||
|
||||
```text
|
||||
DEPENDENCY_UNAVAILABLE → level 0 / auto local
|
||||
RESOURCE_UNAVAILABLE → level 0 / auto local
|
||||
WORKER_CRASH → level 0 / auto local
|
||||
LEASE_LOST → level 0 / auto local
|
||||
|
||||
CAPABILITY_CONTRACT_ERROR → level 1 / same-lineage repair task
|
||||
CREDENTIAL_PROJECTION_ERROR → level 1 / same-lineage repair task
|
||||
UNKNOWN → level 1 / diagnostic repair task
|
||||
|
||||
POLICY_DENIED → level 2 / Owner authorization
|
||||
BUSINESS_PERMANENT → level 3 / terminal/manual decision
|
||||
```
|
||||
|
||||
Only business-permanent failure consumes the business retry budget in this first contract.
|
||||
|
||||
The current Task API does not yet have a first-class `EXECUTION_READY_FAILED` durable state. Until it
|
||||
does, the Release Worker enforces the most important invariant by refusing to claim a Task whose
|
||||
admission fails.
|
||||
|
||||
## Capability Contract Registry
|
||||
|
||||
`aios_capability_contract.py` remains backward-compatible with existing aliases but now validates
|
||||
every alias target against a versioned semantic contract registry.
|
||||
|
||||
Unknown capabilities and wildcard capabilities remain fail-closed.
|
||||
|
||||
Agent registration and Task creation should next be changed to call `registered_capability()` so a
|
||||
capability cannot be accepted at registration and rejected only later by the matcher.
|
||||
|
||||
## Credential rule
|
||||
|
||||
Admission probes credential usability, not merely credential existence.
|
||||
|
||||
For release candidate creation the authenticated probe intentionally sends an invalid empty
|
||||
`/createReleaseCandidate` request.
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
HTTP 400
|
||||
```
|
||||
|
||||
This proves:
|
||||
|
||||
```text
|
||||
credential readable
|
||||
→ authentication succeeded
|
||||
→ scope accepted
|
||||
→ request validation rejected empty business input
|
||||
```
|
||||
|
||||
A permission failure/401/403/5xx is `EXECUTION_READY_FAILED`.
|
||||
|
||||
## Lineage
|
||||
|
||||
Repair actions are descriptive, not permission grants. Any repair Task must retain the existing
|
||||
root/parent/attempt/authority chain and use governed same-lineage recovery.
|
||||
|
||||
No repair action may silently create a replacement business Task.
|
||||
|
||||
## Authority separation
|
||||
|
||||
Capability answers:
|
||||
|
||||
```text
|
||||
can this runtime perform the operation?
|
||||
```
|
||||
|
||||
Authority answers:
|
||||
|
||||
```text
|
||||
is this operation currently authorized?
|
||||
```
|
||||
|
||||
`release:candidate` never implies Owner approval, deployment, tag publication, baseline finalization
|
||||
or runtime switch.
|
||||
|
||||
## Deployment sequence
|
||||
|
||||
Phase 1 (this candidate):
|
||||
|
||||
- Capability Contract Registry;
|
||||
- shared Autonomous Control Plane library;
|
||||
- Release Worker pre-claim admission;
|
||||
- failure classification + repair evidence.
|
||||
|
||||
Phase 2:
|
||||
|
||||
- Task API uses the same library before returning Tasks from poll and again during claim CAS;
|
||||
- Task schema gains durable `ADMITTED/EXECUTION_READY/EXECUTION_READY_FAILED` evidence without
|
||||
counting pre-side-effect infrastructure failures against business attempts.
|
||||
|
||||
Phase 3:
|
||||
|
||||
- Agent registration validates against the same Capability Contract Registry;
|
||||
- external connector writes only durable Intent/Command Inbox records and leaves execution to the
|
||||
local control plane.
|
||||
|
||||
This sequencing avoids a second controller stack while converging every decision onto one contract.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Durable Owner Authorization and capability projection
|
||||
|
||||
## Problem
|
||||
|
||||
AIOS already supports unattended worker execution after a Task is executable, but Owner intent was
|
||||
not durable at the capability layer. The same Owner repeatedly had to authorize or repair:
|
||||
|
||||
- `governed-task:project`;
|
||||
- `release:candidate`.
|
||||
|
||||
This caused valid one-time Owner intent to be lost between conversations and forced repeated human
|
||||
interaction.
|
||||
|
||||
## Design
|
||||
|
||||
Durable authorization is represented by a root-owned mode `0600` policy outside Git. The policy is
|
||||
persistent until explicitly revoked.
|
||||
|
||||
The first persistable capabilities are deliberately narrow:
|
||||
|
||||
```text
|
||||
governed-task:project
|
||||
release:candidate
|
||||
```
|
||||
|
||||
They cover preparation work only.
|
||||
|
||||
The following capabilities can never be inherited from this durable grant:
|
||||
|
||||
```text
|
||||
submitOwnerApproval
|
||||
startDeployment
|
||||
tag_publish
|
||||
baseline_registry_finalize
|
||||
runtime_switch
|
||||
main_write
|
||||
production_deploy
|
||||
force_push
|
||||
credential_admin
|
||||
```
|
||||
|
||||
Those remain separate high-risk release/Owner-signature gates.
|
||||
|
||||
## Policy
|
||||
|
||||
Example schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.owner-durable-authorization.v1",
|
||||
"grant_id": "owner-permanent-preparation-20260819",
|
||||
"owner_identity": "owner@example",
|
||||
"account": "chatgpt-governed-implementer-singlehost",
|
||||
"capabilities": [
|
||||
"governed-task:project",
|
||||
"release:candidate"
|
||||
],
|
||||
"issued_at": "2026-08-19T05:00:00Z",
|
||||
"not_before": "2026-08-19T05:00:00Z",
|
||||
"persistent_until_revoked": true,
|
||||
"constraints": {
|
||||
"repository": "aios/aios",
|
||||
"allowed_target_prefix": "agent/",
|
||||
"release_version_prefix": "V2R2C",
|
||||
"max_task_risk": "engineering"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The repository never stores the live Owner policy or release credential.
|
||||
|
||||
## Revocation
|
||||
|
||||
A root-owned mode `0600` revocation file lists `revoked_grant_ids`.
|
||||
|
||||
Revocation is checked on every operation, not only at service startup. A running broker therefore
|
||||
fails closed immediately after revocation without needing a new conversation.
|
||||
|
||||
## Broker boundary
|
||||
|
||||
The broker exposes only an AF_UNIX socket:
|
||||
|
||||
```text
|
||||
/run/aios-owner-authorization-broker/broker.sock
|
||||
```
|
||||
|
||||
The socket is mode `0660` and owned by the service's `aios-executor-exchange` group. This avoids introducing another long-lived bearer secret solely for local broker access.
|
||||
|
||||
It exposes only:
|
||||
|
||||
```text
|
||||
GET /v1/capabilities
|
||||
POST /v1/project-governed-task
|
||||
POST /v1/authorize-release-candidate
|
||||
```
|
||||
|
||||
`project-governed-task` calls only the canonical Bridge.
|
||||
|
||||
`authorize-release-candidate` persists an Owner authorization decision only. It deliberately does
|
||||
**not** invent a local trusted-builder endpoint. The external release connector remains responsible
|
||||
for projecting the account's real `release:candidate` scope. Until that external projection exists,
|
||||
the decision state is `AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION`.
|
||||
|
||||
The broker holds only the existing host control credential needed for canonical governed-task
|
||||
projection. It never holds an Owner signing key or a release/deployment credential. The caller also supplies the expected account identifier over the Unix transport; the OS socket permission is the local access boundary.
|
||||
|
||||
|
||||
## Canonical Online Task projection
|
||||
|
||||
A durable `governed-task:project` grant may create a complete canonical Online Task rather than a
|
||||
bare Task. It requires:
|
||||
|
||||
```text
|
||||
require_online_task_projection=true
|
||||
generation=1
|
||||
branch_mode=CREATE_NEW
|
||||
max_attempts<=3
|
||||
risk<=engineering
|
||||
initial action=source_capture
|
||||
```
|
||||
|
||||
Projected task capabilities are limited to:
|
||||
|
||||
```text
|
||||
git.read
|
||||
git.test
|
||||
governed.write.branch
|
||||
governed.write.file
|
||||
```
|
||||
|
||||
A write-capable task still begins as `source_capture`. Existing placeholder-deferral prevents the
|
||||
generic worker from claiming it until a deterministic plan is registered. Repository mutation
|
||||
therefore still requires the normal deterministic plan, WriterLease and governed commit path.
|
||||
|
||||
This fixes the previous split-brain behavior where a bare Task could exist without the canonical
|
||||
governed execution projection.
|
||||
|
||||
|
||||
## External capability projection outbox
|
||||
|
||||
A `release:candidate` authorization now creates a durable machine-readable outbox record:
|
||||
|
||||
```text
|
||||
schema=aios.external-capability-projection.request.v1
|
||||
state=PENDING_EXTERNAL_CONNECTOR
|
||||
capability=release:candidate
|
||||
```
|
||||
|
||||
The projection identifier is deterministic from:
|
||||
|
||||
```text
|
||||
grant_id
|
||||
account
|
||||
capability
|
||||
request_sha256
|
||||
```
|
||||
|
||||
The outbox stores no credentials or Owner signing material. It retains only the already-authorized
|
||||
release-candidate request, policy/request hashes, account, grant id and timestamps.
|
||||
|
||||
The broker exposes read-only Unix-socket endpoints:
|
||||
|
||||
```text
|
||||
GET /v1/projections
|
||||
GET /v1/projections/{projection_id}
|
||||
```
|
||||
|
||||
These endpoints re-check the current Owner policy on every read. If the durable grant has been
|
||||
revoked, enumeration fails closed with `OWNER_GRANT_REVOKED`. The historical outbox record is
|
||||
retained for audit and is not silently deleted.
|
||||
|
||||
The repository intentionally does **not** provide a connector acknowledgement/write endpoint yet.
|
||||
No in-repository code can truthfully assert that the external ChatGPT connector account has gained
|
||||
`release:candidate`. Until the platform consumes the outbox and performs its own identity-scope
|
||||
projection, the only valid state is:
|
||||
|
||||
```text
|
||||
AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION
|
||||
PENDING_EXTERNAL_CONNECTOR
|
||||
```
|
||||
|
||||
A future external projector must preserve:
|
||||
|
||||
```text
|
||||
release:candidate
|
||||
!= submitOwnerApproval
|
||||
!= startDeployment
|
||||
!= tag_publish
|
||||
!= baseline_registry_finalize
|
||||
!= runtime_switch
|
||||
```
|
||||
|
||||
## Idempotency and audit
|
||||
|
||||
Every broker operation requires an idempotency key. A receipt SQLite database stores only:
|
||||
|
||||
- idempotency key;
|
||||
- capability;
|
||||
- request SHA-256;
|
||||
- response SHA-256;
|
||||
- sanitized upstream response;
|
||||
- timestamp.
|
||||
|
||||
An exact retry returns the same receipt. Reusing an idempotency key with different content fails.
|
||||
|
||||
## Unattended invariant
|
||||
|
||||
Once the Owner policy and narrow service credentials are provisioned:
|
||||
|
||||
```text
|
||||
Owner durable grant
|
||||
→ broker decision
|
||||
→ canonical governed task projection / release candidate preparation
|
||||
→ worker / trusted builder
|
||||
```
|
||||
|
||||
No repeated Owner interaction is required for those preparation capabilities.
|
||||
|
||||
Production execution remains:
|
||||
|
||||
```text
|
||||
release candidate
|
||||
→ immutable approval envelope
|
||||
→ Owner signature
|
||||
→ deployment
|
||||
```
|
||||
|
||||
The durable grant never substitutes for the Owner signature.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Owner-approved GPT HTTPS edge activation and rollback
|
||||
|
||||
## Scope
|
||||
|
||||
This controller is the final host-side control mechanism before the disabled-by-default GPT HTTPS
|
||||
edge may bind publicly.
|
||||
|
||||
Repository implementation and public activation remain separate states:
|
||||
|
||||
```text
|
||||
IMPLEMENTED
|
||||
≠
|
||||
OWNER_APPROVED
|
||||
≠
|
||||
ACTIVATED
|
||||
≠
|
||||
RUNTIME_VERIFIED
|
||||
```
|
||||
|
||||
The controller never creates owner approval, TLS assets, DNS, firewall rules or ingress bearer
|
||||
credentials.
|
||||
|
||||
## Approval binding
|
||||
|
||||
The owner approval file is root-owned mode `0600` and has schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.gpt-https-edge.public-approval.v1",
|
||||
"approval_id": "owner-public-edge-...",
|
||||
"candidate_oid": "<exact governed edge candidate>",
|
||||
"domain": "tasks.example.com",
|
||||
"public_bind": true,
|
||||
"bind_host": "0.0.0.0",
|
||||
"port": 18443,
|
||||
"tls_cert_sha256": "<exact certificate sha256>",
|
||||
"issued_at": "2026-08-19T00:00:00Z",
|
||||
"not_before": "2026-08-19T00:00:00Z",
|
||||
"expires_at": "2026-08-20T00:00:00Z",
|
||||
"owner": "<owner identity>"
|
||||
}
|
||||
```
|
||||
|
||||
Approval lifetime is at most seven days. Candidate/domain/bind/port/certificate are immutable within
|
||||
one approval.
|
||||
|
||||
Changing any of those requires a new owner approval.
|
||||
|
||||
## Check
|
||||
|
||||
```text
|
||||
aios_gpt_https_edge_activation.py check --candidate <GE>
|
||||
```
|
||||
|
||||
Checks:
|
||||
|
||||
- approval ownership/mode/schema/time window;
|
||||
- exact candidate binding;
|
||||
- exact certificate hash;
|
||||
- certificate/private-key pair;
|
||||
- certificate hostname;
|
||||
- certificate remaining lifetime;
|
||||
- loopback GPT ingress service health.
|
||||
|
||||
It performs no public network mutation.
|
||||
|
||||
## Activate
|
||||
|
||||
`activate` additionally:
|
||||
|
||||
- writes one exact systemd public-bind drop-in atomically;
|
||||
- enables/restarts only the HTTPS edge unit;
|
||||
- verifies effective ExecStart points at the immutable candidate release;
|
||||
- verifies effective public-bind/domain environment;
|
||||
- performs a TLS handshake to `127.0.0.1:18443` using the approved public hostname;
|
||||
- writes a durable activation receipt.
|
||||
|
||||
Any failure after drop-in mutation automatically invokes rollback.
|
||||
|
||||
Exact replay of an already-active matching approval returns the durable receipt without restarting
|
||||
or creating duplicate state.
|
||||
|
||||
## Rollback
|
||||
|
||||
`rollback`:
|
||||
|
||||
- stops and disables only the HTTPS edge;
|
||||
- removes only the controller-owned public-bind drop-in;
|
||||
- daemon-reloads systemd;
|
||||
- verifies edge inactive;
|
||||
- writes a durable rollback receipt.
|
||||
|
||||
It does not delete:
|
||||
|
||||
- owner approval;
|
||||
- TLS certificate/private key;
|
||||
- ingress bearer token;
|
||||
- internal loopback ingress;
|
||||
- Task/Job state;
|
||||
- Git branches.
|
||||
|
||||
Rollback is therefore replay-safe and does not require reconstructing secrets.
|
||||
|
||||
## Not covered
|
||||
|
||||
This controller intentionally does not own:
|
||||
|
||||
- DNS records;
|
||||
- host firewall/NAT/security-group changes;
|
||||
- certificate issuance/renewal;
|
||||
- OpenAI Custom GPT domain verification;
|
||||
- public Internet acceptance from outside the host.
|
||||
|
||||
Those remain separate owner/environment gates.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Disabled-by-default HTTPS edge for external GPT Online Task access
|
||||
|
||||
## Purpose
|
||||
|
||||
The internal ingress (`127.0.0.1:18945`) remains loopback-only. This edge is the only component
|
||||
allowed to become Internet-facing, and only after a separate owner approval.
|
||||
|
||||
It forwards only:
|
||||
|
||||
```text
|
||||
POST /v1/read-only-tasks
|
||||
GET /v1/read-only-tasks/{request_id}
|
||||
GET /openapi.json
|
||||
GET /health
|
||||
```
|
||||
|
||||
It never proxies:
|
||||
|
||||
```text
|
||||
18892 canonical Bridge
|
||||
18940 Task API
|
||||
WriterLease
|
||||
commit
|
||||
verification
|
||||
release
|
||||
tag
|
||||
baseline
|
||||
arbitrary URLs
|
||||
```
|
||||
|
||||
## Default state
|
||||
|
||||
The systemd unit is disabled by policy unless the owner creates:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge.approved
|
||||
```
|
||||
|
||||
The base unit still binds only to:
|
||||
|
||||
```text
|
||||
127.0.0.1:18443
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
```
|
||||
|
||||
Non-loopback bind is rejected by code unless an owner-controlled drop-in sets:
|
||||
|
||||
```text
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1
|
||||
AIOS_GPT_EDGE_HOST=0.0.0.0
|
||||
AIOS_GPT_EDGE_PUBLIC_DOMAIN=<approved hostname>
|
||||
```
|
||||
|
||||
This repository candidate does not create that drop-in or approval marker.
|
||||
|
||||
## TLS
|
||||
|
||||
TLS certificate/key are owner-managed systemd credentials:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
```
|
||||
|
||||
The service requires TLS 1.2 or newer, disables TLS compression, and never logs or returns key
|
||||
material.
|
||||
|
||||
Certificate/key issuance and renewal are external owner responsibilities until a separately
|
||||
governed ACME workflow is implemented.
|
||||
|
||||
## Request protections
|
||||
|
||||
The edge provides:
|
||||
|
||||
- 64 KiB request cap;
|
||||
- 256 KiB upstream response cap;
|
||||
- JSON validation before proxying;
|
||||
- fixed upstream `http://127.0.0.1:18945`;
|
||||
- Authorization-only header forwarding;
|
||||
- no Cookie/X-Forwarded-* trust;
|
||||
- route/method allowlist;
|
||||
- per-source-IP in-memory rate limiting;
|
||||
- bounded concurrent request handling;
|
||||
- optional exact Host-header enforcement;
|
||||
- HSTS / no-store / nosniff / no-referrer response headers.
|
||||
|
||||
The rate limiter is intentionally simple and local. Multi-node/global abuse protection is outside
|
||||
the current one-host architecture.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTPS edge does not create a new authentication scheme. It forwards the external Bearer
|
||||
credential to the loopback GPT ingress, which verifies `gpt_ingress_token`.
|
||||
|
||||
The edge never receives the AIOS control-plane token.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
Public activation is a high-risk network operation and requires explicit owner approval. Before
|
||||
activation verify:
|
||||
|
||||
```text
|
||||
host-side GPT ingress E2E=VERIFIED
|
||||
owner ingress token provisioned
|
||||
TLS certificate/key provisioned
|
||||
public hostname owned and DNS configured
|
||||
firewall/network exposure reviewed
|
||||
explicit public-bind approval recorded
|
||||
rollback plan available
|
||||
```
|
||||
|
||||
Only after those gates may an owner-authorized runtime drop-in bind this service publicly.
|
||||
|
||||
Until then:
|
||||
|
||||
```text
|
||||
GPT_HTTPS_EDGE_IMPLEMENTATION=candidate
|
||||
PUBLIC_NETWORK_EXPOSURE=NONE
|
||||
PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# External GPT → Online Task ingress
|
||||
|
||||
## Scope
|
||||
|
||||
This component closes the current architectural gap between an external Custom GPT and AIOS
|
||||
without exposing the internal Task API (`18940`) or canonical Bridge (`18892`) directly.
|
||||
|
||||
The first supported public-facing capability is intentionally read-only:
|
||||
|
||||
```text
|
||||
authenticated external intent
|
||||
→ host-side ingress
|
||||
→ canonical /v2/governed-task
|
||||
→ canonical /v2/execution-envelope
|
||||
→ persistent generic worker
|
||||
→ source_capture result
|
||||
→ official control-plane verification
|
||||
```
|
||||
|
||||
The ingress itself never claims, heartbeats, verifies, commits, acquires WriterLease, or writes
|
||||
protected refs.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service binds only to loopback (`127.0.0.1:18945`). A future owner-approved TLS reverse proxy
|
||||
or tunnel may expose this service externally. Direct non-loopback binding is denied by code.
|
||||
|
||||
Two independent credentials are projected by systemd:
|
||||
|
||||
- `gpt_ingress_token`: authenticates the external caller to this ingress.
|
||||
- `agent_task_control_token`: lets the ingress call the loopback AIOS control plane.
|
||||
|
||||
Neither credential is stored in Git, SQLite, task payloads, results, logs, or API responses.
|
||||
|
||||
The ingress has no repository filesystem access.
|
||||
|
||||
## Caller-controlled fields
|
||||
|
||||
Allowed:
|
||||
|
||||
```text
|
||||
request_id
|
||||
goal
|
||||
instructions
|
||||
success_criteria
|
||||
constraints.max_files
|
||||
constraints.focus_paths
|
||||
constraints.notes
|
||||
```
|
||||
|
||||
Not caller-controlled:
|
||||
|
||||
```text
|
||||
repository
|
||||
base_oid
|
||||
expected_branch_oid
|
||||
target_branch
|
||||
branch_mode
|
||||
generation
|
||||
risk
|
||||
priority
|
||||
max_attempts
|
||||
capabilities
|
||||
action/adapter
|
||||
WriterLease
|
||||
worker identity
|
||||
credentials
|
||||
verification decision
|
||||
main/production/tag/baseline permissions
|
||||
```
|
||||
|
||||
All external tasks are hard-bound to:
|
||||
|
||||
```text
|
||||
repository=aios/aios
|
||||
capabilities=[git.read, git.test]
|
||||
action=source_capture
|
||||
writer_lease=false
|
||||
repository_mutation=forbidden
|
||||
control_plane_side_effects=forbidden
|
||||
credential_access=forbidden
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
`request_id` is the durable external idempotency boundary.
|
||||
|
||||
The ingress stores only:
|
||||
|
||||
```text
|
||||
request hash
|
||||
derived AIOS task id
|
||||
canonical job id
|
||||
base oid
|
||||
sanitized receipt
|
||||
state
|
||||
```
|
||||
|
||||
An exact retry returns the same receipt. Reusing a `request_id` with different content returns
|
||||
`IDEMPOTENCY_CONFLICT`.
|
||||
|
||||
A crash after canonical task creation can recover the same binding from the Task API rather than
|
||||
creating a replacement Task.
|
||||
|
||||
## Status
|
||||
|
||||
`GET /v1/read-only-tasks/{request_id}` returns only sanitized state:
|
||||
|
||||
```text
|
||||
task/job ids
|
||||
status
|
||||
attempt
|
||||
whether claimed
|
||||
result classification
|
||||
official verification decision/classification
|
||||
source snapshot hash/count when available
|
||||
```
|
||||
|
||||
It does not proxy raw Task metadata, execution envelopes, result text, credentials, or source files.
|
||||
|
||||
## Public exposure
|
||||
|
||||
This candidate does **not** make AIOS publicly reachable. Public exposure requires a separately
|
||||
approved HTTPS ingress/reverse proxy with:
|
||||
|
||||
- TLS;
|
||||
- external authentication lifecycle;
|
||||
- rate limiting / abuse controls;
|
||||
- request size limits;
|
||||
- observability;
|
||||
- explicit owner approval for the network exposure.
|
||||
|
||||
Until that step is verified:
|
||||
|
||||
```text
|
||||
HOST_SIDE_EXTERNAL_GPT_ADAPTER=implemented/staging-candidate
|
||||
PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=not verified
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Release candidate capability contract remediation
|
||||
|
||||
## Problem
|
||||
|
||||
The release-preparation worker can be correctly registered with the raw capability:
|
||||
|
||||
```text
|
||||
release:candidate
|
||||
```
|
||||
|
||||
while the canonical Task API matcher still rejects the worker.
|
||||
|
||||
Before this repair, the capability matrix is:
|
||||
|
||||
```text
|
||||
worker raw = ["release:candidate"]
|
||||
worker effective= []
|
||||
task required = ["exact:release:candidate"]
|
||||
eligible = false
|
||||
```
|
||||
|
||||
The Task therefore remains `QUEUED`, `attempt=0`, and cannot be claimed even though both the Task and
|
||||
worker use the same literal capability.
|
||||
|
||||
## Root cause
|
||||
|
||||
`aios_capability_contract.py` is deliberately fail-closed:
|
||||
|
||||
- task-side unknown names become `exact:<name>`;
|
||||
- worker-side unknown names are discarded;
|
||||
- wildcard worker capabilities are ignored.
|
||||
|
||||
`release:candidate` was absent from both canonical alias tables.
|
||||
|
||||
## Repair
|
||||
|
||||
Add exactly one semantic capability to both vocabularies:
|
||||
|
||||
```text
|
||||
release:candidate → release:candidate
|
||||
```
|
||||
|
||||
Do not add:
|
||||
|
||||
```text
|
||||
release:*
|
||||
submitOwnerApproval
|
||||
startDeployment
|
||||
tag_publish
|
||||
baseline_registry_finalize
|
||||
runtime_switch
|
||||
```
|
||||
|
||||
Unknown names and wildcards must continue to fail closed.
|
||||
|
||||
## Runtime recovery
|
||||
|
||||
After the governed remediation candidate is officially verified:
|
||||
|
||||
1. install the candidate capability-contract module as a higher-precedence Task API overlay;
|
||||
2. restart only `aios-agent-task-api.service`;
|
||||
3. require `/health` success;
|
||||
4. require the fixed existing release Task to remain the same Task/idempotency;
|
||||
5. let the already-active dedicated release worker poll and claim it automatically;
|
||||
6. require heartbeat, trusted-builder result and official verification;
|
||||
7. do not manually claim or create a replacement live Task.
|
||||
|
||||
The fixed V2R2C2 live Task may complete as the acceptance candidate it was originally bound to.
|
||||
Because this remediation is a later source commit, the first release candidate that includes the
|
||||
matcher repair should use the next immutable version (for example `V2R2C3`) rather than rewriting an
|
||||
already-created V2R2C2 candidate.
|
||||
|
||||
## Governance boundary
|
||||
|
||||
This repair affects only Task eligibility. It grants no credential and performs no release action by
|
||||
itself.
|
||||
|
||||
The dedicated release worker still requires its independent `release:candidate` credential, and the
|
||||
following remain separate:
|
||||
|
||||
```text
|
||||
Owner signed approval
|
||||
deployment
|
||||
tag publication
|
||||
baseline finalization
|
||||
runtime switch
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# Fixed V2R2C2 release lineage contract
|
||||
|
||||
## Why this contract exists
|
||||
|
||||
AIOS has multiple engineering commits in the same ancestry, and they serve different purposes.
|
||||
|
||||
The fixed V2R2C2 live Task was created by the dedicated Release Worker activation. Its immutable
|
||||
release source is therefore the Release Worker implementation candidate:
|
||||
|
||||
```text
|
||||
71e736b05fab13efc06af7a4fe2dc022c1cf9a85
|
||||
```
|
||||
|
||||
Later engineering commits are descendants but are not retroactive V2R2C2 release sources:
|
||||
|
||||
```text
|
||||
17deb91ca7a187dc81766945b1174e854f86bd25
|
||||
capability-contract remediation
|
||||
|
||||
b4b4416c93fedbe451d655d49e28f915b8fd38e6
|
||||
Autonomous Control Plane engineering candidate
|
||||
```
|
||||
|
||||
Conflating "current engineering base" with "immutable release source" caused ACP activation to
|
||||
fail closed with `FIXED_RELEASE_SOURCE_DRIFT`.
|
||||
|
||||
## Canonical fixed lineage
|
||||
|
||||
```text
|
||||
Task:
|
||||
r42-release-candidate-v2r2c2-live-20260819
|
||||
|
||||
candidate_id:
|
||||
r42-release-candidate-v2r2c2-20260819
|
||||
|
||||
source_commit:
|
||||
71e736b05fab13efc06af7a4fe2dc022c1cf9a85
|
||||
|
||||
version:
|
||||
V2R2C2
|
||||
|
||||
required_capabilities:
|
||||
["release:candidate"]
|
||||
```
|
||||
|
||||
This contract is immutable for V2R2C2.
|
||||
|
||||
## What may change
|
||||
|
||||
The following are lifecycle state, not release identity:
|
||||
|
||||
```text
|
||||
status
|
||||
attempt
|
||||
max_attempts
|
||||
revision
|
||||
claimed_by
|
||||
lease
|
||||
verification
|
||||
```
|
||||
|
||||
They may change through official retry/claim/result/verification without changing the release
|
||||
lineage.
|
||||
|
||||
## What must never be "repaired"
|
||||
|
||||
Do not rewrite the fixed V2R2C2 Task source to a later engineering commit.
|
||||
|
||||
A later engineering source that must itself be released receives a later immutable release version
|
||||
(for example V2R2C3), not a mutation of V2R2C2.
|
||||
|
||||
## Activation invariant
|
||||
|
||||
ACP activation must:
|
||||
|
||||
1. verify its own governed engineering candidate separately;
|
||||
2. read the live fixed V2R2C2 Task;
|
||||
3. validate that Task through `validate_fixed_v2r2c2_task()`;
|
||||
4. never compare the fixed release source to the ACP engineering base/candidate;
|
||||
5. never mutate the fixed Task merely to satisfy activation.
|
||||
|
||||
This makes engineering lineage and release lineage explicit and independently verifiable.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Release Preparation Online Agent
|
||||
|
||||
## Purpose
|
||||
|
||||
This worker closes the release-preparation execution gap without widening the ChatGPT connector
|
||||
identity.
|
||||
|
||||
The dedicated Agent identity is:
|
||||
|
||||
```text
|
||||
online-release-worker-cn-network-mkt-20260819
|
||||
```
|
||||
|
||||
Its only advertised task capability is:
|
||||
|
||||
```text
|
||||
release:candidate
|
||||
```
|
||||
|
||||
The worker may create an immutable release candidate, but it never submits Owner approval and never
|
||||
starts deployment, publishes a tag, finalizes a baseline, switches runtime or writes Git.
|
||||
|
||||
## Credentials
|
||||
|
||||
The worker uses two independently scoped credentials:
|
||||
|
||||
```text
|
||||
agent_token
|
||||
release_candidate_token
|
||||
```
|
||||
|
||||
`agent_token` is the Online Agent registration credential. It must be encrypted into systemd's
|
||||
credential store and must never be written to Git, Task metadata, evidence or logs.
|
||||
|
||||
`release_candidate_token` is provisioned during Owner-authorized runtime activation. Only its SHA-256 record is stored in the release API token registry; the plaintext is immediately encrypted into systemd credstore and the temporary plaintext is removed. Its only allowed scope is:
|
||||
|
||||
```text
|
||||
release:candidate
|
||||
```
|
||||
|
||||
It is explicitly not an Owner signing key and must not grant:
|
||||
|
||||
```text
|
||||
submitOwnerApproval
|
||||
startDeployment
|
||||
tag_publish
|
||||
baseline_registry_finalize
|
||||
runtime_switch
|
||||
```
|
||||
|
||||
The unit reads both credentials through systemd `LoadCredentialEncrypted`; neither plaintext token is kept in the unit, repository, Task metadata, evidence, or logs.
|
||||
|
||||
## Task contract
|
||||
|
||||
The worker will only claim Tasks whose exact required capability set is:
|
||||
|
||||
```json
|
||||
["release:candidate"]
|
||||
```
|
||||
|
||||
The Task must provide a bounded `constraints.release_candidate` payload (metadata is accepted only for compatibility):
|
||||
|
||||
```json
|
||||
{
|
||||
"candidate_id": "...",
|
||||
"source_commit": "<40 hex>",
|
||||
"version": "V2R2C2",
|
||||
"projection_id": "projection-..."
|
||||
}
|
||||
```
|
||||
|
||||
No caller-supplied URL, method, headers, command, shell, environment, credential path, deployment
|
||||
operation or arbitrary release route is accepted.
|
||||
|
||||
The release API base is host configuration, not Task input. The only release mutation invoked by the
|
||||
worker is:
|
||||
|
||||
```text
|
||||
POST /createReleaseCandidate
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```text
|
||||
poll
|
||||
→ claim
|
||||
→ heartbeat
|
||||
→ createReleaseCandidate
|
||||
→ result submit
|
||||
→ official verifier
|
||||
```
|
||||
|
||||
The result declares:
|
||||
|
||||
```text
|
||||
writer_lease=NONE
|
||||
changed_files=[]
|
||||
direct_main_write=false
|
||||
production_write=false
|
||||
tag_write=false
|
||||
baseline_write=false
|
||||
runtime_switch=false
|
||||
owner_approval_submitted=false
|
||||
```
|
||||
|
||||
## Remaining high-risk gate
|
||||
|
||||
Successful release-candidate creation does not authorize Production.
|
||||
|
||||
The next stage remains:
|
||||
|
||||
```text
|
||||
release candidate
|
||||
→ immutable approval envelope
|
||||
→ Owner signature
|
||||
→ startDeployment
|
||||
```
|
||||
|
||||
That separation is mandatory even when release preparation is fully unattended.
|
||||
|
||||
|
||||
## Unattended availability
|
||||
|
||||
The release worker has its own systemd watchdog rather than modifying the shared worker-availability
|
||||
guard contract.
|
||||
|
||||
Every successful Task poll and Task heartbeat emits:
|
||||
|
||||
```text
|
||||
WATCHDOG=1
|
||||
```
|
||||
|
||||
and the service uses:
|
||||
|
||||
```text
|
||||
Type=notify
|
||||
WatchdogSec=90
|
||||
Restart=always
|
||||
```
|
||||
|
||||
A process that is technically alive but no longer polling/heartbeating therefore gets restarted by
|
||||
systemd. The worker also writes `worker-liveness.json` for operator diagnostics.
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
@@ -17,8 +19,18 @@ REQUIRED_APP = (
|
||||
'gitea_tag_publication.py',
|
||||
'silent_release_controller.py',
|
||||
'silent_release_hardening.py',
|
||||
'aios_trusted_candidate_builder.py',
|
||||
'aios_gitops_job_controller.py',
|
||||
)
|
||||
REQUIRED_RUNTIME = (
|
||||
'orchestrator/__init__.py',
|
||||
'orchestrator/action_monitor.py',
|
||||
)
|
||||
REQUIRED_BUILDER_ASSETS = (
|
||||
Path('config/gitops/trusted-candidate-builder-policy.json'),
|
||||
Path('deploy/systemd/aios-trusted-candidate-builder.socket'),
|
||||
Path('deploy/systemd/aios-trusted-candidate-builder.service'),
|
||||
)
|
||||
OPTIONAL_APP = (
|
||||
'aios_chatgpt_actions_api.py',
|
||||
'aios_continuation_executor.py',
|
||||
@@ -44,6 +56,38 @@ def private_marker(path: Path) -> bool:
|
||||
)
|
||||
return any(re.search(pattern, text) for pattern in patterns)
|
||||
|
||||
def checked_regular_file(path: Path, label: str) -> Path:
|
||||
forbidden_parts = {'.git', 'credential', 'credentials', 'secret', 'secrets'}
|
||||
if forbidden_parts.intersection(part.lower() for part in Path(label).parts):
|
||||
raise SystemExit(f'credential path rejected: {label}')
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: {label}')
|
||||
try:
|
||||
mode = path.stat(follow_symlinks=False).st_mode
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f'required dependency missing: {label}') from None
|
||||
if not stat.S_ISREG(mode):
|
||||
raise SystemExit(f'non-regular file rejected: {label}')
|
||||
return path
|
||||
|
||||
def normalized_tar_info(path: Path, arcname: str) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(arcname)
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = 'root'
|
||||
info.gname = 'root'
|
||||
if path.is_dir():
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o755
|
||||
info.size = 0
|
||||
else:
|
||||
info.type = tarfile.REGTYPE
|
||||
source_mode = path.stat(follow_symlinks=False).st_mode
|
||||
info.mode = 0o755 if source_mode & stat.S_IXUSR else 0o644
|
||||
info.size = path.stat(follow_symlinks=False).st_size
|
||||
return info
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--source-root', required=True, type=Path)
|
||||
@@ -63,27 +107,48 @@ def main() -> int:
|
||||
paths = []
|
||||
for name in REQUIRED_APP:
|
||||
path = app / name
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required app dependency missing: {name}')
|
||||
checked_regular_file(path, f'active-runtime/app/{name}')
|
||||
paths.append((Path('app') / name, path))
|
||||
for name in OPTIONAL_APP:
|
||||
path = app / name
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: active-runtime/app/{name}')
|
||||
if path.is_file():
|
||||
checked_regular_file(path, f'active-runtime/app/{name}')
|
||||
paths.append((Path('app') / name, path))
|
||||
for rel in ('orchestrator/__init__.py', 'orchestrator/action_monitor.py'):
|
||||
for rel in REQUIRED_RUNTIME:
|
||||
path = runtime / rel
|
||||
if not path.is_file():
|
||||
raise SystemExit(f'required runtime dependency missing: {rel}')
|
||||
checked_regular_file(path, f'runtime/agent-os/{rel}')
|
||||
paths.append((Path('runtime-agent-os') / rel, path))
|
||||
for rel in REQUIRED_BUILDER_ASSETS:
|
||||
path = source / rel
|
||||
checked_regular_file(path, str(rel).replace('\\', '/'))
|
||||
paths.append((rel, path))
|
||||
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
||||
if openapi.is_symlink():
|
||||
raise SystemExit('symlink rejected: docs/gitops/openapi-chatgpt-actions.yaml')
|
||||
if openapi.is_file():
|
||||
checked_regular_file(openapi, 'docs/gitops/openapi-chatgpt-actions.yaml')
|
||||
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
||||
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
||||
root = source / folder
|
||||
if root.is_dir():
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_symlink():
|
||||
raise SystemExit(f'symlink rejected: {path.relative_to(source)}')
|
||||
if not path.is_dir() and not path.is_file():
|
||||
raise SystemExit(f'special file rejected: {path.relative_to(source)}')
|
||||
if path.is_file() and path.suffix.lower() in {'.json', '.yaml', '.yml', '.md'}:
|
||||
checked_regular_file(path, str(path.relative_to(source)).replace('\\', '/'))
|
||||
paths.append((Path(folder) / path.relative_to(root), path))
|
||||
seen: set[str] = set()
|
||||
for rel, _ in paths:
|
||||
normalized = str(rel).replace('\\', '/')
|
||||
if normalized.startswith('/') or '..' in Path(normalized).parts:
|
||||
raise SystemExit(f'path traversal rejected: {normalized}')
|
||||
if normalized in seen:
|
||||
raise SystemExit(f'duplicate archive member rejected: {normalized}')
|
||||
seen.add(normalized)
|
||||
for rel, path in paths:
|
||||
if private_marker(path):
|
||||
raise SystemExit(f'private marker rejected: {rel}')
|
||||
@@ -110,27 +175,25 @@ def main() -> int:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(path.read_bytes())
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + '\n', encoding='utf-8')
|
||||
with tarfile.open(archive, 'w:gz', format=tarfile.PAX_FORMAT) as tf:
|
||||
for path in sorted(root.rglob('*')):
|
||||
rel = path.relative_to(root.parent)
|
||||
info = tf.gettarinfo(str(path), arcname=str(rel).replace('\\', '/'))
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = 'root'
|
||||
info.gname = 'root'
|
||||
if path.is_file():
|
||||
with path.open('rb') as fh:
|
||||
tf.addfile(info, fh)
|
||||
else:
|
||||
tf.addfile(info)
|
||||
with archive.open('wb') as raw:
|
||||
with gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as tf:
|
||||
for path in sorted(root.rglob('*')):
|
||||
rel = path.relative_to(root.parent)
|
||||
arcname = str(rel).replace('\\', '/')
|
||||
info = normalized_tar_info(path, arcname)
|
||||
if path.is_file():
|
||||
with path.open('rb') as fh:
|
||||
tf.addfile(info, fh)
|
||||
else:
|
||||
tf.addfile(info)
|
||||
digest = sha256(archive)
|
||||
receipt = {
|
||||
'schema': 'aios.v2r2c1.model-api-staging-build-receipt.v1',
|
||||
'target_version': 'V2R2C1',
|
||||
'source_oid': oid,
|
||||
'repository_identity': 'aios/aios',
|
||||
'artifact_path': str(archive),
|
||||
'artifact_path': archive.name,
|
||||
'artifact_sha256': digest,
|
||||
'deployment_state': 'STAGING',
|
||||
'manifest_schema': SCHEMA,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_all_modified_files_packager as packager
|
||||
|
||||
|
||||
def init_repo(tmp_path: Path) -> tuple[Path, Path, str]:
|
||||
bare=tmp_path/"repo.git"
|
||||
work=tmp_path/"work"
|
||||
subprocess.run(["git","init","--bare",str(bare)],check=True,capture_output=True)
|
||||
subprocess.run(["git","clone",str(bare),str(work)],check=True,capture_output=True)
|
||||
subprocess.run(["git","-C",str(work),"config","user.email","test@example.invalid"],check=True)
|
||||
subprocess.run(["git","-C",str(work),"config","user.name","AIOS Test"],check=True)
|
||||
(work/"README.md").write_text("base\n",encoding="utf-8")
|
||||
subprocess.run(["git","-C",str(work),"add","README.md"],check=True)
|
||||
subprocess.run(["git","-C",str(work),"commit","-m","base"],check=True,capture_output=True)
|
||||
subprocess.run(["git","-C",str(work),"push","origin","HEAD:refs/heads/main"],check=True,capture_output=True)
|
||||
base=subprocess.check_output(["git","-C",str(work),"rev-parse","HEAD"],text=True).strip()
|
||||
return bare,work,base
|
||||
|
||||
|
||||
def commit(work: Path, message: str) -> str:
|
||||
subprocess.run(["git","-C",str(work),"add","-A"],check=True)
|
||||
subprocess.run(["git","-C",str(work),"commit","-m",message],check=True,capture_output=True)
|
||||
candidate=subprocess.check_output(["git","-C",str(work),"rev-parse","HEAD"],text=True).strip()
|
||||
subprocess.run(["git","-C",str(work),"push","origin","HEAD:refs/heads/main"],check=True,capture_output=True)
|
||||
return candidate
|
||||
|
||||
|
||||
def real_pem_header() -> bytes:
|
||||
return b"-----" + b"BEGIN " + b"PRIVATE KEY" + b"-----"
|
||||
|
||||
|
||||
def bearer_value() -> bytes:
|
||||
return b"Authorization" + b": Bearer " + (b"a"*40)
|
||||
|
||||
|
||||
def key_value_secret() -> bytes:
|
||||
return b"api_key" + b"=" + (b"x"*24)
|
||||
|
||||
|
||||
def test_plain_private_key_words_are_not_secret_findings():
|
||||
for value in (
|
||||
b"BEGIN PRIVATE KEY",
|
||||
b"PRIVATE KEY",
|
||||
b"PEM private-key header",
|
||||
b"assert 'BEGIN PRIVATE KEY' in message",
|
||||
):
|
||||
assert packager.secret_finding(value) is None
|
||||
|
||||
|
||||
def test_complete_pem_private_key_header_is_rejected():
|
||||
assert packager.secret_finding(real_pem_header())=="PEM_PRIVATE_KEY"
|
||||
assert packager.secret_finding(
|
||||
b"-----BEGIN OPENSSH " + b"PRIVATE KEY-----"
|
||||
)=="PEM_PRIVATE_KEY"
|
||||
|
||||
|
||||
def test_bearer_and_key_value_secret_detection_remains_fail_closed():
|
||||
assert packager.secret_finding(bearer_value())=="AUTHORIZATION_BEARER"
|
||||
assert packager.secret_finding(key_value_secret())=="KEY_VALUE_SECRET"
|
||||
|
||||
|
||||
def test_short_or_descriptive_secret_words_do_not_false_positive():
|
||||
assert packager.secret_finding(b"secret=short") is None
|
||||
assert packager.secret_finding(b"password field is validated") is None
|
||||
assert packager.secret_finding(b"api_key example placeholder") is None
|
||||
|
||||
|
||||
def test_end_to_end_bundle_accepts_literal_begin_private_key_text(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"tests").mkdir()
|
||||
(work/"tests"/"test_text.py").write_text(
|
||||
'ERROR = "BEGIN PRIVATE KEY is not a complete PEM header"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(work/"docs").mkdir()
|
||||
(work/"docs"/"note.md").write_text("Scanner regression.\n",encoding="utf-8")
|
||||
candidate=commit(work,"candidate")
|
||||
|
||||
extra=tmp_path/"execution"
|
||||
extra.mkdir()
|
||||
(extra/"evidence.json").write_text('{"status":"PASS"}\n',encoding="utf-8")
|
||||
out=tmp_path/"out"
|
||||
|
||||
summary=packager.build_bundle(
|
||||
git_dir=bare,
|
||||
base=base,
|
||||
candidate=candidate,
|
||||
task_id="r42-packager-test",
|
||||
extra_dir=extra,
|
||||
output_root=out,
|
||||
now=datetime(2026,8,19,tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert summary["changed_file_count"]==2
|
||||
assert summary["execution_file_count"]==1
|
||||
assert summary["repository_document_count"]==1
|
||||
assert summary["execution_document_count"]==0
|
||||
bundle_path=Path(summary["bundle_path"])
|
||||
assert bundle_path.is_file()
|
||||
assert packager.sha256(bundle_path.read_bytes())==summary["bundle_sha256"]
|
||||
|
||||
with zipfile.ZipFile(bundle_path) as archive:
|
||||
names=set(archive.namelist())
|
||||
assert "repo/after/tests/test_text.py" in names
|
||||
assert "repo/after/docs/note.md" in names
|
||||
assert "execution/evidence.json" in names
|
||||
assert "documents-index.json" in names
|
||||
assert "all-modified-files-manifest.json" in names
|
||||
assert "checksums.sha256" in names
|
||||
manifest=json.loads(archive.read("all-modified-files-manifest.json"))
|
||||
assert manifest["schema"]=="aios.all-modified-files.bundle.v1"
|
||||
assert manifest["candidate_oid"]==candidate
|
||||
|
||||
packager.verify_checksums(out/"bundle")
|
||||
|
||||
|
||||
def test_end_to_end_bundle_rejects_real_pem_header(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"credential.txt").write_bytes(
|
||||
real_pem_header()+b"\nnot-a-real-key-body\n"
|
||||
)
|
||||
candidate=commit(work,"credential-like candidate")
|
||||
|
||||
with pytest.raises(
|
||||
packager.PackagerError,
|
||||
match="PEM_PRIVATE_KEY:after:credential.txt",
|
||||
):
|
||||
packager.build_bundle(
|
||||
git_dir=bare,
|
||||
base=base,
|
||||
candidate=candidate,
|
||||
task_id="r42-packager-pem-reject",
|
||||
output_root=tmp_path/"out",
|
||||
)
|
||||
|
||||
|
||||
def test_execution_evidence_bearer_is_rejected(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"normal.txt").write_text("normal\n",encoding="utf-8")
|
||||
candidate=commit(work,"normal")
|
||||
extra=tmp_path/"execution"
|
||||
extra.mkdir()
|
||||
(extra/"unsafe.txt").write_bytes(bearer_value()+b"\n")
|
||||
|
||||
with pytest.raises(
|
||||
packager.PackagerError,
|
||||
match="AUTHORIZATION_BEARER:execution:unsafe.txt",
|
||||
):
|
||||
packager.build_bundle(
|
||||
git_dir=bare,
|
||||
base=base,
|
||||
candidate=candidate,
|
||||
task_id="r42-packager-bearer-reject",
|
||||
extra_dir=extra,
|
||||
output_root=tmp_path/"out",
|
||||
)
|
||||
|
||||
|
||||
def test_execution_symlink_is_rejected(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"normal.txt").write_text("normal\n",encoding="utf-8")
|
||||
candidate=commit(work,"normal")
|
||||
extra=tmp_path/"execution"
|
||||
extra.mkdir()
|
||||
target=extra/"target.txt"
|
||||
target.write_text("safe\n",encoding="utf-8")
|
||||
(extra/"link.txt").symlink_to(target)
|
||||
|
||||
with pytest.raises(
|
||||
packager.PackagerError,
|
||||
match="EXTRA_FILE_SYMLINK_REJECTED",
|
||||
):
|
||||
packager.build_bundle(
|
||||
git_dir=bare,
|
||||
base=base,
|
||||
candidate=candidate,
|
||||
task_id="r42-packager-symlink-reject",
|
||||
extra_dir=extra,
|
||||
output_root=tmp_path/"out",
|
||||
)
|
||||
|
||||
|
||||
def test_non_ancestor_lineage_is_rejected(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"one.txt").write_text("one\n",encoding="utf-8")
|
||||
first=commit(work,"first")
|
||||
|
||||
subprocess.run(["git","-C",str(work),"checkout","--detach",base],check=True,capture_output=True)
|
||||
(work/"other.txt").write_text("other\n",encoding="utf-8")
|
||||
subprocess.run(["git","-C",str(work),"add","other.txt"],check=True)
|
||||
subprocess.run(["git","-C",str(work),"commit","-m","other"],check=True,capture_output=True)
|
||||
other=subprocess.check_output(["git","-C",str(work),"rev-parse","HEAD"],text=True).strip()
|
||||
subprocess.run(
|
||||
["git","-C",str(work),"push","origin","HEAD:refs/heads/other"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
with pytest.raises(packager.PackagerError,match="LINEAGE_INVALID"):
|
||||
packager.validate_lineage(bare,first,other)
|
||||
|
||||
|
||||
def test_bundle_checksum_closure_detects_tamper(tmp_path):
|
||||
bare,work,base=init_repo(tmp_path)
|
||||
(work/"normal.txt").write_text("normal\n",encoding="utf-8")
|
||||
candidate=commit(work,"normal")
|
||||
out=tmp_path/"out"
|
||||
packager.build_bundle(
|
||||
git_dir=bare,
|
||||
base=base,
|
||||
candidate=candidate,
|
||||
task_id="r42-packager-checksum",
|
||||
output_root=out,
|
||||
)
|
||||
(out/"bundle"/"repo"/"after"/"normal.txt").write_text(
|
||||
"tampered\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(
|
||||
packager.PackagerError,
|
||||
match="CHECKSUM_CLOSURE_INVALID",
|
||||
):
|
||||
packager.verify_checksums(out/"bundle")
|
||||
|
||||
|
||||
def test_source_does_not_contain_complete_pem_header_literal():
|
||||
source=(ROOT/"active-runtime/app/aios_all_modified_files_packager.py").read_bytes()
|
||||
tests=(ROOT/"tests/test_all_modified_files_packager.py").read_bytes()
|
||||
complete=real_pem_header()
|
||||
assert complete not in source
|
||||
assert complete not in tests
|
||||
|
||||
|
||||
def test_document_path_contract():
|
||||
assert packager.is_document_path("docs/spec.md")
|
||||
assert packager.is_document_path("README")
|
||||
assert packager.is_document_path("CHANGELOG.md")
|
||||
assert not packager.is_document_path("active-runtime/app/main.py")
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_autonomous_control_plane as acp
|
||||
import aios_capability_contract as contract
|
||||
import aios_release_online_agent_worker as worker
|
||||
|
||||
|
||||
def _ready_probes():
|
||||
return {
|
||||
"credential_projection": lambda: True,
|
||||
"trusted_builder": lambda: True,
|
||||
"authenticated_probe": lambda: True,
|
||||
"release_api_health": lambda: True,
|
||||
"worker_liveness": lambda: True,
|
||||
}
|
||||
|
||||
|
||||
def _task():
|
||||
return {
|
||||
"task_id":"r42-autonomous-release-admission-test",
|
||||
"required_capabilities":["release:candidate"],
|
||||
"constraints":{
|
||||
"release_candidate":{
|
||||
"candidate_id":"r42-release-candidate-v2r2c9-test",
|
||||
"source_commit":"1"*40,
|
||||
"version":"V2R2C9",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_capability_contract_registry_is_closed_and_release_is_versioned():
|
||||
status=contract.validate_capability_contract_registry()
|
||||
assert status["valid"] is True
|
||||
release=contract.capability_contract("release:candidate")
|
||||
assert release is not None
|
||||
assert release.min_version==1
|
||||
assert release.max_version==1
|
||||
assert release.permissions==("prepare_release",)
|
||||
assert "credential_projection" in release.dependencies
|
||||
assert "trusted_builder" in release.dependencies
|
||||
assert "authenticated_probe" in release.health_requirements
|
||||
assert "production_deploy" in release.forbidden
|
||||
assert "runtime_switch" in release.forbidden
|
||||
|
||||
|
||||
def test_unknown_agent_capability_cannot_register_or_match():
|
||||
assert contract.registered_capability("release:future",worker=True) is False
|
||||
assert contract.registered_capability("release:*",worker=True) is False
|
||||
assert contract.has_capabilities(["release:future"],["release:future"]) is False
|
||||
|
||||
|
||||
def test_runtime_admission_requires_all_release_execution_readiness():
|
||||
decision=acp.runtime_admission(
|
||||
worker_capabilities=["release:candidate"],
|
||||
required_capabilities=["release:candidate"],
|
||||
requested_operations=["prepare_release"],
|
||||
probes=_ready_probes(),
|
||||
)
|
||||
assert decision.admitted is True
|
||||
assert decision.state=="EXECUTION_READY"
|
||||
|
||||
|
||||
def test_runtime_admission_blocks_credential_failure_before_execution():
|
||||
probes=_ready_probes()
|
||||
probes["credential_projection"]=lambda:False
|
||||
probes["authenticated_probe"]=lambda:False
|
||||
decision=acp.runtime_admission(
|
||||
worker_capabilities=["release:candidate"],
|
||||
required_capabilities=["release:candidate"],
|
||||
requested_operations=["prepare_release"],
|
||||
probes=probes,
|
||||
)
|
||||
assert decision.admitted is False
|
||||
assert decision.state=="EXECUTION_READY_FAILED"
|
||||
assert decision.failure_class==acp.FailureClass.CREDENTIAL_PROJECTION_ERROR
|
||||
assert decision.repair_level==acp.RepairLevel.AUTO_REPAIR_TASK
|
||||
|
||||
|
||||
def test_forbidden_release_operation_requires_owner_boundary():
|
||||
decision=acp.runtime_admission(
|
||||
worker_capabilities=["release:candidate"],
|
||||
required_capabilities=["release:candidate"],
|
||||
requested_operations=["production_deploy"],
|
||||
probes=_ready_probes(),
|
||||
)
|
||||
assert decision.admitted is False
|
||||
assert decision.failure_class==acp.FailureClass.POLICY_DENIED
|
||||
assert decision.repair_level==acp.RepairLevel.OWNER_APPROVAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error,phase,expected,level,consumes",
|
||||
[
|
||||
("Permission denied: gitops.tokens","release",acp.FailureClass.CREDENTIAL_PROJECTION_ERROR,acp.RepairLevel.AUTO_REPAIR_TASK,False),
|
||||
("HTTP_TRANSPORT_UNAVAILABLE","network",acp.FailureClass.DEPENDENCY_UNAVAILABLE,acp.RepairLevel.AUTO_LOCAL,False),
|
||||
("TASK_CAPABILITY_SCOPE_INVALID","validation",acp.FailureClass.CAPABILITY_CONTRACT_ERROR,acp.RepairLevel.AUTO_REPAIR_TASK,False),
|
||||
("lease lost","heartbeat",acp.FailureClass.LEASE_LOST,acp.RepairLevel.AUTO_LOCAL,False),
|
||||
("business permanent invalid release","release",acp.FailureClass.BUSINESS_PERMANENT,acp.RepairLevel.MANUAL,True),
|
||||
],
|
||||
)
|
||||
def test_failure_classification_drives_repair_policy(error,phase,expected,level,consumes):
|
||||
failure=acp.classify_failure(error,phase=phase)
|
||||
assert failure==expected
|
||||
repair=acp.repair_for_failure(failure)
|
||||
assert repair.level==level
|
||||
assert repair.consumes_business_attempt is consumes
|
||||
|
||||
|
||||
def test_release_worker_does_not_claim_when_admission_is_not_ready(monkeypatch):
|
||||
calls=[]
|
||||
def fake_http(base,method,route,token,body=None,timeout=30):
|
||||
calls.append((base,method,route,body))
|
||||
if route=="/health":
|
||||
return 200,{"status":"ok"}
|
||||
if route=="/createReleaseCandidate" and body=={}:
|
||||
return 500,{"error":"Permission denied: gitops.tokens"}
|
||||
if route.endswith("/claim"):
|
||||
raise AssertionError("claim must not happen before EXECUTION_READY")
|
||||
raise AssertionError(route)
|
||||
|
||||
events=[]
|
||||
monkeypatch.setattr(worker,"http_json",fake_http)
|
||||
monkeypatch.setattr(worker,"audit",lambda event,**fields:events.append((event,fields)))
|
||||
monkeypatch.setattr(worker,"write_liveness",lambda *args,**kwargs:None)
|
||||
|
||||
assert worker.execute_task(
|
||||
_task(),
|
||||
agent_token="a"*32,
|
||||
release_token="r"*32,
|
||||
) is False
|
||||
assert not any(route.endswith("/claim") for _,_,route,_ in calls)
|
||||
blocked=[item for item in events if item[0]=="TASK_ADMISSION_BLOCKED"]
|
||||
assert len(blocked)==1
|
||||
assert blocked[0][1]["admission"]["state"]=="EXECUTION_READY_FAILED"
|
||||
|
||||
|
||||
def test_system_invariant_snapshot_is_degraded_when_dependency_is_missing():
|
||||
probes=_ready_probes()
|
||||
probes["trusted_builder"]=lambda:False
|
||||
snapshot=acp.system_invariant_snapshot(
|
||||
worker_capabilities=["release:candidate"],
|
||||
required_capabilities=["release:candidate"],
|
||||
probes=probes,
|
||||
)
|
||||
assert snapshot["status"]=="DEGRADED"
|
||||
assert snapshot["execution_ready"] is False
|
||||
@@ -0,0 +1,627 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_durable_owner_authorization as auth
|
||||
|
||||
|
||||
NOW=datetime(2026,8,19,6,0,0,tzinfo=timezone.utc)
|
||||
ACCOUNT="chatgpt-governed-implementer-singlehost"
|
||||
|
||||
|
||||
def write_root_file(path: Path, value: object) -> Path:
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
path.write_text(json.dumps(value,sort_keys=True)+"\n",encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def policy_value(**overrides):
|
||||
value={
|
||||
"schema":auth.POLICY_SCHEMA,
|
||||
"grant_id":"owner-permanent-preparation-20260819",
|
||||
"owner_identity":"owner-singlehost",
|
||||
"account":ACCOUNT,
|
||||
"capabilities":[
|
||||
"governed-task:project",
|
||||
"release:candidate",
|
||||
],
|
||||
"issued_at":"2026-08-19T05:00:00Z",
|
||||
"not_before":"2026-08-19T05:00:00Z",
|
||||
"persistent_until_revoked":True,
|
||||
"constraints":{
|
||||
"repository":"aios/aios",
|
||||
"allowed_target_prefix":"agent/",
|
||||
"release_version_prefix":"V2R2C",
|
||||
"max_task_risk":"engineering",
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def load_policy(tmp_path,monkeypatch,**overrides):
|
||||
path=write_root_file(tmp_path/"policy.json",policy_value(**overrides))
|
||||
revocations=write_root_file(
|
||||
tmp_path/"revocations.json",
|
||||
{"revoked_grant_ids":[]},
|
||||
)
|
||||
monkeypatch.setattr(auth,"validate_root_file",lambda path,**kwargs:path)
|
||||
return auth.DurablePolicy.load(
|
||||
path,
|
||||
revocations,
|
||||
now=NOW,
|
||||
),path,revocations
|
||||
|
||||
|
||||
def governed_request(**overrides):
|
||||
value={
|
||||
"task_id":"r42-owner-auth-projection-test-20260819",
|
||||
"repository":"aios/aios",
|
||||
"base_oid":"8"*40,
|
||||
"expected_branch_oid":"8"*40,
|
||||
"target_branch":"agent/r42-owner-auth-projection-test-20260819",
|
||||
"generation":1,
|
||||
"risk":"engineering",
|
||||
"description":"project existing governed task",
|
||||
"idempotency_key":"binding-owner-auth-projection-v1",
|
||||
"branch_mode":"CREATE_NEW",
|
||||
"priority":100,
|
||||
"max_attempts":3,
|
||||
"require_online_task_projection":True,
|
||||
"online_task":{
|
||||
"goal":"Analyze source read-only.",
|
||||
"instructions":"Use source capture first.",
|
||||
"success_criteria":[
|
||||
{"id":"source","description":"Return source snapshot."}
|
||||
],
|
||||
"constraints":{"repository_mutation":"forbidden"},
|
||||
"required_capabilities":["git.read","git.test"],
|
||||
"priority":100,
|
||||
"max_attempts":3,
|
||||
},
|
||||
"execution":{
|
||||
"action":"source_capture",
|
||||
"adapter":"source_capture",
|
||||
"capability_scope":["git.read","git.test"],
|
||||
"source_signals":["durable authorization projection"],
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def release_request(**overrides):
|
||||
value={
|
||||
"candidate_id":"r42-pf1-release-candidate-20260819",
|
||||
"source_commit":"8"*40,
|
||||
"version":"V2R2C2",
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
class FakeUpstream:
|
||||
def __init__(self):
|
||||
self.project_calls=[]
|
||||
|
||||
def project_governed_task(self,body):
|
||||
self.project_calls.append(dict(body))
|
||||
return 200,{
|
||||
"task_id":body["task_id"],
|
||||
"job_id":"job-"+"a"*32,
|
||||
"state":"BOUND",
|
||||
}
|
||||
|
||||
|
||||
def broker(tmp_path,monkeypatch):
|
||||
policy,path,revocations=load_policy(tmp_path,monkeypatch)
|
||||
store=auth.ReceiptStore(tmp_path/"receipts.sqlite3")
|
||||
upstream=FakeUpstream()
|
||||
instance=auth.Broker(
|
||||
policy_path=path,
|
||||
revocation_path=revocations,
|
||||
store=store,
|
||||
upstream=upstream,
|
||||
expected_account=ACCOUNT,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth.DurablePolicy,
|
||||
"load",
|
||||
classmethod(lambda cls,*args,**kwargs:policy),
|
||||
)
|
||||
return instance,upstream,store,policy
|
||||
|
||||
|
||||
def test_allowed_persistent_capabilities_are_only_preparation_scopes():
|
||||
assert auth.ALLOWED_PERSISTENT_CAPABILITIES=={
|
||||
"governed-task:project",
|
||||
"release:candidate",
|
||||
}
|
||||
assert auth.ALLOWED_PERSISTENT_CAPABILITIES.isdisjoint(
|
||||
auth.FORBIDDEN_PERSISTENT_CAPABILITIES
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"capability",
|
||||
sorted(auth.FORBIDDEN_PERSISTENT_CAPABILITIES),
|
||||
)
|
||||
def test_policy_rejects_high_risk_persistent_capability(
|
||||
tmp_path,monkeypatch,capability
|
||||
):
|
||||
bad=policy_value(
|
||||
capabilities=["governed-task:project",capability]
|
||||
)
|
||||
path=write_root_file(tmp_path/"policy.json",bad)
|
||||
revocations=write_root_file(
|
||||
tmp_path/"revocations.json",
|
||||
{"revoked_grant_ids":[]},
|
||||
)
|
||||
monkeypatch.setattr(auth,"validate_root_file",lambda path,**kwargs:path)
|
||||
with pytest.raises(
|
||||
auth.AuthorizationError,
|
||||
match="POLICY_CAPABILITY_NOT_PERSISTABLE",
|
||||
):
|
||||
auth.DurablePolicy.load(path,revocations,now=NOW)
|
||||
|
||||
|
||||
def test_policy_is_persistent_until_revoked(tmp_path,monkeypatch):
|
||||
policy,_,_=load_policy(tmp_path,monkeypatch)
|
||||
assert policy.persistent_until_revoked is True
|
||||
assert policy.account==ACCOUNT
|
||||
assert policy.capabilities=={
|
||||
"governed-task:project",
|
||||
"release:candidate",
|
||||
}
|
||||
|
||||
|
||||
def test_revocation_fails_closed(tmp_path,monkeypatch):
|
||||
path=write_root_file(tmp_path/"policy.json",policy_value())
|
||||
revocations=write_root_file(
|
||||
tmp_path/"revocations.json",
|
||||
{"revoked_grant_ids":["owner-permanent-preparation-20260819"]},
|
||||
)
|
||||
monkeypatch.setattr(auth,"validate_root_file",lambda path,**kwargs:path)
|
||||
with pytest.raises(auth.AuthorizationError,match="OWNER_GRANT_REVOKED"):
|
||||
auth.DurablePolicy.load(path,revocations,now=NOW)
|
||||
|
||||
|
||||
def test_future_policy_is_not_active(tmp_path,monkeypatch):
|
||||
with pytest.raises(auth.AuthorizationError,match="POLICY_NOT_ACTIVE"):
|
||||
load_policy(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
issued_at="2026-08-20T05:00:00Z",
|
||||
not_before="2026-08-20T05:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
def test_governed_projection_request_is_tightly_constrained(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
policy,_,_=load_policy(tmp_path,monkeypatch)
|
||||
decision=policy.authorize(
|
||||
"governed-task:project",
|
||||
governed_request(),
|
||||
)
|
||||
assert decision["decision"]=="ALLOW"
|
||||
assert decision["persistent_until_revoked"] is True
|
||||
|
||||
bad_caps=governed_request()
|
||||
bad_caps["online_task"]={
|
||||
**bad_caps["online_task"],
|
||||
"required_capabilities":["release:candidate"],
|
||||
}
|
||||
bad_action=governed_request()
|
||||
bad_action["execution"]={
|
||||
**bad_action["execution"],
|
||||
"action":"deterministic",
|
||||
"adapter":"deterministic",
|
||||
}
|
||||
bad_scope=governed_request()
|
||||
bad_scope["execution"]={
|
||||
**bad_scope["execution"],
|
||||
"capability_scope":["git.read"],
|
||||
}
|
||||
for bad in (
|
||||
governed_request(repository="other/repo"),
|
||||
governed_request(target_branch="main"),
|
||||
governed_request(generation=2),
|
||||
governed_request(risk="high"),
|
||||
governed_request(expected_branch_oid="9"*40),
|
||||
governed_request(branch_mode="CONTINUE_EXISTING"),
|
||||
governed_request(priority=101),
|
||||
governed_request(max_attempts=4),
|
||||
governed_request(require_online_task_projection=False),
|
||||
bad_caps,
|
||||
bad_action,
|
||||
bad_scope,
|
||||
):
|
||||
with pytest.raises(auth.AuthorizationError):
|
||||
policy.authorize("governed-task:project",bad)
|
||||
|
||||
|
||||
|
||||
def test_write_capable_projection_is_allowed_only_as_source_capture_placeholder(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
policy,_,_=load_policy(tmp_path,monkeypatch)
|
||||
request=governed_request()
|
||||
request["online_task"]={
|
||||
**request["online_task"],
|
||||
"required_capabilities":[
|
||||
"git.read",
|
||||
"git.test",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
],
|
||||
}
|
||||
request["execution"]={
|
||||
**request["execution"],
|
||||
"capability_scope":[
|
||||
"git.read",
|
||||
"git.test",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
],
|
||||
}
|
||||
decision=policy.authorize("governed-task:project",request)
|
||||
assert decision["decision"]=="ALLOW"
|
||||
assert request["execution"]["action"]=="source_capture"
|
||||
|
||||
def test_release_candidate_request_is_tightly_constrained(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
policy,_,_=load_policy(tmp_path,monkeypatch)
|
||||
decision=policy.authorize("release:candidate",release_request())
|
||||
assert decision["decision"]=="ALLOW"
|
||||
|
||||
with pytest.raises(
|
||||
auth.AuthorizationError,
|
||||
match="RELEASE_VERSION_NOT_AUTHORIZED",
|
||||
):
|
||||
policy.authorize(
|
||||
"release:candidate",
|
||||
release_request(version="V3R1C1"),
|
||||
)
|
||||
with pytest.raises(
|
||||
auth.AuthorizationError,
|
||||
match="RELEASE_CANDIDATE_FIELDS_INVALID",
|
||||
):
|
||||
policy.authorize(
|
||||
"release:candidate",
|
||||
{**release_request(),"execute":True},
|
||||
)
|
||||
|
||||
|
||||
def test_governed_task_projection_executes_canonical_upstream(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,upstream,store,_=broker(tmp_path,monkeypatch)
|
||||
request=governed_request()
|
||||
response=instance.execute(
|
||||
"governed-task:project",
|
||||
request,
|
||||
idempotency_key="owner-auth-projection-operation-0001",
|
||||
)
|
||||
assert len(upstream.project_calls)==1
|
||||
assert upstream.project_calls[0]==request
|
||||
assert response["operation_state"]=="EXECUTED"
|
||||
assert response["external_capability_projection_required"] is False
|
||||
assert response["separate_owner_release_approval_still_required"] is True
|
||||
|
||||
|
||||
def test_release_candidate_authorization_does_not_invent_builder_upstream(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,upstream,store,_=broker(tmp_path,monkeypatch)
|
||||
response=instance.execute(
|
||||
"release:candidate",
|
||||
release_request(),
|
||||
idempotency_key="owner-auth-release-candidate-operation-0001",
|
||||
)
|
||||
assert upstream.project_calls==[]
|
||||
assert response["operation_state"]==(
|
||||
"AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION"
|
||||
)
|
||||
assert response["upstream_status"] is None
|
||||
assert response["upstream_response"] is None
|
||||
assert response["external_capability_projection_required"] is True
|
||||
assert response["projection_id"].startswith("projection-")
|
||||
assert response["projection_state"]=="PENDING_EXTERNAL_CONNECTOR"
|
||||
assert response["separate_owner_release_approval_still_required"] is True
|
||||
|
||||
|
||||
|
||||
def test_release_authorization_creates_durable_projection_outbox(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,policy=broker(tmp_path,monkeypatch)
|
||||
request=release_request()
|
||||
response=instance.execute(
|
||||
"release:candidate",
|
||||
request,
|
||||
idempotency_key="owner-auth-release-outbox-0001",
|
||||
)
|
||||
projection=store.get_projection(response["projection_id"])
|
||||
assert projection is not None
|
||||
assert projection["schema"]==auth.PROJECTION_SCHEMA
|
||||
assert projection["state"]=="PENDING_EXTERNAL_CONNECTOR"
|
||||
assert projection["grant_id"]==policy.grant_id
|
||||
assert projection["account"]==ACCOUNT
|
||||
assert projection["capability"]=="release:candidate"
|
||||
assert projection["request"]==request
|
||||
assert projection["separate_owner_release_approval_still_required"] is True
|
||||
|
||||
listed=instance.projections()
|
||||
assert listed["schema"]==auth.PROJECTION_LIST_SCHEMA
|
||||
assert listed["count"]==1
|
||||
assert listed["items"][0]["projection_id"]==response["projection_id"]
|
||||
assert listed["external_connector_ack_supported"] is False
|
||||
|
||||
|
||||
|
||||
def test_existing_pre_outbox_release_receipt_is_backfilled_on_exact_retry(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,policy=broker(tmp_path,monkeypatch)
|
||||
request=release_request()
|
||||
decision=policy.authorize("release:candidate",request)
|
||||
key="owner-auth-release-pre-outbox-0001"
|
||||
legacy_response={
|
||||
"schema":auth.RECEIPT_SCHEMA,
|
||||
"grant_id":policy.grant_id,
|
||||
"owner_identity":policy.owner_identity,
|
||||
"account":policy.account,
|
||||
"capability":"release:candidate",
|
||||
"policy_sha256":policy.policy_sha256,
|
||||
"request_sha256":decision["request_sha256"],
|
||||
"idempotency_key":key,
|
||||
"operation_state":"AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION",
|
||||
"upstream_status":None,
|
||||
"upstream_response":None,
|
||||
"external_capability_projection_required":True,
|
||||
"separate_owner_release_approval_still_required":True,
|
||||
}
|
||||
store.put(
|
||||
key,
|
||||
"release:candidate",
|
||||
decision["request_sha256"],
|
||||
legacy_response,
|
||||
)
|
||||
response=instance.execute(
|
||||
"release:candidate",
|
||||
request,
|
||||
idempotency_key=key,
|
||||
)
|
||||
assert response["projection_id"].startswith("projection-")
|
||||
assert response["projection_state"]=="PENDING_EXTERNAL_CONNECTOR"
|
||||
assert store.get_projection(response["projection_id"]) is not None
|
||||
persisted=store.get(key)["response"]
|
||||
assert persisted["projection_id"]==response["projection_id"]
|
||||
|
||||
def test_projection_id_is_deterministic_and_exact_retry_does_not_duplicate(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,_=broker(tmp_path,monkeypatch)
|
||||
key="owner-auth-release-outbox-0002"
|
||||
first=instance.execute("release:candidate",release_request(),idempotency_key=key)
|
||||
second=instance.execute("release:candidate",release_request(),idempotency_key=key)
|
||||
assert second==first
|
||||
con=sqlite3.connect(store.path)
|
||||
rows=con.execute("select projection_id from projection_outbox").fetchall()
|
||||
assert rows==[(first["projection_id"],)]
|
||||
|
||||
|
||||
def test_projection_enqueue_recovers_crash_between_outbox_and_receipt(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,policy=broker(tmp_path,monkeypatch)
|
||||
request=release_request()
|
||||
decision=policy.authorize("release:candidate",request)
|
||||
key="owner-auth-release-outbox-crash-0001"
|
||||
first=store.enqueue_projection(
|
||||
idempotency_key=key,
|
||||
grant_id=policy.grant_id,
|
||||
account=policy.account,
|
||||
capability="release:candidate",
|
||||
policy_sha256=policy.policy_sha256,
|
||||
request_sha256=decision["request_sha256"],
|
||||
request=request,
|
||||
)
|
||||
# Simulate a later retry after the process crashed before receipt creation.
|
||||
second=store.enqueue_projection(
|
||||
idempotency_key=key,
|
||||
grant_id=policy.grant_id,
|
||||
account=policy.account,
|
||||
capability="release:candidate",
|
||||
policy_sha256=policy.policy_sha256,
|
||||
request_sha256=decision["request_sha256"],
|
||||
request=request,
|
||||
)
|
||||
assert second==first
|
||||
|
||||
|
||||
def test_projection_idempotency_conflict_is_fail_closed(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,policy=broker(tmp_path,monkeypatch)
|
||||
first=release_request()
|
||||
first_decision=policy.authorize("release:candidate",first)
|
||||
key="owner-auth-release-outbox-conflict-0001"
|
||||
store.enqueue_projection(
|
||||
idempotency_key=key,
|
||||
grant_id=policy.grant_id,
|
||||
account=policy.account,
|
||||
capability="release:candidate",
|
||||
policy_sha256=policy.policy_sha256,
|
||||
request_sha256=first_decision["request_sha256"],
|
||||
request=first,
|
||||
)
|
||||
second=release_request(version="V2R2C3")
|
||||
second_decision=policy.authorize("release:candidate",second)
|
||||
with pytest.raises(auth.AuthorizationError,match="PROJECTION_IDEMPOTENCY_CONFLICT"):
|
||||
store.enqueue_projection(
|
||||
idempotency_key=key,
|
||||
grant_id=policy.grant_id,
|
||||
account=policy.account,
|
||||
capability="release:candidate",
|
||||
policy_sha256=policy.policy_sha256,
|
||||
request_sha256=second_decision["request_sha256"],
|
||||
request=second,
|
||||
)
|
||||
|
||||
|
||||
def test_revocation_blocks_projection_enumeration(tmp_path,monkeypatch):
|
||||
instance,_,store,policy=broker(tmp_path,monkeypatch)
|
||||
response=instance.execute(
|
||||
"release:candidate",
|
||||
release_request(),
|
||||
idempotency_key="owner-auth-release-outbox-revoke-0001",
|
||||
)
|
||||
# Restore real policy loading and write a revocation for this grant.
|
||||
monkeypatch.undo()
|
||||
revocations=instance.revocation_path
|
||||
revocations.write_text(
|
||||
json.dumps({"revoked_grant_ids":[policy.grant_id]})+"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
revocations.chmod(0o600)
|
||||
monkeypatch.setattr(auth,"validate_root_file",lambda path,**kwargs:path)
|
||||
with pytest.raises(auth.AuthorizationError,match="OWNER_GRANT_REVOKED"):
|
||||
instance.projections()
|
||||
# Evidence is retained; revocation does not erase history.
|
||||
assert store.get_projection(response["projection_id"]) is not None
|
||||
|
||||
|
||||
def test_projection_outbox_contains_no_credentials(tmp_path,monkeypatch):
|
||||
instance,_,store,_=broker(tmp_path,monkeypatch)
|
||||
instance.execute(
|
||||
"release:candidate",
|
||||
release_request(),
|
||||
idempotency_key="owner-auth-release-outbox-schema-0001",
|
||||
)
|
||||
con=sqlite3.connect(store.path)
|
||||
columns=[row[1] for row in con.execute("pragma table_info(projection_outbox)")]
|
||||
joined=" ".join(columns).lower()
|
||||
for forbidden in ("token","credential","private_key","secret"):
|
||||
assert forbidden not in joined
|
||||
payload=con.execute("select payload_json from projection_outbox").fetchone()[0]
|
||||
assert "Bearer " not in payload
|
||||
assert "submitOwnerApproval" not in payload
|
||||
assert "startDeployment" not in payload
|
||||
|
||||
def test_exact_retry_returns_same_receipt_without_second_projection(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,upstream,_,_=broker(tmp_path,monkeypatch)
|
||||
request=governed_request()
|
||||
first=instance.execute(
|
||||
"governed-task:project",
|
||||
request,
|
||||
idempotency_key="owner-auth-projection-operation-0002",
|
||||
)
|
||||
second=instance.execute(
|
||||
"governed-task:project",
|
||||
request,
|
||||
idempotency_key="owner-auth-projection-operation-0002",
|
||||
)
|
||||
assert second==first
|
||||
assert len(upstream.project_calls)==1
|
||||
|
||||
|
||||
def test_idempotency_conflict_fails_closed(tmp_path,monkeypatch):
|
||||
instance,_,_,_=broker(tmp_path,monkeypatch)
|
||||
instance.execute(
|
||||
"release:candidate",
|
||||
release_request(),
|
||||
idempotency_key="owner-auth-release-operation-0002",
|
||||
)
|
||||
with pytest.raises(auth.AuthorizationError,match="IDEMPOTENCY_CONFLICT"):
|
||||
instance.execute(
|
||||
"release:candidate",
|
||||
release_request(version="V2R2C3"),
|
||||
idempotency_key="owner-auth-release-operation-0002",
|
||||
)
|
||||
|
||||
|
||||
def test_receipt_store_contains_no_owner_policy_or_credentials(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
instance,_,store,_=broker(tmp_path,monkeypatch)
|
||||
instance.execute(
|
||||
"release:candidate",
|
||||
release_request(),
|
||||
idempotency_key="owner-auth-release-operation-0003",
|
||||
)
|
||||
con=sqlite3.connect(store.path)
|
||||
columns=[row[1] for row in con.execute("pragma table_info(receipts)")]
|
||||
joined=" ".join(columns).lower()
|
||||
assert "token" not in joined
|
||||
assert "credential" not in joined
|
||||
assert "policy_json" not in columns
|
||||
|
||||
|
||||
def test_systemd_broker_uses_unix_socket_and_only_host_control_credential():
|
||||
unit=(
|
||||
ROOT/"deploy/systemd/aios-owner-durable-authorization-broker.service"
|
||||
).read_text()
|
||||
assert "LoadCredential=agent_task_control_token:" in unit
|
||||
assert "release_candidate_token" not in unit
|
||||
assert "18947" not in unit
|
||||
assert "AIOS_RELEASE_CANDIDATE_API_URL" not in unit
|
||||
assert "AIOS_OWNER_AUTH_BROKER_SOCKET=%t/aios-owner-authorization-broker/broker.sock" in unit
|
||||
assert "Group=aios-executor-exchange" in unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
||||
assert "IPAddressDeny=any" in unit
|
||||
assert "IPAddressAllow=127.0.0.0/8" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops /home" in unit
|
||||
|
||||
|
||||
def test_source_contains_no_release_builder_endpoint_or_high_risk_execution():
|
||||
source=(
|
||||
ROOT/"active-runtime/app/aios_durable_owner_authorization.py"
|
||||
).read_text()
|
||||
assert "/createReleaseCandidate" not in source
|
||||
assert "18947" not in source
|
||||
assert "ThreadingHTTPServer" not in source
|
||||
assert "ThreadingUnixStreamServer" in source
|
||||
assert "startDeployment" in source
|
||||
assert "tag_publish" in source
|
||||
assert "baseline_registry_finalize" in source
|
||||
assert "runtime_switch" in source
|
||||
assert "AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION" in source
|
||||
assert "PENDING_EXTERNAL_CONNECTOR" in source
|
||||
assert "projection_outbox" in source
|
||||
assert "external_connector_ack_supported" in source
|
||||
assert "SAFE_PROJECTED_TASK_CAPABILITIES" in source
|
||||
assert "INITIAL_EXECUTION_ACTION_NOT_AUTHORIZED" in source
|
||||
|
||||
|
||||
|
||||
def test_docs_avoid_new_broker_bearer_secret():
|
||||
doc=(
|
||||
ROOT/"docs/superpowers/specs/2026-08-19-durable-owner-authorization.md"
|
||||
).read_text()
|
||||
assert "AF_UNIX" in doc
|
||||
assert "avoids introducing another long-lived bearer secret" in doc
|
||||
|
||||
def test_docs_preserve_separate_owner_release_approval():
|
||||
doc=(
|
||||
ROOT/"docs/superpowers/specs/2026-08-19-durable-owner-authorization.md"
|
||||
).read_text()
|
||||
assert "persistent until explicitly revoked" in doc.lower()
|
||||
assert "Owner signature" in doc
|
||||
assert "AUTHORIZED_PENDING_EXTERNAL_CAPABILITY_PROJECTION" in doc
|
||||
assert "PENDING_EXTERNAL_CONNECTOR" in doc
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_online_agent_worker as worker
|
||||
from aios_external_agent_runtime import (
|
||||
ExternalAgentExchange,
|
||||
ExternalAgentRuntimeError,
|
||||
validate_request,
|
||||
)
|
||||
|
||||
|
||||
def envelope():
|
||||
value=object.__new__(worker.ExecutionEnvelope)
|
||||
object.__setattr__(value,"task_id","task-attempt-identity")
|
||||
object.__setattr__(value,"job_id","job-attempt-identity")
|
||||
object.__setattr__(value,"digest","d"*64)
|
||||
return value
|
||||
|
||||
|
||||
def test_external_request_id_is_stable_within_attempt_and_changes_across_attempts():
|
||||
env=envelope()
|
||||
a1=worker.external_agent_request_id(env,"hermes",6)
|
||||
a1_replay=worker.external_agent_request_id(env,"hermes",6)
|
||||
a2=worker.external_agent_request_id(env,"hermes",7)
|
||||
assert a1==a1_replay
|
||||
assert a1!=a2
|
||||
assert a1.startswith("external-hermes-")
|
||||
assert a2.startswith("external-hermes-")
|
||||
|
||||
|
||||
def test_external_request_id_rejects_invalid_attempt():
|
||||
env=envelope()
|
||||
with pytest.raises(worker.WorkerError,match="TASK_ATTEMPT_INVALID"):
|
||||
worker.external_agent_request_id(env,"hermes",0)
|
||||
|
||||
|
||||
def test_external_request_schema_binds_attempt():
|
||||
value=validate_request({
|
||||
"schema":"aios.external-agent-runtime.request.v1",
|
||||
"request_id":"external-hermes-0123456789abcdef",
|
||||
"runtime":"hermes",
|
||||
"task_id":"task-attempt-identity",
|
||||
"attempt":7,
|
||||
"execution_digest":"d"*64,
|
||||
"payload":{
|
||||
"goal":"Return ok.",
|
||||
"instructions":"",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
},
|
||||
})
|
||||
assert value["attempt"]==7
|
||||
|
||||
|
||||
def test_external_request_schema_rejects_missing_attempt():
|
||||
with pytest.raises(ExternalAgentRuntimeError,match="EXTERNAL_AGENT_ATTEMPT_INVALID"):
|
||||
validate_request({
|
||||
"schema":"aios.external-agent-runtime.request.v1",
|
||||
"request_id":"external-hermes-0123456789abcdef",
|
||||
"runtime":"hermes",
|
||||
"task_id":"task-attempt-identity",
|
||||
"execution_digest":"d"*64,
|
||||
"payload":{
|
||||
"goal":"Return ok.",
|
||||
"instructions":"",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def _write_bound_result(path, request):
|
||||
import hashlib
|
||||
from aios_external_agent_runtime import canonical, sha256
|
||||
material={
|
||||
"schema":"aios.external-agent-runtime.result.v1",
|
||||
"request_id":request["request_id"],
|
||||
"request_sha256":sha256(canonical(request)),
|
||||
"runtime":request["runtime"],
|
||||
"task_id":request["task_id"],
|
||||
"attempt":request["attempt"],
|
||||
"execution_digest":request["execution_digest"],
|
||||
"exit_code":0,
|
||||
"duration_ms":1,
|
||||
"output_text":"ok",
|
||||
"output_sha256":hashlib.sha256(b"ok").hexdigest(),
|
||||
"stderr_sha256":hashlib.sha256(b"").hexdigest(),
|
||||
"credential_material_exposed":False,
|
||||
"control_plane_access_granted":False,
|
||||
}
|
||||
material["result_sha256"]=sha256(canonical(material))
|
||||
path.write_text(json.dumps(material,sort_keys=True),encoding="utf-8")
|
||||
|
||||
|
||||
def test_exchange_result_binding_rejects_wrong_attempt(tmp_path):
|
||||
exchange=ExternalAgentExchange(tmp_path)
|
||||
request={
|
||||
"schema":"aios.external-agent-runtime.request.v1",
|
||||
"request_id":"external-hermes-0123456789abcdef",
|
||||
"runtime":"hermes",
|
||||
"task_id":"task-attempt-identity",
|
||||
"attempt":7,
|
||||
"execution_digest":"d"*64,
|
||||
"payload":{
|
||||
"goal":"Return ok.",
|
||||
"instructions":"",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
},
|
||||
}
|
||||
request=validate_request(request)
|
||||
result_path=tmp_path/"result.json"
|
||||
_write_bound_result(result_path,request)
|
||||
value=json.loads(result_path.read_text())
|
||||
value["attempt"]=6
|
||||
# Recompute digest so the binding check, not the digest check, is what fails.
|
||||
from aios_external_agent_runtime import canonical,sha256
|
||||
material=dict(value); material.pop("result_sha256")
|
||||
value["result_sha256"]=sha256(canonical(material))
|
||||
result_path.write_text(json.dumps(value),encoding="utf-8")
|
||||
with pytest.raises(ExternalAgentRuntimeError,match="EXTERNAL_AGENT_RESULT_BINDING_INVALID"):
|
||||
exchange._validated_result(
|
||||
result_path,
|
||||
request,
|
||||
sha256(canonical(request)),
|
||||
)
|
||||
|
||||
|
||||
def test_model_governance_request_id_is_attempt_scoped():
|
||||
env=envelope()
|
||||
attempt6=worker.external_agent_request_id(env,"hermes",6)+":model"
|
||||
attempt7=worker.external_agent_request_id(env,"hermes",7)+":model"
|
||||
assert attempt6!=attempt7
|
||||
|
||||
|
||||
def test_failed_previous_attempt_does_not_poison_next_model_governance_reservation(tmp_path):
|
||||
from aios_model_governance import (
|
||||
ModelGovernanceStore,
|
||||
ModelRecord,
|
||||
ProviderRecord,
|
||||
RouteRequest,
|
||||
ScopePolicy,
|
||||
)
|
||||
|
||||
store=ModelGovernanceStore(tmp_path/"model-governance.sqlite3",now_fn=lambda:1_800_000_000.0)
|
||||
store.register_provider(
|
||||
ProviderRecord(
|
||||
"oneapi",
|
||||
"runtime-profile:hermes",
|
||||
max_failures=5,
|
||||
cooldown_seconds=60,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"deepseek-v4-flash",
|
||||
"oneapi",
|
||||
"v1",
|
||||
("agent.runtime.hermes","text"),
|
||||
input_cost_micros_per_1k=1,
|
||||
output_cost_micros_per_1k=1,
|
||||
max_input_tokens=10000,
|
||||
max_output_tokens=1000,
|
||||
evaluation_sha256="a"*64,
|
||||
)
|
||||
)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy(
|
||||
"tenant",
|
||||
"model-governance-live",
|
||||
"staging",
|
||||
allowed_models=("deepseek-v4-flash",),
|
||||
required_capabilities=("text",),
|
||||
daily_token_budget=100000,
|
||||
daily_cost_budget_micros=100000,
|
||||
fallback_limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
def route(request_id):
|
||||
return store.route(
|
||||
RouteRequest(
|
||||
request_id=request_id,
|
||||
task_id="r42-model-governance-hermes-live-20260818",
|
||||
trace_id="trace-hermes-model-governance-live",
|
||||
scope_type="tenant",
|
||||
tenant_id="model-governance-live",
|
||||
project_id="staging",
|
||||
required_capabilities=("agent.runtime.hermes",),
|
||||
estimated_input_tokens=100,
|
||||
max_output_tokens=50,
|
||||
)
|
||||
)
|
||||
|
||||
attempt6=route("external-hermes-attempt6:model")
|
||||
store.record_failure(attempt6.request_id,"provider_process_failed")
|
||||
row6=store.conn.execute(
|
||||
"select state from model_governance_reservations where request_id=?",
|
||||
(attempt6.request_id,),
|
||||
).fetchone()
|
||||
assert row6["state"]=="FAILED"
|
||||
|
||||
attempt7=route("external-hermes-attempt7:model")
|
||||
assert attempt7.request_id!=attempt6.request_id
|
||||
row7=store.conn.execute(
|
||||
"select state from model_governance_reservations where request_id=?",
|
||||
(attempt7.request_id,),
|
||||
).fetchone()
|
||||
assert row7["state"]=="RESERVED"
|
||||
|
||||
settled=store.record_success(
|
||||
attempt7.request_id,
|
||||
input_tokens=90,
|
||||
output_tokens=40,
|
||||
usage_source="runtime",
|
||||
)
|
||||
assert settled["state"]=="SETTLED"
|
||||
assert store.conn.execute(
|
||||
"select state from model_governance_reservations where request_id=?",
|
||||
(attempt6.request_id,),
|
||||
).fetchone()["state"]=="FAILED"
|
||||
assert store.verify_audit_chain() is True
|
||||
store.close()
|
||||
@@ -110,6 +110,7 @@ def make_text_request(tmp_path, runtime, payload=None):
|
||||
"request_id":f"external-{runtime}-0123456789abcdef",
|
||||
"runtime":runtime,
|
||||
"task_id":f"task-{runtime}-runtime",
|
||||
"attempt":1,
|
||||
"execution_digest":"d"*64,
|
||||
"payload":payload or {
|
||||
"goal":"Return ok.",
|
||||
@@ -193,6 +194,7 @@ def test_external_executor_runs_root_owned_profile_without_shell(tmp_path,monkey
|
||||
"request_id":f"external-{runtime}-0123456789abcdef",
|
||||
"runtime":runtime,
|
||||
"task_id":f"task-{runtime}-runtime",
|
||||
"attempt":1,
|
||||
"execution_digest":"d"*64,
|
||||
"payload":{
|
||||
"goal":"Return ok.",
|
||||
@@ -326,6 +328,7 @@ def test_worker_external_adapter_builds_readonly_result(monkeypatch,tmp_path,run
|
||||
"request_sha256":"e"*64,
|
||||
"runtime":runtime,
|
||||
"task_id":kwargs["task_id"],
|
||||
"attempt":kwargs["attempt"],
|
||||
"execution_digest":kwargs["execution_digest"],
|
||||
"exit_code":0,
|
||||
"duration_ms":10,
|
||||
@@ -339,7 +342,7 @@ def test_worker_external_adapter_builds_readonly_result(monkeypatch,tmp_path,run
|
||||
monkeypatch.setattr(worker,"ExternalAgentExchange",FakeExchange)
|
||||
monkeypatch.setattr(worker,"audit",lambda *a,**k:None)
|
||||
executor=worker.HermesExecutor() if runtime=="hermes" else worker.OpenClawExecutor()
|
||||
execution=executor.execute(envelope,tmp_path)
|
||||
execution=executor.execute(envelope,tmp_path,attempt=1)
|
||||
result=worker.build_agent_runtime_result(envelope.task_id,envelope,execution)
|
||||
assert result["status"]=="SUCCEEDED"
|
||||
assert result["classification"]=="EXTERNAL_AGENT_RUNTIME_SUCCEEDED"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import Message
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_gpt_https_edge as edge
|
||||
|
||||
|
||||
class Headers(dict):
|
||||
def get(self,key,default=None):
|
||||
return super().get(key,default)
|
||||
|
||||
|
||||
def test_default_and_public_bind_require_explicit_owner_approval():
|
||||
assert edge.public_bind_allowed("127.0.0.1","0")=="127.0.0.1"
|
||||
assert edge.public_bind_allowed("::1","0")=="::1"
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("0.0.0.0","0")
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("::","")
|
||||
assert edge.public_bind_allowed("0.0.0.0","1")=="0.0.0.0"
|
||||
|
||||
|
||||
def test_arbitrary_public_bind_address_is_denied_even_with_approval():
|
||||
with pytest.raises(RuntimeError,match="BIND_ADDRESS_INVALID"):
|
||||
edge.public_bind_allowed("192.0.2.10","1")
|
||||
|
||||
|
||||
def test_public_domain_validation():
|
||||
assert edge.validated_public_domain("tasks.example.com")=="tasks.example.com"
|
||||
assert edge.validated_public_domain("")==""
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("https://example.com")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("localhost")
|
||||
|
||||
|
||||
def test_route_allowlist_accepts_only_readonly_api():
|
||||
assert edge.allowed_route("POST","/v1/read-only-tasks")=="/v1/read-only-tasks"
|
||||
assert edge.allowed_route("GET","/v1/read-only-tasks/external-request-0001")==(
|
||||
"/v1/read-only-tasks/external-request-0001"
|
||||
)
|
||||
assert edge.allowed_route("GET","/health")=="/health"
|
||||
assert edge.allowed_route("GET","/openapi.json")=="/openapi.json"
|
||||
for method,path in [
|
||||
("POST","/v2/governed-task"),
|
||||
("GET","/v1/tasks/x"),
|
||||
("POST","/v1/read-only-tasks/x"),
|
||||
("DELETE","/v1/read-only-tasks/x"),
|
||||
]:
|
||||
with pytest.raises(edge.EdgeError,match="ROUTE_DENY"):
|
||||
edge.allowed_route(method,path)
|
||||
|
||||
|
||||
def test_query_and_fragment_are_denied():
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/health?x=1")
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/openapi.json#x")
|
||||
|
||||
|
||||
def test_forwarded_headers_are_minimal_and_require_bearer():
|
||||
incoming=Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json; charset=utf-8",
|
||||
"Cookie":"secret=1",
|
||||
"X-Forwarded-For":"203.0.113.10",
|
||||
"X-Api-Key":"do-not-forward",
|
||||
})
|
||||
forwarded=edge.forwarded_headers(incoming,body=True)
|
||||
assert forwarded=={
|
||||
"Accept":"application/json",
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json",
|
||||
}
|
||||
with pytest.raises(edge.EdgeError,match="AUTHORIZATION_REQUIRED"):
|
||||
edge.forwarded_headers(Headers({}),body=False)
|
||||
|
||||
|
||||
def test_invalid_content_type_is_denied():
|
||||
with pytest.raises(edge.EdgeError,match="CONTENT_TYPE_INVALID"):
|
||||
edge.forwarded_headers(
|
||||
Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"text/plain",
|
||||
}),
|
||||
body=True,
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limiter_caps_window_and_allows_expiry():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(window_seconds=60,limit=2,burst=1,now_fn=lambda:now[0])
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert not limiter.allowed("client")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("client")
|
||||
|
||||
|
||||
|
||||
def test_rate_limiter_client_table_is_bounded():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(
|
||||
window_seconds=60,
|
||||
limit=10,
|
||||
burst=0,
|
||||
max_clients=2,
|
||||
now_fn=lambda:now[0],
|
||||
)
|
||||
assert limiter.allowed("a")
|
||||
assert limiter.allowed("b")
|
||||
assert not limiter.allowed("c")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("c")
|
||||
assert len(limiter._events) <= 2
|
||||
|
||||
|
||||
def test_audit_source_does_not_log_sensitive_request_material():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert '"authorization", "body", "token", "cookie", "client_ip"' in source
|
||||
assert "_client_pseudonym" in source
|
||||
|
||||
def test_proxy_upstream_is_fixed():
|
||||
proxy=edge.Proxy()
|
||||
assert proxy.upstream=="http://127.0.0.1:18945"
|
||||
bad=edge.Proxy(upstream="http://127.0.0.1:18892")
|
||||
with pytest.raises(edge.EdgeError,match="UPSTREAM_DRIFT_DENY"):
|
||||
bad.request("GET","/health",{"Authorization":"Bearer "+"a"*40},None)
|
||||
|
||||
|
||||
|
||||
def test_public_bind_requires_approved_domain():
|
||||
assert edge.validated_edge_identity("127.0.0.1","0","")==("127.0.0.1","")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_REQUIRED"):
|
||||
edge.validated_edge_identity("0.0.0.0","1","")
|
||||
assert edge.validated_edge_identity(
|
||||
"0.0.0.0","1","tasks.example.com"
|
||||
)==("0.0.0.0","tasks.example.com")
|
||||
|
||||
def test_tls_key_requires_no_group_or_other_permissions(tmp_path):
|
||||
key=tmp_path/"key.pem"
|
||||
key.write_text("x"*64,encoding="utf-8")
|
||||
key.chmod(0o600)
|
||||
assert edge.validate_tls_file(key,key=True)==key
|
||||
key.chmod(0o644)
|
||||
with pytest.raises(RuntimeError,match="TLS_KEY_MODE_INVALID"):
|
||||
edge.validate_tls_file(key,key=True)
|
||||
|
||||
|
||||
def test_tls_symlink_is_denied(tmp_path):
|
||||
real=tmp_path/"cert.pem"
|
||||
real.write_text("x"*64,encoding="utf-8")
|
||||
link=tmp_path/"link.pem"
|
||||
link.symlink_to(real)
|
||||
with pytest.raises(RuntimeError,match="TLS_FILE_INVALID"):
|
||||
edge.validate_tls_file(link,key=False)
|
||||
|
||||
|
||||
def test_systemd_unit_is_disabled_by_owner_approval_condition_and_loopback_default():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved" in unit
|
||||
assert "AIOS_GPT_EDGE_HOST=127.0.0.1" in unit
|
||||
assert "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0" in unit
|
||||
assert "AIOS_GPT_EDGE_PORT=18443" in unit
|
||||
assert "LoadCredential=tls_cert:" in unit
|
||||
assert "LoadCredential=tls_key:" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops" in unit
|
||||
assert "InaccessiblePaths=" in unit and "/var/lib/aios-agent-task" in unit
|
||||
assert "NoNewPrivileges=yes" in unit
|
||||
assert "ProtectSystem=strict" in unit
|
||||
|
||||
|
||||
def test_systemd_unit_does_not_reference_internal_control_credentials():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "agent_task_control" not in unit
|
||||
assert "gitea" not in unit.lower()
|
||||
assert "18892" not in unit
|
||||
assert "18940" not in unit
|
||||
|
||||
|
||||
def test_edge_source_does_not_proxy_internal_bridge_or_task_api():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert 'UPSTREAM = "http://127.0.0.1:18945"' in source
|
||||
assert "18892" not in source
|
||||
assert "18940" not in source
|
||||
assert "/v2/governed-task" not in source
|
||||
assert "/v1/tasks/" not in source
|
||||
|
||||
|
||||
def test_docs_keep_public_activation_separately_approved():
|
||||
doc=(ROOT/"docs/superpowers/specs/2026-08-19-gpt-https-edge.md").read_text()
|
||||
assert "explicit owner approval" in doc.lower()
|
||||
assert "PUBLIC_NETWORK_EXPOSURE=NONE" in doc
|
||||
assert "PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED" in doc
|
||||
@@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_gpt_https_edge_activation as activation
|
||||
|
||||
|
||||
CANDIDATE="69a51d50c7f6cd9045346d41e338acebe4b6f1e8"
|
||||
NOW=datetime(2026,8,19,2,0,0,tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def approval_value(cert_sha: str, **overrides):
|
||||
value={
|
||||
"schema":"aios.gpt-https-edge.public-approval.v1",
|
||||
"approval_id":"owner-edge-approval-0001",
|
||||
"candidate_oid":CANDIDATE,
|
||||
"domain":"tasks.example.com",
|
||||
"public_bind":True,
|
||||
"bind_host":"0.0.0.0",
|
||||
"port":18443,
|
||||
"tls_cert_sha256":cert_sha,
|
||||
"issued_at":"2026-08-19T01:00:00Z",
|
||||
"not_before":"2026-08-19T01:00:00Z",
|
||||
"expires_at":"2026-08-20T01:00:00Z",
|
||||
"owner":"owner@example",
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def write_owner_file(path: Path, data: bytes, mode=0o600):
|
||||
path.write_bytes(data)
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
|
||||
def load_approval(tmp_path, monkeypatch, **overrides):
|
||||
tmp_path.mkdir(parents=True,exist_ok=True)
|
||||
cert=write_owner_file(tmp_path/"cert.pem",b"certificate-material-"*4,0o600)
|
||||
value=approval_value(hashlib.sha256(cert.read_bytes()).hexdigest(),**overrides)
|
||||
approval=write_owner_file(
|
||||
tmp_path/"approval.json",
|
||||
(json.dumps(value,sort_keys=True)+"\n").encode(),
|
||||
0o600,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
activation,
|
||||
"validate_root_file",
|
||||
lambda path, **kwargs: path,
|
||||
)
|
||||
return activation.Approval.load(
|
||||
approval,
|
||||
candidate_oid=CANDIDATE,
|
||||
cert_path=cert,
|
||||
now=NOW,
|
||||
),approval,cert
|
||||
|
||||
|
||||
class FakeSystemd:
|
||||
def __init__(self):
|
||||
self.active_units={activation.INGRESS_UNIT}
|
||||
self.enabled_units=set()
|
||||
self.calls=[]
|
||||
self.exec_start=""
|
||||
self.environment=""
|
||||
self.fail_restart=False
|
||||
|
||||
def run(self,*args,check=True):
|
||||
self.calls.append(args)
|
||||
class Result:
|
||||
returncode=0
|
||||
stdout=""
|
||||
if args[:2]==("restart",activation.EDGE_UNIT):
|
||||
if self.fail_restart:
|
||||
raise subprocess.CalledProcessError(1,args)
|
||||
self.active_units.add(activation.EDGE_UNIT)
|
||||
elif args[:2]==("stop",activation.EDGE_UNIT):
|
||||
self.active_units.discard(activation.EDGE_UNIT)
|
||||
elif args[:2]==("enable",activation.EDGE_UNIT):
|
||||
self.enabled_units.add(activation.EDGE_UNIT)
|
||||
elif args[:2]==("disable",activation.EDGE_UNIT):
|
||||
self.enabled_units.discard(activation.EDGE_UNIT)
|
||||
return Result()
|
||||
|
||||
def active(self,unit):
|
||||
return unit in self.active_units
|
||||
|
||||
def enabled(self,unit):
|
||||
return unit in self.enabled_units
|
||||
|
||||
def show(self,unit,prop):
|
||||
if prop=="ExecStart":
|
||||
return self.exec_start
|
||||
if prop=="Environment":
|
||||
return self.environment
|
||||
return ""
|
||||
|
||||
|
||||
def test_approval_binds_candidate_domain_bind_port_and_cert(tmp_path,monkeypatch):
|
||||
approval,_,cert=load_approval(tmp_path,monkeypatch)
|
||||
assert approval.candidate_oid==CANDIDATE
|
||||
assert approval.domain=="tasks.example.com"
|
||||
assert approval.bind_host=="0.0.0.0"
|
||||
assert approval.port==18443
|
||||
assert approval.tls_cert_sha256==hashlib.sha256(cert.read_bytes()).hexdigest()
|
||||
assert approval.expires_at>approval.not_before
|
||||
|
||||
|
||||
def test_approval_rejects_candidate_mismatch(tmp_path,monkeypatch):
|
||||
cert=write_owner_file(tmp_path/"cert.pem",b"certificate-material-"*4)
|
||||
value=approval_value(hashlib.sha256(cert.read_bytes()).hexdigest())
|
||||
approval=write_owner_file(tmp_path/"approval.json",json.dumps(value).encode())
|
||||
monkeypatch.setattr(activation,"validate_root_file",lambda path,**kwargs:path)
|
||||
with pytest.raises(activation.ActivationError,match="CANDIDATE_MISMATCH"):
|
||||
activation.Approval.load(
|
||||
approval,
|
||||
candidate_oid="0"*40,
|
||||
cert_path=cert,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_approval_rejects_cert_hash_mismatch(tmp_path,monkeypatch):
|
||||
cert=write_owner_file(tmp_path/"cert.pem",b"certificate-material-"*4)
|
||||
value=approval_value("a"*64)
|
||||
approval=write_owner_file(tmp_path/"approval.json",json.dumps(value).encode())
|
||||
monkeypatch.setattr(activation,"validate_root_file",lambda path,**kwargs:path)
|
||||
with pytest.raises(activation.ActivationError,match="CERT_SHA_MISMATCH"):
|
||||
activation.Approval.load(
|
||||
approval,
|
||||
candidate_oid=CANDIDATE,
|
||||
cert_path=cert,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_approval_rejects_expired_not_yet_valid_and_long_lived(tmp_path,monkeypatch):
|
||||
with pytest.raises(activation.ActivationError,match="APPROVAL_EXPIRED"):
|
||||
load_approval(
|
||||
tmp_path/"expired",
|
||||
monkeypatch,
|
||||
issued_at="2026-08-17T01:00:00Z",
|
||||
not_before="2026-08-17T01:00:00Z",
|
||||
expires_at="2026-08-18T01:00:00Z",
|
||||
)
|
||||
# Use distinct temp roots because helper writes fixed names.
|
||||
|
||||
with pytest.raises(activation.ActivationError,match="NOT_YET_VALID"):
|
||||
load_approval(
|
||||
tmp_path/"future",
|
||||
monkeypatch,
|
||||
issued_at="2026-08-19T03:00:00Z",
|
||||
not_before="2026-08-19T03:00:00Z",
|
||||
expires_at="2026-08-20T03:00:00Z",
|
||||
)
|
||||
with pytest.raises(activation.ActivationError,match="VALIDITY_TOO_LONG"):
|
||||
load_approval(
|
||||
tmp_path/"long",
|
||||
monkeypatch,
|
||||
issued_at="2026-08-18T01:00:00Z",
|
||||
not_before="2026-08-18T01:00:00Z",
|
||||
expires_at="2026-08-26T01:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_validate_release_requires_edge_and_controller_and_denies_symlink(tmp_path):
|
||||
release=tmp_path/"release"
|
||||
app=release/"active-runtime/app"
|
||||
app.mkdir(parents=True)
|
||||
edge_file=app/"aios_gpt_https_edge.py"
|
||||
controller=app/"aios_gpt_https_edge_activation.py"
|
||||
edge_file.write_text("edge\n")
|
||||
controller.write_text("controller\n")
|
||||
value=activation.validate_release(release)
|
||||
assert value["edge"]==edge_file
|
||||
assert value["controller"]==controller
|
||||
controller.unlink()
|
||||
with pytest.raises(activation.ActivationError,match="IMMUTABLE_RELEASE_INVALID"):
|
||||
activation.validate_release(release)
|
||||
|
||||
def test_render_dropin_is_exact_and_contains_no_secret(tmp_path,monkeypatch):
|
||||
approval,_,_=load_approval(tmp_path,monkeypatch)
|
||||
release=Path("/opt/aios/releases")/(CANDIDATE+"-gpt-https-edge-public")
|
||||
text=activation.render_dropin(approval,release)
|
||||
assert "AIOS_GPT_EDGE_HOST=0.0.0.0" in text
|
||||
assert "AIOS_GPT_EDGE_PORT=18443" in text
|
||||
assert "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1" in text
|
||||
assert "AIOS_GPT_EDGE_PUBLIC_DOMAIN=tasks.example.com" in text
|
||||
assert CANDIDATE in text
|
||||
for forbidden in ("Bearer ","agent_task_control","tls_key","gpt_ingress_token"):
|
||||
assert forbidden not in text
|
||||
|
||||
|
||||
def test_receipt_contains_binding_but_no_owner_secret_material(tmp_path,monkeypatch):
|
||||
approval,_,_=load_approval(tmp_path,monkeypatch)
|
||||
receipt=activation.receipt_for(
|
||||
approval,
|
||||
Path("/opt/aios/releases/test"),
|
||||
state="ACTIVE",
|
||||
)
|
||||
assert receipt["approval_id"]==approval.approval_id
|
||||
assert receipt["candidate_oid"]==CANDIDATE
|
||||
assert receipt["domain"]=="tasks.example.com"
|
||||
serialized=json.dumps(receipt)
|
||||
assert "Bearer " not in serialized
|
||||
assert "private" not in serialized.lower()
|
||||
|
||||
|
||||
def test_activate_is_idempotent_for_same_active_approval(tmp_path,monkeypatch):
|
||||
approval,_,cert=load_approval(tmp_path,monkeypatch)
|
||||
release=tmp_path/"release"
|
||||
app=release/"active-runtime/app/aios_gpt_https_edge.py"
|
||||
controller=release/"active-runtime/app/aios_gpt_https_edge_activation.py"
|
||||
app.parent.mkdir(parents=True)
|
||||
app.write_text("print('x')\n")
|
||||
controller.write_text("print('controller')\n")
|
||||
key=write_owner_file(tmp_path/"key.pem",b"private-key-material-"*4,0o600)
|
||||
dropin=tmp_path/"edge.d"/activation.DROPIN_NAME
|
||||
state=tmp_path/"state.json"
|
||||
systemd=FakeSystemd()
|
||||
|
||||
monkeypatch.setattr(activation.os,"geteuid",lambda:0)
|
||||
monkeypatch.setattr(activation,"require_ingress_ready",lambda _:None)
|
||||
monkeypatch.setattr(activation,"validate_tls_pair",lambda *a,**k:{"cert_sha256":approval.tls_cert_sha256})
|
||||
monkeypatch.setattr(activation,"tls_probe",lambda *a,**k:None)
|
||||
|
||||
systemd.exec_start=str(app)
|
||||
systemd.environment=(
|
||||
"AIOS_GPT_EDGE_HOST=0.0.0.0 "
|
||||
"AIOS_GPT_EDGE_PORT=18443 "
|
||||
"AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1 "
|
||||
"AIOS_GPT_EDGE_PUBLIC_DOMAIN=tasks.example.com"
|
||||
)
|
||||
first=activation.activate(
|
||||
approval=approval,
|
||||
release_root=release,
|
||||
cert_path=cert,
|
||||
key_path=key,
|
||||
dropin_path=dropin,
|
||||
state_path=state,
|
||||
systemd=systemd,
|
||||
)
|
||||
calls_after_first=len(systemd.calls)
|
||||
second=activation.activate(
|
||||
approval=approval,
|
||||
release_root=release,
|
||||
cert_path=cert,
|
||||
key_path=key,
|
||||
dropin_path=dropin,
|
||||
state_path=state,
|
||||
systemd=systemd,
|
||||
)
|
||||
assert first["state"]=="ACTIVE"
|
||||
assert second==json.loads(state.read_text())
|
||||
assert len(systemd.calls)==calls_after_first
|
||||
|
||||
|
||||
def test_activate_failure_after_mutation_rolls_back(tmp_path,monkeypatch):
|
||||
approval,_,cert=load_approval(tmp_path,monkeypatch)
|
||||
release=tmp_path/"release"
|
||||
app=release/"active-runtime/app/aios_gpt_https_edge.py"
|
||||
controller=release/"active-runtime/app/aios_gpt_https_edge_activation.py"
|
||||
app.parent.mkdir(parents=True)
|
||||
app.write_text("print('x')\n")
|
||||
controller.write_text("print('controller')\n")
|
||||
key=write_owner_file(tmp_path/"key.pem",b"private-key-material-"*4,0o600)
|
||||
dropin=tmp_path/"edge.d"/activation.DROPIN_NAME
|
||||
state=tmp_path/"state.json"
|
||||
systemd=FakeSystemd()
|
||||
systemd.fail_restart=True
|
||||
|
||||
monkeypatch.setattr(activation.os,"geteuid",lambda:0)
|
||||
monkeypatch.setattr(activation,"require_ingress_ready",lambda _:None)
|
||||
monkeypatch.setattr(activation,"validate_tls_pair",lambda *a,**k:{"cert_sha256":approval.tls_cert_sha256})
|
||||
monkeypatch.setattr(activation,"tls_probe",lambda *a,**k:None)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
activation.activate(
|
||||
approval=approval,
|
||||
release_root=release,
|
||||
cert_path=cert,
|
||||
key_path=key,
|
||||
dropin_path=dropin,
|
||||
state_path=state,
|
||||
systemd=systemd,
|
||||
)
|
||||
assert not dropin.exists()
|
||||
assert not systemd.active(activation.EDGE_UNIT)
|
||||
assert json.loads(state.read_text())["state"]=="ROLLED_BACK"
|
||||
|
||||
|
||||
def test_rollback_is_replay_safe_and_leaves_owner_assets_alone(tmp_path,monkeypatch):
|
||||
approval_file=tmp_path/"approval.json"
|
||||
cert=tmp_path/"cert.pem"
|
||||
key=tmp_path/"key.pem"
|
||||
for path in (approval_file,cert,key):
|
||||
path.write_text("owner-asset"*4)
|
||||
dropin=tmp_path/"edge.d"/activation.DROPIN_NAME
|
||||
dropin.parent.mkdir(parents=True)
|
||||
dropin.write_text("[Service]\n")
|
||||
state=tmp_path/"state.json"
|
||||
state.write_text(json.dumps({
|
||||
"schema":activation.STATE_SCHEMA,
|
||||
"state":"ACTIVE",
|
||||
"candidate_oid":CANDIDATE,
|
||||
"approval_id":"owner-edge-approval-0001",
|
||||
"domain":"tasks.example.com",
|
||||
}))
|
||||
systemd=FakeSystemd()
|
||||
systemd.active_units.add(activation.EDGE_UNIT)
|
||||
systemd.enabled_units.add(activation.EDGE_UNIT)
|
||||
monkeypatch.setattr(activation.os,"geteuid",lambda:0)
|
||||
|
||||
first=activation.rollback(
|
||||
dropin_path=dropin,
|
||||
state_path=state,
|
||||
systemd=systemd,
|
||||
reason="owner_requested",
|
||||
)
|
||||
second=activation.rollback(
|
||||
dropin_path=dropin,
|
||||
state_path=state,
|
||||
systemd=systemd,
|
||||
reason="owner_requested",
|
||||
)
|
||||
assert first["state"]=="ROLLED_BACK"
|
||||
assert second["state"]=="ROLLED_BACK"
|
||||
assert not dropin.exists()
|
||||
assert not systemd.active(activation.EDGE_UNIT)
|
||||
assert not systemd.enabled(activation.EDGE_UNIT)
|
||||
for path in (approval_file,cert,key):
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_activation_source_never_creates_owner_approval_or_tls_secret():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge_activation.py").read_text()
|
||||
assert "secrets.token" not in source
|
||||
assert "openssl" not in source.lower()
|
||||
assert "BEGIN PRIVATE KEY" not in source
|
||||
assert "gpt-https-edge.approved" in source
|
||||
assert "gpt-https-edge-tls.key" in source
|
||||
|
||||
|
||||
def test_docs_separate_implementation_activation_and_runtime_verification():
|
||||
doc=(ROOT/"docs/superpowers/specs/2026-08-19-gpt-https-edge-activation.md").read_text()
|
||||
assert "IMPLEMENTED" in doc
|
||||
assert "OWNER_APPROVED" in doc
|
||||
assert "ACTIVATED" in doc
|
||||
assert "RUNTIME_VERIFIED" in doc
|
||||
assert "does not own" in doc.lower()
|
||||
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
from aios_gpt_online_task_ingress import (
|
||||
IngressError,
|
||||
IngressService,
|
||||
Store,
|
||||
READONLY_CAPABILITIES,
|
||||
branch_for,
|
||||
normalized_request,
|
||||
openapi_document,
|
||||
task_id_for,
|
||||
validated_bind_host,
|
||||
)
|
||||
|
||||
|
||||
MAIN="3244f0f17f8845592ac94d585f5ebd65693c9f29"
|
||||
JOB="job-0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def request(request_id="external-request-0001", **overrides):
|
||||
value={
|
||||
"schema":"aios.gpt-online-task-ingress.request.v1",
|
||||
"request_id":request_id,
|
||||
"goal":"Inspect the repository for durable worker state.",
|
||||
"instructions":"Read only. Return a concise source analysis.",
|
||||
"success_criteria":[
|
||||
{"id":"analysis","description":"Return a source-backed analysis."}
|
||||
],
|
||||
"constraints":{
|
||||
"max_files":50,
|
||||
"focus_paths":["active-runtime/app","tests"],
|
||||
"notes":"Do not mutate repository state.",
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
class FakeUpstream:
|
||||
def __init__(self):
|
||||
self.created=[]
|
||||
self.projected=[]
|
||||
self.tasks={}
|
||||
self.snapshot={"authoritative_head_oid":MAIN}
|
||||
|
||||
def control_snapshot(self):
|
||||
return dict(self.snapshot)
|
||||
|
||||
def create_governed_task(self, body):
|
||||
self.created.append(dict(body))
|
||||
task_id=body["task_id"]
|
||||
job_id=JOB
|
||||
self.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"task_id":task_id,
|
||||
"job_id":job_id,
|
||||
"base_oid":body["base_oid"],
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
return 201,{"task_id":task_id,"job_id":job_id,"state":"BOUND"}
|
||||
|
||||
def execution_envelope(self, body):
|
||||
self.projected.append(dict(body))
|
||||
task_id=body["task_id"]
|
||||
return 200,{
|
||||
"schema":"aios.online-agent.execution.v2",
|
||||
"task_id":task_id,
|
||||
"job_id":body["job_id"],
|
||||
"repository":"aios/aios",
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
|
||||
def get_task(self, task_id):
|
||||
value=self.tasks.get(task_id)
|
||||
return (404,{}) if value is None else (200,dict(value))
|
||||
|
||||
|
||||
def service(tmp_path):
|
||||
upstream=FakeUpstream()
|
||||
store=Store(tmp_path/"state.sqlite3")
|
||||
return IngressService(upstream,store),upstream,store
|
||||
|
||||
|
||||
def test_request_schema_is_narrow_and_rejects_capability_or_execution_control():
|
||||
value=normalized_request(request())
|
||||
assert value["goal"].startswith("Inspect")
|
||||
for field in ("capability_scope","required_capabilities","action","adapter","repository","base_oid"):
|
||||
bad=request()
|
||||
bad[field]="evil"
|
||||
with pytest.raises(IngressError,match="UNKNOWN_FIELDS"):
|
||||
normalized_request(bad)
|
||||
|
||||
|
||||
def test_constraints_are_bounded_and_path_traversal_is_denied():
|
||||
bad=request(constraints={"focus_paths":["../etc/passwd"]})
|
||||
with pytest.raises(IngressError,match="FOCUS_PATHS_INVALID"):
|
||||
normalized_request(bad)
|
||||
bad=request(constraints={"max_files":1000})
|
||||
with pytest.raises(IngressError,match="MAX_FILES_INVALID"):
|
||||
normalized_request(bad)
|
||||
|
||||
|
||||
def test_non_loopback_bind_is_denied():
|
||||
assert validated_bind_host("127.0.0.1")=="127.0.0.1"
|
||||
with pytest.raises(RuntimeError,match="GPT_INGRESS_NON_LOOPBACK_BIND_DENY"):
|
||||
validated_bind_host("0.0.0.0")
|
||||
with pytest.raises(RuntimeError,match="GPT_INGRESS_NON_LOOPBACK_BIND_DENY"):
|
||||
validated_bind_host("192.0.2.10")
|
||||
|
||||
|
||||
def test_task_and_branch_ids_are_deterministic_and_bounded():
|
||||
a=task_id_for("external-request-0001")
|
||||
b=task_id_for("external-request-0001")
|
||||
c=task_id_for("external-request-0002")
|
||||
assert a==b
|
||||
assert a!=c
|
||||
assert a.startswith("gpt-readonly-")
|
||||
branch=branch_for(a)
|
||||
assert branch.startswith("agent/external-gpt-readonly/")
|
||||
assert len(a)<180
|
||||
|
||||
|
||||
def test_create_translates_to_canonical_readonly_governed_task(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
response=svc.create(request())
|
||||
assert response["state"]=="BOUND"
|
||||
assert response["write_access"] is False
|
||||
assert response["official_verification_required"] is True
|
||||
assert response["capabilities"]==list(READONLY_CAPABILITIES)
|
||||
|
||||
assert len(upstream.created)==1
|
||||
body=upstream.created[0]
|
||||
assert body["repository"]=="aios/aios"
|
||||
assert body["base_oid"]==MAIN
|
||||
assert body["expected_branch_oid"]==MAIN
|
||||
assert body["branch_mode"]=="CREATE_NEW"
|
||||
assert body["risk"]=="low"
|
||||
assert body["priority"]==0
|
||||
assert body["max_attempts"]==3
|
||||
assert body["require_online_task_projection"] is True
|
||||
assert body["online_task"]["required_capabilities"]==list(READONLY_CAPABILITIES)
|
||||
assert body["online_task"]["constraints"]["repository_mutation"]=="forbidden"
|
||||
assert body["online_task"]["constraints"]["credential_access"]=="forbidden"
|
||||
assert body["execution"]["action"]=="source_capture"
|
||||
assert body["execution"]["adapter"]=="source_capture"
|
||||
assert body["execution"]["capability_scope"]==list(READONLY_CAPABILITIES)
|
||||
assert "writer_lease" not in body["execution"]
|
||||
|
||||
assert len(upstream.projected)==1
|
||||
projection=upstream.projected[0]
|
||||
assert projection["task_id"]==response["task_id"]
|
||||
assert projection["job_id"]==JOB
|
||||
assert projection["required_capabilities"]==list(READONLY_CAPABILITIES)
|
||||
|
||||
row=store.get("external-request-0001")
|
||||
assert row is not None
|
||||
assert row["state"]=="BOUND"
|
||||
assert row["job_id"]==JOB
|
||||
assert row["base_oid"]==MAIN
|
||||
|
||||
|
||||
def test_exact_retry_is_idempotent_without_duplicate_bridge_call(tmp_path):
|
||||
svc,upstream,_=service(tmp_path)
|
||||
first=svc.create(request())
|
||||
second=svc.create(request())
|
||||
assert second==first
|
||||
assert len(upstream.created)==1
|
||||
assert len(upstream.projected)==1
|
||||
|
||||
|
||||
def test_same_request_id_with_different_content_is_conflict(tmp_path):
|
||||
svc,upstream,_=service(tmp_path)
|
||||
svc.create(request())
|
||||
with pytest.raises(IngressError,match="IDEMPOTENCY_CONFLICT"):
|
||||
svc.create(request(goal="Different goal."))
|
||||
assert len(upstream.created)==1
|
||||
|
||||
|
||||
def test_pending_request_recovers_existing_canonical_task_after_crash(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
raw=normalized_request(request())
|
||||
task_id=task_id_for(raw["request_id"])
|
||||
store.reserve(raw,task_id)
|
||||
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"task_id":task_id,
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
result=svc.create(request())
|
||||
assert result["job_id"]==JOB
|
||||
assert result["task_id"]==task_id
|
||||
assert len(upstream.created)==0
|
||||
row=store.get(raw["request_id"])
|
||||
assert row["state"]=="BOUND"
|
||||
|
||||
|
||||
def test_status_is_sanitized_and_never_proxies_raw_execution_or_result_text(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
created=svc.create(request())
|
||||
task_id=created["task_id"]
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"SUCCEEDED",
|
||||
"attempt":1,
|
||||
"claimed_by":"online-engineering-worker-cn-network-mkt-generic-20260815",
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
"secret_marker":"do-not-return",
|
||||
}
|
||||
},
|
||||
"result":{
|
||||
"status":"SUCCEEDED",
|
||||
"classification":"NEEDS_REWORK_SOURCE_CAPTURE",
|
||||
"execution_mode":"source_capture",
|
||||
"output_text":"raw source text that must not be proxied",
|
||||
"source_snapshot":{
|
||||
"payload_sha256":"a"*64,
|
||||
"file_count":42,
|
||||
"files":["secret/path"],
|
||||
},
|
||||
},
|
||||
"verification":{
|
||||
"decision":"SUCCEEDED",
|
||||
"classification":"GENERIC_ONLINE_TASK_AUTO_CONSUMPTION_VERIFIED",
|
||||
"private":"do-not-return",
|
||||
},
|
||||
}
|
||||
status=svc.status("external-request-0001")
|
||||
assert status["state"]=="SUCCEEDED"
|
||||
assert status["attempt"]==1
|
||||
assert status["claimed"] is True
|
||||
assert status["official_verification"]=="SUCCEEDED"
|
||||
assert status["source_snapshot_sha256"]=="a"*64
|
||||
assert status["source_snapshot_file_count"]==42
|
||||
serialized=json.dumps(status)
|
||||
assert "raw source text" not in serialized
|
||||
assert "secret_marker" not in serialized
|
||||
assert "secret/path" not in serialized
|
||||
assert "do-not-return" not in serialized
|
||||
|
||||
|
||||
def test_pending_status_can_recover_job_binding(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
raw=normalized_request(request())
|
||||
task_id=task_id_for(raw["request_id"])
|
||||
store.reserve(raw,task_id)
|
||||
upstream.tasks[task_id]={
|
||||
"task_id":task_id,
|
||||
"status":"PROJECTED",
|
||||
"attempt":0,
|
||||
"metadata":{
|
||||
"execution":{
|
||||
"job_id":JOB,
|
||||
"base_oid":MAIN,
|
||||
"action":"source_capture",
|
||||
"capability_scope":list(READONLY_CAPABILITIES),
|
||||
"governance":{"writer_lease":False},
|
||||
}
|
||||
},
|
||||
}
|
||||
value=svc.status(raw["request_id"])
|
||||
assert value["job_id"]==JOB
|
||||
assert store.get(raw["request_id"])["state"]=="BOUND"
|
||||
|
||||
|
||||
def test_openapi_exposes_only_create_readonly_and_status_operations():
|
||||
doc=openapi_document()
|
||||
assert doc["openapi"]=="3.1.0"
|
||||
paths=doc["paths"]
|
||||
assert set(paths)=={
|
||||
"/v1/read-only-tasks",
|
||||
"/v1/read-only-tasks/{request_id}",
|
||||
}
|
||||
rendered=json.dumps(doc)
|
||||
for forbidden in ("writer-lease","commit","verify","production","tag","baseline"):
|
||||
assert forbidden not in rendered.lower()
|
||||
|
||||
|
||||
def test_systemd_unit_is_loopback_credential_isolated_and_has_no_repository_access():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-online-task-ingress.service").read_text()
|
||||
assert "DynamicUser=yes" in unit
|
||||
assert "LoadCredential=gpt_ingress_token:" in unit
|
||||
assert "LoadCredential=agent_task_control_token:" in unit
|
||||
assert "AIOS_GPT_INGRESS_HOST=127.0.0.1" in unit
|
||||
assert "AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892" in unit
|
||||
assert "AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940" in unit
|
||||
assert "IPAddressDeny=any" in unit
|
||||
assert "IPAddressAllow=127.0.0.0/8" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home" in unit
|
||||
assert "ProtectSystem=strict" in unit
|
||||
assert "NoNewPrivileges=yes" in unit
|
||||
assert "MemoryDenyWriteExecute=yes" in unit
|
||||
assert "0.0.0.0" not in unit
|
||||
|
||||
|
||||
def test_ingress_sqlite_contains_no_request_body_or_credentials(tmp_path):
|
||||
svc,upstream,store=service(tmp_path)
|
||||
svc.create(request(instructions="sensitive-looking but non-secret user text"))
|
||||
con=sqlite3.connect(store.path)
|
||||
columns=[row[1] for row in con.execute("pragma table_info(external_task_requests)")]
|
||||
assert "request_json" not in columns
|
||||
assert "goal" not in columns
|
||||
assert "instructions" not in columns
|
||||
assert "token" not in " ".join(columns).lower()
|
||||
@@ -1,4 +1,13 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -33,3 +42,111 @@ def test_model_api_builder_manifest_contract():
|
||||
assert "'deployment_state': 'STAGING'" in builder
|
||||
assert "'legacy_global_writes_enabled': False" in builder
|
||||
assert 'private marker rejected' in builder
|
||||
|
||||
|
||||
def _load_builder_module():
|
||||
path = ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py'
|
||||
spec = importlib.util.spec_from_file_location('model_api_packager', path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run_builder(source: Path, output: Path, oid: str = 'a' * 40):
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(ROOT / 'scripts' / 'build_v2r2c1_model_api_staging.py'),
|
||||
'--source-root',
|
||||
str(source),
|
||||
'--output-root',
|
||||
str(output),
|
||||
'--source-oid',
|
||||
oid,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_source(tmp_path: Path) -> Path:
|
||||
module = _load_builder_module()
|
||||
source = tmp_path / 'source'
|
||||
for name in module.REQUIRED_APP:
|
||||
target = source / 'active-runtime' / 'app' / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / 'active-runtime' / 'app' / name, target)
|
||||
for rel in module.REQUIRED_RUNTIME:
|
||||
target = source / 'runtime' / 'agent-os' / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / 'runtime' / 'agent-os' / rel, target)
|
||||
for rel in module.REQUIRED_BUILDER_ASSETS:
|
||||
target = source / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / rel, target)
|
||||
return source
|
||||
|
||||
|
||||
def test_model_api_package_contains_builder_runtime_and_is_deterministic(tmp_path):
|
||||
first = tmp_path / 'first'
|
||||
second = tmp_path / 'second'
|
||||
one = _run_builder(ROOT, first)
|
||||
two = _run_builder(ROOT, second)
|
||||
assert one.returncode == 0, one.stderr
|
||||
assert two.returncode == 0, two.stderr
|
||||
|
||||
receipt = json.loads(one.stdout)
|
||||
peer_receipt = json.loads(two.stdout)
|
||||
assert one.stdout == two.stdout
|
||||
archive = first / receipt['artifact_path']
|
||||
peer_archive = second / peer_receipt['artifact_path']
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == hashlib.sha256(
|
||||
peer_archive.read_bytes()
|
||||
).hexdigest()
|
||||
|
||||
with tarfile.open(archive, 'r:gz') as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
assert len(names) == len(set(names))
|
||||
assert 'runtime/app/aios_trusted_candidate_builder.py' in names
|
||||
assert 'runtime/config/gitops/trusted-candidate-builder-policy.json' in names
|
||||
assert 'runtime/deploy/systemd/aios-trusted-candidate-builder.socket' in names
|
||||
assert 'runtime/deploy/systemd/aios-trusted-candidate-builder.service' in names
|
||||
assert all(member.isfile() or member.isdir() for member in members)
|
||||
assert all('.git' not in Path(name).parts for name in names)
|
||||
assert all(
|
||||
member.mode == (0o755 if member.isdir() else 0o644)
|
||||
for member in members
|
||||
)
|
||||
|
||||
|
||||
def test_model_api_packager_rejects_symlinked_required_asset(tmp_path):
|
||||
source = _minimal_source(tmp_path)
|
||||
policy = source / 'config' / 'gitops' / 'trusted-candidate-builder-policy.json'
|
||||
policy.unlink()
|
||||
try:
|
||||
policy.symlink_to(
|
||||
source / 'active-runtime' / 'app' / 'silent_release_hardening.py'
|
||||
)
|
||||
except OSError:
|
||||
pytest.skip('creating symlinks is not permitted by this host')
|
||||
result = _run_builder(source, tmp_path / 'output')
|
||||
assert result.returncode != 0
|
||||
assert 'symlink' in (result.stderr + result.stdout).lower()
|
||||
|
||||
|
||||
def test_private_marker_rejects_real_pem_but_allows_prose(tmp_path):
|
||||
module = _load_builder_module()
|
||||
prose = tmp_path / 'prose.txt'
|
||||
prose.write_text('Documentation says BEGIN PRIVATE KEY without a PEM fence.\n')
|
||||
assert module.private_marker(prose) is False
|
||||
|
||||
private = tmp_path / 'private.pem'
|
||||
pem_begin = '-----' + 'BEGIN PRIVATE KEY' + '-----\n'
|
||||
pem_end = '-----' + 'END PRIVATE KEY' + '-----\n'
|
||||
private.write_text(
|
||||
pem_begin + 'c2Vuc2l0aXZlLWJ1aWxkZXIta2V5LW1hdGVyaWFs\n'
|
||||
+ pem_end
|
||||
)
|
||||
assert module.private_marker(private) is True
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
AGENT_OS = Path(__file__).resolve().parents[1] / "runtime" / "agent-os"
|
||||
for import_root in (APP, AGENT_OS):
|
||||
if str(import_root) not in sys.path:
|
||||
sys.path.insert(0, str(import_root))
|
||||
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
CapabilityAuthorizationError,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBuilderUnavailable,
|
||||
)
|
||||
import silent_release_hardening as hardening # noqa: E402
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
TREE = "b" * 40
|
||||
SHA = "c" * 64
|
||||
GITEA = {"consistent": True}
|
||||
|
||||
|
||||
def _af_inet_available() -> bool:
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
probe.close()
|
||||
return True
|
||||
|
||||
|
||||
def _canonical_digest(value: dict) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _instant(delta: timedelta = timedelta()) -> str:
|
||||
return (datetime.now(timezone.utc) + delta).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
def _keyring_payload(private: Ed25519PrivateKey, *, purpose: str) -> dict:
|
||||
public = private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
||||
)
|
||||
record = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": purpose,
|
||||
"key_id": "builder-key-001",
|
||||
"generation": 1,
|
||||
"not_before": _instant(timedelta(minutes=-1)),
|
||||
"expires_at": _instant(timedelta(days=1)),
|
||||
"status": "ACTIVE",
|
||||
"public_key": base64.b64encode(public).decode("ascii"),
|
||||
"public_key_sha256": hashlib.sha256(public).hexdigest(),
|
||||
}
|
||||
payload = {
|
||||
"algorithm": "Ed25519",
|
||||
"purpose": "builder_attestation",
|
||||
"keys": [record],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_keyring_digest(payload: dict) -> dict:
|
||||
canonical = {
|
||||
"algorithm": payload["algorithm"],
|
||||
"purpose": payload["purpose"],
|
||||
"keys": payload["keys"],
|
||||
}
|
||||
payload["keyring_sha256"] = hashlib.sha256(
|
||||
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return payload
|
||||
|
||||
|
||||
def _attestation(private: Ed25519PrivateKey) -> BuilderAttestation:
|
||||
unsigned = BuilderAttestation(
|
||||
builder_attestation_id="builder-attestation-001",
|
||||
builder_key_id="builder-key-001",
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
package_closure_sha256=SHA,
|
||||
test_evidence_sha256=SHA,
|
||||
canary_evidence_sha256=SHA,
|
||||
issued_at=_instant(),
|
||||
signature="",
|
||||
builder_key_generation=1,
|
||||
signature_algorithm="Ed25519",
|
||||
signature_purpose="builder_attestation",
|
||||
gitea_evidence_sha256=_canonical_digest(GITEA),
|
||||
rollback_evidence_sha256=_canonical_digest({
|
||||
"ready": True,
|
||||
"rollback_sha256": SHA,
|
||||
"source_commit": COMMIT,
|
||||
}),
|
||||
policy_evidence_sha256=SHA,
|
||||
secret_scan_evidence_sha256=SHA,
|
||||
)
|
||||
return BuilderAttestation(
|
||||
**{
|
||||
**unsigned.canonical_payload,
|
||||
"signature": base64.b64encode(
|
||||
private.sign(unsigned.canonical_bytes())
|
||||
).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_owner_key_record_cross_purpose(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="owner_approval")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="purpose"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_verifier_checks_domain_key_window_digest_and_signature(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="builder_attestation")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=SHA,
|
||||
manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA,
|
||||
rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(
|
||||
deployment=SimpleNamespace(
|
||||
package_closure_sha256=SHA,
|
||||
rollback_sha256=SHA,
|
||||
),
|
||||
policy_sha256=SHA,
|
||||
)
|
||||
assert verifier.verify(_attestation(private), candidate, plan).builder_key_id == "builder-key-001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["REVOKED", "EXPIRED", "FUTURE"])
|
||||
def test_builder_verifier_rejects_unavailable_key_lifecycle_states(tmp_path, state):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="builder_attestation")
|
||||
record = payload["keys"][0]
|
||||
if state == "REVOKED":
|
||||
record["status"] = "REVOKED"
|
||||
elif state == "EXPIRED":
|
||||
record["not_before"] = _instant(timedelta(days=-2))
|
||||
record["expires_at"] = _instant(timedelta(days=-1))
|
||||
else:
|
||||
record["not_before"] = _instant(timedelta(days=1))
|
||||
record["expires_at"] = _instant(timedelta(days=2))
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(_refresh_keyring_digest(payload)), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="validity|revoked|unavailable"):
|
||||
verifier.verify(_attestation(private), candidate, plan)
|
||||
|
||||
|
||||
def test_builder_verifier_rejects_unknown_key_invalid_signature_and_keyring_digest(tmp_path):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
payload = _keyring_payload(private, purpose="builder_attestation")
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
verifier = BuilderAttestationVerifier(BuilderAttestationKeyRing.from_file(path))
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="not trusted"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), builder_key_id="unknown-builder-key"),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="signature"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), signature=base64.b64encode(b"x" * 64).decode()),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
|
||||
payload["keyring_sha256"] = "0" * 64
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="keyring digest"):
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
|
||||
|
||||
def test_builder_attestation_rejects_tampered_digest_and_signing_metadata():
|
||||
private = Ed25519PrivateKey.generate()
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["fingerprint"] = "0" * 64
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("builder_key_generation", 2),
|
||||
("signature_algorithm", "RSA"),
|
||||
("signature_purpose", "owner_approval"),
|
||||
],
|
||||
)
|
||||
def test_builder_keyring_rejects_attestation_metadata_mismatch(tmp_path, field, value):
|
||||
private = Ed25519PrivateKey.generate()
|
||||
path = tmp_path / "builder-keyring.json"
|
||||
path.write_text(
|
||||
json.dumps(_keyring_payload(private, purpose="builder_attestation")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing.from_file(path)
|
||||
)
|
||||
candidate = SimpleNamespace(
|
||||
source_commit=COMMIT, tree_oid=TREE,
|
||||
artifact_sha256=SHA, manifest_sha256=SHA,
|
||||
gitea_consistency=GITEA, rollback_ready=True,
|
||||
)
|
||||
plan = SimpleNamespace(deployment=SimpleNamespace(package_closure_sha256=SHA, rollback_sha256=SHA), policy_sha256=SHA)
|
||||
with pytest.raises(CapabilityAuthorizationError, match="metadata"):
|
||||
verifier.verify(
|
||||
dataclasses.replace(_attestation(private), **{field: value}),
|
||||
candidate,
|
||||
plan,
|
||||
)
|
||||
raw = _attestation(private).to_dict()
|
||||
raw["builder_key_generation"] = 2
|
||||
with pytest.raises(CapabilityAuthorizationError, match="digest is invalid"):
|
||||
BuilderAttestation.from_dict(raw, require_fingerprint=True)
|
||||
|
||||
|
||||
def test_builder_unavailable_intent_is_bounded_retryable_and_recovers_without_secret(tmp_path, monkeypatch):
|
||||
class UnavailableBuilder:
|
||||
def build(self, intent):
|
||||
raise TrustedCandidateBuilderUnavailable("socket token=should-not-leak")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=UnavailableBuilder(),
|
||||
max_builder_attempts=2,
|
||||
)
|
||||
payload = {"event_id": "event-builder-001", "source_commit": COMMIT}
|
||||
|
||||
first = service.create_release_intent(payload)
|
||||
second = service.create_release_intent(payload)
|
||||
|
||||
assert first["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert first["retryable"] is True
|
||||
assert first["attempt_count"] == 1
|
||||
assert "should-not-leak" not in json.dumps(first)
|
||||
assert second["status"] == "BUILDER_TERMINAL_FAILURE"
|
||||
assert second["retryable"] is False
|
||||
assert second["attempt_count"] == 2
|
||||
|
||||
|
||||
def test_stale_building_intent_recovers_after_restart_without_double_running_active_attempt(tmp_path, monkeypatch):
|
||||
class CountingUnavailableBuilder:
|
||||
calls = 0
|
||||
|
||||
def build(self, intent):
|
||||
self.calls += 1
|
||||
raise TrustedCandidateBuilderUnavailable("nonsecret")
|
||||
|
||||
monkeypatch.setattr(hardening, "_fsync_directory", lambda _path: None)
|
||||
builder = CountingUnavailableBuilder()
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path,
|
||||
candidate_builder=builder,
|
||||
max_builder_attempts=3,
|
||||
builder_attempt_timeout_seconds=1,
|
||||
)
|
||||
payload = {"event_id": "event-builder-crash-001", "source_commit": COMMIT}
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{payload['event_id']}\0{COMMIT}".encode()
|
||||
).hexdigest()[:24]
|
||||
intent_path = tmp_path / "intents" / f"{intent_id}.json"
|
||||
intent_path.parent.mkdir(parents=True)
|
||||
active_record = {
|
||||
"attempt_count": 1,
|
||||
"attempt_started_at": _instant(),
|
||||
"created_at": _instant(timedelta(minutes=-1)),
|
||||
"event_id": payload["event_id"],
|
||||
"intent_id": intent_id,
|
||||
"source_commit": COMMIT,
|
||||
"status": "BUILDING",
|
||||
}
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
|
||||
active = service.create_release_intent(payload)
|
||||
assert active["status"] == "BUILDING"
|
||||
assert active["idempotent"] is True
|
||||
assert builder.calls == 0
|
||||
|
||||
active_record["attempt_started_at"] = _instant(timedelta(minutes=-5))
|
||||
intent_path.write_text(json.dumps(active_record), encoding="utf-8")
|
||||
recovered = service.create_release_intent(payload)
|
||||
assert recovered["status"] == "BUILDER_RETRYABLE_FAILURE"
|
||||
assert recovered["attempt_count"] == 2
|
||||
assert recovered["failure_code"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert recovered["last_recovery_code"] == "TRUSTED_BUILDER_CRASH_RECOVERED"
|
||||
assert recovered["recovery_count"] == 1
|
||||
assert builder.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
@pytest.mark.skipif(
|
||||
not _af_inet_available(),
|
||||
reason="isolated executor permits AF_UNIX only; live HTTP probe runs at activation",
|
||||
)
|
||||
def test_model_api_keeps_process_liveness_separate_from_builder_readiness(monkeypatch):
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
|
||||
class FakeService:
|
||||
ready = False
|
||||
|
||||
def get_builder_readiness(self):
|
||||
return {"status": "READY" if self.ready else "UNAVAILABLE"}
|
||||
|
||||
def create_release_candidate(self, payload):
|
||||
if not payload:
|
||||
raise ValueError("event_id is required")
|
||||
return {"status": "READY_FOR_APPROVAL"}
|
||||
|
||||
service = FakeService()
|
||||
monkeypatch.setattr(api, "silent_release_service", lambda: service)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"authenticate",
|
||||
lambda token: (
|
||||
{"account": "test", "role": "owner", "scopes": ["release:candidate"]}
|
||||
if token == "test-token"
|
||||
else None
|
||||
),
|
||||
)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api.H)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
def request(path, body=None):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
value = urllib.request.Request(
|
||||
base + path,
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
method="POST" if body is not None else "GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(value, timeout=5) as response:
|
||||
return response.status, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, json.load(error)
|
||||
|
||||
try:
|
||||
health_code, health = request("/health")
|
||||
unavailable_code, unavailable = request("/createReleaseCandidate", {})
|
||||
service.ready = True
|
||||
invalid_code, invalid = request("/createReleaseCandidate", {})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert health_code == 200
|
||||
assert health["process_liveness"] == "PASS"
|
||||
assert health["builder_readiness"]["status"] == "UNAVAILABLE"
|
||||
assert unavailable_code == 503
|
||||
assert unavailable["error"] == "TRUSTED_BUILDER_UNAVAILABLE"
|
||||
assert invalid_code == 400
|
||||
assert invalid["error"] == "event_id is required"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_accepts_a_real_builder_bundle_response():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"trusted_builder_public_seam",
|
||||
APP.parents[1] / "tests" / "test_trusted_candidate_builder.py",
|
||||
)
|
||||
assert spec and spec.loader
|
||||
backend_seam = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(backend_seam)
|
||||
server, client, _ = backend_seam.make_seam()
|
||||
candidate_id = "r42-release-candidate-v2r2c2-20260819"
|
||||
source_commit = "71e736b05fab13efc06af7a4fe2dc022c1cf9a85"
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2", candidate_id, source_commit, nonce="z" * 32
|
||||
),
|
||||
connection=object(),
|
||||
)
|
||||
|
||||
class ResponseClient:
|
||||
def build(self, version, supplied_candidate_id, supplied_source, *, nonce):
|
||||
assert version == "V2R2C2"
|
||||
assert supplied_candidate_id == candidate_id
|
||||
assert supplied_source == source_commit
|
||||
assert len(nonce) >= 32
|
||||
return response
|
||||
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
bundle = api._HostTrustedCandidateBuilderAdapter(ResponseClient()).build(
|
||||
{
|
||||
"candidate_id": candidate_id,
|
||||
"source_commit": source_commit,
|
||||
"version": "V2R2C2",
|
||||
}
|
||||
)
|
||||
|
||||
assert bundle.validate().candidate.source_commit == response["base_oid"]
|
||||
assert bundle.attestation.builder_key_id == response["attestation"]["builder_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="model API runtime requires POSIX fcntl")
|
||||
def test_host_adapter_forwards_v2r2c3_to_the_policy_and_fails_closed_when_unavailable():
|
||||
from aios_trusted_candidate_builder import BuilderError
|
||||
api = importlib.import_module("aios_model_api_staging")
|
||||
calls = []
|
||||
|
||||
class DerivedOnlyClient:
|
||||
def build(self, version, candidate_id, source_commit, *, nonce):
|
||||
calls.append((version, candidate_id, source_commit, nonce))
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
|
||||
with pytest.raises(TrustedCandidateBuilderUnavailable):
|
||||
api._HostTrustedCandidateBuilderAdapter(DerivedOnlyClient()).build(
|
||||
{
|
||||
"candidate_id": "r42-release-candidate-v2r2c3-20260820",
|
||||
"source_commit": COMMIT,
|
||||
"version": "V2R2C3",
|
||||
}
|
||||
)
|
||||
assert calls[0][:3] == (
|
||||
"V2R2C3",
|
||||
"r42-release-candidate-v2r2c3-20260820",
|
||||
COMMIT,
|
||||
)
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_model_governance import (
|
||||
APPROVED,
|
||||
ModelGovernanceError,
|
||||
ModelGovernanceStore,
|
||||
ModelRecord,
|
||||
ProviderRecord,
|
||||
RouteRequest,
|
||||
ScopePolicy,
|
||||
)
|
||||
|
||||
|
||||
def setup_store(tmp_path, now=1_800_000_000.0):
|
||||
store=ModelGovernanceStore(tmp_path/"model-governance.sqlite3",now_fn=lambda:now)
|
||||
store.register_provider(
|
||||
ProviderRecord(
|
||||
"provider-a",
|
||||
"runtime-profile:provider-a",
|
||||
max_failures=1,
|
||||
cooldown_seconds=120,
|
||||
)
|
||||
)
|
||||
store.register_provider(
|
||||
ProviderRecord(
|
||||
"provider-b",
|
||||
"runtime-profile:provider-b",
|
||||
max_failures=2,
|
||||
cooldown_seconds=60,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-a",
|
||||
"provider-a",
|
||||
"2026-08-18",
|
||||
("agent.runtime.hermes","text"),
|
||||
input_cost_micros_per_1k=100,
|
||||
output_cost_micros_per_1k=200,
|
||||
max_input_tokens=10000,
|
||||
max_output_tokens=4000,
|
||||
evaluation_state=APPROVED,
|
||||
evaluation_sha256="a"*64,
|
||||
priority=10,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-b",
|
||||
"provider-b",
|
||||
"2026-08-17",
|
||||
("agent.runtime.hermes","text"),
|
||||
input_cost_micros_per_1k=120,
|
||||
output_cost_micros_per_1k=250,
|
||||
max_input_tokens=10000,
|
||||
max_output_tokens=4000,
|
||||
evaluation_state=APPROVED,
|
||||
evaluation_sha256="b"*64,
|
||||
priority=20,
|
||||
)
|
||||
)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy(
|
||||
"tenant",
|
||||
"tenant-a",
|
||||
"project-a",
|
||||
allowed_models=("model-a","model-b"),
|
||||
required_capabilities=("text",),
|
||||
max_input_tokens_per_request=5000,
|
||||
max_output_tokens_per_request=2000,
|
||||
daily_token_budget=12000,
|
||||
daily_cost_budget_micros=4000,
|
||||
fallback_limit=2,
|
||||
)
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def req(request_id="request-001", *, exclude=()):
|
||||
return RouteRequest(
|
||||
request_id=request_id,
|
||||
task_id="task-a",
|
||||
trace_id="trace-a",
|
||||
scope_type="tenant",
|
||||
tenant_id="tenant-a",
|
||||
project_id="project-a",
|
||||
required_capabilities=("agent.runtime.hermes",),
|
||||
estimated_input_tokens=1000,
|
||||
max_output_tokens=500,
|
||||
preferred_models=(),
|
||||
exclude_providers=exclude,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_rejects_raw_secret(tmp_path):
|
||||
store=ModelGovernanceStore(tmp_path/"db.sqlite3")
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_CREDENTIAL"):
|
||||
store.register_provider(
|
||||
ProviderRecord("provider-a","Bearer raw-secret-value")
|
||||
)
|
||||
|
||||
|
||||
def test_model_registration_requires_evaluation_gate(tmp_path):
|
||||
store=ModelGovernanceStore(tmp_path/"db.sqlite3")
|
||||
store.register_provider(ProviderRecord("provider-a"))
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_EVALUATION_GATE_DENY"):
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-a","provider-a","v1",("text",),
|
||||
evaluation_state="PENDING",evaluation_sha256="a"*64
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_scope_policy_requires_version_with_pin(tmp_path):
|
||||
store=ModelGovernanceStore(tmp_path/"db.sqlite3")
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_POLICY_PIN_VERSION_REQUIRED"):
|
||||
store.set_scope_policy(
|
||||
ScopePolicy("tenant","t","p",pinned_model_id="model-a")
|
||||
)
|
||||
|
||||
|
||||
def test_route_is_deterministic_version_pinned_and_idempotent(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
first=store.route(req())
|
||||
second=store.route(req())
|
||||
assert first==second
|
||||
assert first.model_id=="model-a"
|
||||
assert first.model_version=="2026-08-18"
|
||||
assert first.provider_credential_ref=="runtime-profile:provider-a"
|
||||
assert first.policy_revision==1
|
||||
assert first.model_revision==1
|
||||
snapshot=store.usage_snapshot("tenant","tenant-a","project-a")
|
||||
assert snapshot["reserved_input_tokens"]==1000
|
||||
assert snapshot["reserved_output_tokens"]==500
|
||||
assert snapshot["reserved_cost_micros"]>0
|
||||
|
||||
|
||||
def test_route_idempotency_payload_conflict_fails(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
store.route(req())
|
||||
changed=RouteRequest(
|
||||
**{**req().__dict__,"estimated_input_tokens":2000}
|
||||
)
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_ROUTE_IDEMPOTENCY_CONFLICT"):
|
||||
store.route(changed)
|
||||
|
||||
|
||||
def test_budget_reservation_is_atomic(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy(
|
||||
"tenant","tenant-a","project-a",
|
||||
allowed_models=("model-a",),
|
||||
required_capabilities=("text",),
|
||||
daily_token_budget=1600,
|
||||
daily_cost_budget_micros=10000,
|
||||
),
|
||||
expected_revision=1,
|
||||
)
|
||||
store.route(req("request-1"))
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_BUDGET_DENY"):
|
||||
store.route(req("request-2"))
|
||||
|
||||
|
||||
def test_usage_settlement_is_idempotent_and_releases_reservation(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
decision=store.route(req())
|
||||
first=store.record_success(
|
||||
decision.request_id,
|
||||
input_tokens=900,
|
||||
output_tokens=400,
|
||||
usage_source="runtime",
|
||||
)
|
||||
second=store.record_success(
|
||||
decision.request_id,
|
||||
input_tokens=900,
|
||||
output_tokens=400,
|
||||
usage_source="runtime",
|
||||
)
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
snapshot=store.usage_snapshot("tenant","tenant-a","project-a")
|
||||
assert snapshot["settled_input_tokens"]==900
|
||||
assert snapshot["settled_output_tokens"]==400
|
||||
assert snapshot["reserved_input_tokens"]==0
|
||||
assert snapshot["reserved_output_tokens"]==0
|
||||
|
||||
|
||||
def test_missing_runtime_usage_falls_back_to_reserved_upper_bound(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
decision=store.route(req())
|
||||
result=store.record_success(
|
||||
decision.request_id,
|
||||
input_tokens=None,
|
||||
output_tokens=None,
|
||||
)
|
||||
assert result["usage_source"]=="reserved_upper_bound"
|
||||
assert result["input_tokens"]==1000
|
||||
assert result["output_tokens"]==500
|
||||
|
||||
|
||||
def test_usage_cannot_exceed_reserved_budget(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
decision=store.route(req())
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_USAGE_EXCEEDS_RESERVATION"):
|
||||
store.record_success(
|
||||
decision.request_id,
|
||||
input_tokens=1001,
|
||||
output_tokens=500,
|
||||
)
|
||||
|
||||
|
||||
def test_provider_failure_opens_circuit_and_fallback_uses_other_provider(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
original=req()
|
||||
first=store.route(original)
|
||||
assert first.provider_id=="provider-a"
|
||||
failure=store.record_failure(first.request_id,"provider_timeout")
|
||||
assert failure["open_until"]>0
|
||||
fallback=store.fallback(original,first.request_id)
|
||||
assert fallback.provider_id=="provider-b"
|
||||
assert fallback.fallback_index==1
|
||||
|
||||
|
||||
def test_circuit_excludes_provider_for_new_request(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
first=store.route(req("request-first"))
|
||||
store.record_failure(first.request_id,"provider_timeout")
|
||||
next_decision=store.route(req("request-next"))
|
||||
assert next_decision.provider_id=="provider-b"
|
||||
|
||||
|
||||
def test_model_version_pin_is_enforced(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy(
|
||||
"tenant","tenant-a","project-a",
|
||||
allowed_models=("model-a","model-b"),
|
||||
pinned_model_id="model-b",
|
||||
pinned_model_version="2026-08-17",
|
||||
daily_token_budget=12000,
|
||||
daily_cost_budget_micros=4000,
|
||||
),
|
||||
expected_revision=1,
|
||||
)
|
||||
decision=store.route(req())
|
||||
assert decision.model_id=="model-b"
|
||||
assert decision.model_version=="2026-08-17"
|
||||
|
||||
|
||||
def test_scope_isolation_denies_missing_cross_scope_policy(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
cross=RouteRequest(
|
||||
request_id="request-cross",
|
||||
task_id="task-a",
|
||||
trace_id="trace-a",
|
||||
scope_type="tenant",
|
||||
tenant_id="tenant-b",
|
||||
project_id="project-a",
|
||||
required_capabilities=("agent.runtime.hermes",),
|
||||
estimated_input_tokens=100,
|
||||
max_output_tokens=50,
|
||||
)
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_SCOPE_POLICY_MISSING"):
|
||||
store.route(cross)
|
||||
|
||||
|
||||
def test_audit_chain_detects_tampering(tmp_path):
|
||||
store=setup_store(tmp_path)
|
||||
decision=store.route(req())
|
||||
store.record_success(decision.request_id,input_tokens=100,output_tokens=50)
|
||||
assert store.verify_audit_chain() is True
|
||||
store.conn.execute(
|
||||
"UPDATE model_governance_audit SET payload_json='{}' WHERE sequence=1"
|
||||
)
|
||||
assert store.verify_audit_chain() is False
|
||||
|
||||
|
||||
def test_registry_and_policy_use_cas(tmp_path):
|
||||
store=ModelGovernanceStore(tmp_path/"db.sqlite3")
|
||||
store.register_provider(ProviderRecord("p"))
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_PROVIDER_CAS_MISMATCH"):
|
||||
store.register_provider(ProviderRecord("p"),expected_revision=7)
|
||||
store.set_scope_policy(ScopePolicy("tenant","t","p"))
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_POLICY_CAS_MISMATCH"):
|
||||
store.set_scope_policy(
|
||||
ScopePolicy("tenant","t","p"),
|
||||
expected_revision=7,
|
||||
)
|
||||
|
||||
|
||||
def test_system_policy_never_authorizes_missing_tenant_policy(tmp_path):
|
||||
store=ModelGovernanceStore(tmp_path/"db.sqlite3")
|
||||
store.register_provider(ProviderRecord("provider-a"))
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-a","provider-a","v1",("text",),
|
||||
evaluation_sha256="a"*64,
|
||||
)
|
||||
)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy("system",None,None,allowed_models=("model-a",))
|
||||
)
|
||||
request=RouteRequest(
|
||||
request_id="request-tenant-no-policy",
|
||||
task_id="task-a",
|
||||
trace_id="trace-a",
|
||||
scope_type="tenant",
|
||||
tenant_id="tenant-a",
|
||||
project_id="project-a",
|
||||
required_capabilities=("text",),
|
||||
estimated_input_tokens=10,
|
||||
max_output_tokens=10,
|
||||
)
|
||||
with pytest.raises(ModelGovernanceError,match="MODEL_SCOPE_POLICY_MISSING"):
|
||||
store.route(request)
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_model_governance_admin import apply_config
|
||||
from aios_model_governance import ModelGovernanceStore
|
||||
|
||||
|
||||
def config():
|
||||
return {
|
||||
"schema":"aios.model-governance.bootstrap.v1",
|
||||
"providers":[{
|
||||
"provider_id":"provider-a",
|
||||
"credential_ref":"runtime-profile:provider-a",
|
||||
"max_failures":2,
|
||||
"cooldown_seconds":60,
|
||||
}],
|
||||
"models":[{
|
||||
"model_id":"model-a",
|
||||
"provider_id":"provider-a",
|
||||
"version":"v1",
|
||||
"capabilities":["text"],
|
||||
"input_cost_micros_per_1k":1,
|
||||
"output_cost_micros_per_1k":2,
|
||||
"max_input_tokens":1000,
|
||||
"max_output_tokens":100,
|
||||
"evaluation_state":"APPROVED",
|
||||
"evaluation_sha256":"a"*64,
|
||||
"priority":10,
|
||||
}],
|
||||
"policies":[{
|
||||
"scope_type":"legacy",
|
||||
"tenant_id":None,
|
||||
"project_id":None,
|
||||
"allowed_models":["model-a"],
|
||||
"required_capabilities":["text"],
|
||||
"daily_token_budget":10000,
|
||||
"daily_cost_budget_micros":1000,
|
||||
"fallback_limit":0,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_admin_apply_is_idempotent(tmp_path):
|
||||
cfg=tmp_path/"config.json"
|
||||
cfg.write_text(json.dumps(config()),encoding="utf-8")
|
||||
db=tmp_path/"db.sqlite3"
|
||||
first=apply_config(db,cfg)
|
||||
second=apply_config(db,cfg)
|
||||
assert first["changes"]["providers"]==1
|
||||
assert first["changes"]["models"]==1
|
||||
assert first["changes"]["policies"]==1
|
||||
assert second["changes"]["providers"]==0
|
||||
assert second["changes"]["models"]==0
|
||||
assert second["changes"]["policies"]==0
|
||||
assert second["changes"]["unchanged"]==3
|
||||
store=ModelGovernanceStore(db)
|
||||
assert store.verify_audit_chain() is True
|
||||
store.close()
|
||||
|
||||
|
||||
def test_admin_config_change_uses_cas_revision(tmp_path):
|
||||
value=config()
|
||||
cfg=tmp_path/"config.json"
|
||||
cfg.write_text(json.dumps(value),encoding="utf-8")
|
||||
db=tmp_path/"db.sqlite3"
|
||||
apply_config(db,cfg)
|
||||
value["providers"][0]["cooldown_seconds"]=120
|
||||
cfg.write_text(json.dumps(value),encoding="utf-8")
|
||||
result=apply_config(db,cfg)
|
||||
assert result["changes"]["providers"]==1
|
||||
store=ModelGovernanceStore(db)
|
||||
row=store.conn.execute(
|
||||
"select revision,cooldown_seconds from model_governance_providers where provider_id='provider-a'"
|
||||
).fetchone()
|
||||
assert tuple(row)==(2,120)
|
||||
store.close()
|
||||
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
from aios_model_governance import (
|
||||
ModelGovernanceStore,
|
||||
ModelRecord,
|
||||
ProviderRecord,
|
||||
ScopePolicy,
|
||||
)
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_online_task
|
||||
import aios_external_agent_runtime as runtime
|
||||
|
||||
|
||||
BASE="a"*40
|
||||
|
||||
|
||||
def binding(task_id):
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id":task_id,
|
||||
"job_id":"job-"+task_id,
|
||||
"repository":"aios/aios",
|
||||
"base_oid":BASE,
|
||||
"expected_branch_oid":BASE,
|
||||
"target_branch":"agent/"+task_id,
|
||||
"branch_mode":"CREATE_NEW",
|
||||
"generation":1,
|
||||
"idempotency_key":task_id+"-binding-v1",
|
||||
})
|
||||
|
||||
|
||||
def projected(task_id="task-model-governed", *, scope_type="tenant", tenant_id="tenant-a", project_id="project-a"):
|
||||
request={
|
||||
"task_id":task_id,
|
||||
"goal":"Return the requested sentinel.",
|
||||
"instructions":"No repository or control-plane side effects.",
|
||||
"success_criteria":[{"id":"done","description":"Return a result."}],
|
||||
"constraints":{"repository_mutation":"forbidden"},
|
||||
"scope_type":scope_type,
|
||||
"tenant_id":tenant_id,
|
||||
"project_id":project_id,
|
||||
"trace_id":"trace-model-a",
|
||||
"required_capabilities":["agent.runtime.hermes"],
|
||||
}
|
||||
return project_online_task(
|
||||
binding(task_id),
|
||||
request,
|
||||
{
|
||||
"action":"agent_runtime",
|
||||
"adapter":"hermes",
|
||||
"capability_scope":["agent.runtime.hermes"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def helper(tmp_path):
|
||||
path=tmp_path/"model_runtime.py"
|
||||
path.write_text(
|
||||
"import json,sys\n"
|
||||
"model=sys.argv[1]\n"
|
||||
"provider=sys.argv[2]\n"
|
||||
"payload=json.load(sys.stdin)\n"
|
||||
"if model=='model-a' and payload.get('constraints',{}).get('force_fail_a'):\n"
|
||||
" raise SystemExit(7)\n"
|
||||
"print(json.dumps({'answer':'ok','model':model,'provider':provider,"
|
||||
"'usage':{'input_tokens':20,'output_tokens':10,'cost_micros':2}},sort_keys=True))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def write_profile(tmp_path, helper_path, *, providers=("provider-a","provider-b")):
|
||||
root=tmp_path/"profiles";root.mkdir(exist_ok=True)
|
||||
value={
|
||||
"schema":"aios.external-agent-runtime.profile.v1",
|
||||
"runtime":"hermes",
|
||||
"argv":["/usr/bin/python3",str(helper_path),"{model_id}","{provider_id}"],
|
||||
"input_mode":"stdin_json",
|
||||
"output_mode":"stdout_json",
|
||||
"timeout_seconds":30,
|
||||
"max_output_bytes":65536,
|
||||
"environment":{},
|
||||
"credential_env":{},
|
||||
"model_governance":{
|
||||
"required":True,
|
||||
"provider_ids":list(providers),
|
||||
"model_capabilities":["text"],
|
||||
"max_output_tokens":100,
|
||||
},
|
||||
}
|
||||
(root/"hermes.json").write_text(json.dumps(value),encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def seed(db_path, *, legacy=False, tiny_budget=False):
|
||||
store=ModelGovernanceStore(db_path,now_fn=lambda:1_800_000_000.0)
|
||||
for provider in ("provider-a","provider-b"):
|
||||
store.register_provider(
|
||||
ProviderRecord(
|
||||
provider,
|
||||
f"runtime-profile:{provider}",
|
||||
max_failures=1 if provider=="provider-a" else 2,
|
||||
cooldown_seconds=120,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-a","provider-a","v1",
|
||||
("agent.runtime.hermes","text"),
|
||||
input_cost_micros_per_1k=10,
|
||||
output_cost_micros_per_1k=20,
|
||||
max_input_tokens=5000,max_output_tokens=1000,
|
||||
evaluation_sha256="a"*64,priority=1,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"model-b","provider-b","v2",
|
||||
("agent.runtime.hermes","text"),
|
||||
input_cost_micros_per_1k=12,
|
||||
output_cost_micros_per_1k=22,
|
||||
max_input_tokens=5000,max_output_tokens=1000,
|
||||
evaluation_sha256="b"*64,priority=2,
|
||||
)
|
||||
)
|
||||
if legacy:
|
||||
policy=ScopePolicy(
|
||||
"legacy",None,None,
|
||||
allowed_models=("model-a","model-b"),
|
||||
required_capabilities=("text",),
|
||||
daily_token_budget=10000,
|
||||
daily_cost_budget_micros=10000,
|
||||
fallback_limit=2,
|
||||
)
|
||||
else:
|
||||
policy=ScopePolicy(
|
||||
"tenant","tenant-a","project-a",
|
||||
allowed_models=("model-a","model-b"),
|
||||
required_capabilities=("text",),
|
||||
daily_token_budget=1 if tiny_budget else 10000,
|
||||
daily_cost_budget_micros=10000,
|
||||
fallback_limit=2,
|
||||
)
|
||||
store.set_scope_policy(policy)
|
||||
store.close()
|
||||
|
||||
|
||||
def make_external_request(projected_task, *, force_fail=False, attempt=1):
|
||||
execution=projected_task["metadata"]["execution"]
|
||||
payload=dict(execution["runtime_input"])
|
||||
if force_fail:
|
||||
payload["constraints"]=dict(payload["constraints"])
|
||||
payload["constraints"]["force_fail_a"]=True
|
||||
return {
|
||||
"schema":"aios.external-agent-runtime.request.v1",
|
||||
"request_id":"external-model-governance-0123456789",
|
||||
"runtime":"hermes",
|
||||
"task_id":projected_task["task_id"],
|
||||
"attempt":attempt,
|
||||
"execution_digest":execution["execution_digest"],
|
||||
"payload":payload,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
for key in list(os.environ):
|
||||
upper=key.upper()
|
||||
if any(marker in upper for marker in ("TOKEN","SECRET","PASSWORD","CREDENTIAL","AUTHORIZATION","PRIVATE_KEY","API_KEY")):
|
||||
monkeypatch.delenv(key,raising=False)
|
||||
monkeypatch.setenv("CREDENTIALS_DIRECTORY","/definitely-not-present-model-governance")
|
||||
|
||||
|
||||
def test_execution_projection_binds_trusted_scope_and_trace():
|
||||
task=projected()
|
||||
runtime_input=task["metadata"]["execution"]["runtime_input"]
|
||||
assert runtime_input["scope"]=={
|
||||
"scope_type":"tenant",
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
}
|
||||
assert runtime_input["trace_id"]=="trace-model-a"
|
||||
|
||||
|
||||
def test_legacy_scope_remains_explicit_not_fake_tenant():
|
||||
task=projected(scope_type="legacy",tenant_id=None,project_id=None)
|
||||
scope=task["metadata"]["execution"]["runtime_input"]["scope"]
|
||||
assert scope=={"scope_type":"legacy","tenant_id":None,"project_id":None}
|
||||
|
||||
|
||||
def test_profile_requires_model_binding_when_governance_required(tmp_path):
|
||||
path=tmp_path/"helper.py";path.write_text("print('ok')\n")
|
||||
root=tmp_path/"profiles";root.mkdir()
|
||||
(root/"hermes.json").write_text(json.dumps({
|
||||
"schema":"aios.external-agent-runtime.profile.v1",
|
||||
"runtime":"hermes",
|
||||
"argv":["/usr/bin/python3",str(path)],
|
||||
"input_mode":"stdin_text",
|
||||
"output_mode":"stdout_text",
|
||||
"model_governance":{"required":True,"provider_ids":["provider-a"],"max_output_tokens":100},
|
||||
}))
|
||||
with pytest.raises(runtime.ExternalAgentRuntimeError,match="EXTERNAL_AGENT_MODEL_BINDING_REQUIRED"):
|
||||
runtime.RuntimeProfile.load("hermes",root)
|
||||
|
||||
|
||||
def test_governed_runtime_selects_model_and_settles_usage(tmp_path,monkeypatch):
|
||||
helper_path=helper(tmp_path)
|
||||
profile_root=write_profile(tmp_path,helper_path)
|
||||
db=tmp_path/"governance.sqlite3";seed(db)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
request_path=tmp_path/"request.json"
|
||||
result_path=tmp_path/"result.json"
|
||||
request_path.write_text(json.dumps(make_external_request(projected())),encoding="utf-8")
|
||||
runtime.execute_request(request_path,result_path,profile_root=profile_root)
|
||||
result=json.loads(result_path.read_text())
|
||||
mg=result["model_governance"]
|
||||
assert mg["model_id"]=="model-a"
|
||||
assert mg["model_version"]=="v1"
|
||||
assert mg["provider_id"]=="provider-a"
|
||||
assert mg["settlement"]["usage_source"]=="runtime"
|
||||
assert mg["settlement"]["input_tokens"]==20
|
||||
assert mg["settlement"]["output_tokens"]==10
|
||||
assert mg["audit_chain_valid"] is True
|
||||
assert result["output_json"]["model"]=="model-a"
|
||||
|
||||
|
||||
def test_budget_denies_before_runtime_process(tmp_path,monkeypatch):
|
||||
helper_path=helper(tmp_path)
|
||||
profile_root=write_profile(tmp_path,helper_path)
|
||||
db=tmp_path/"governance.sqlite3";seed(db,tiny_budget=True)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
req=tmp_path/"request.json";res=tmp_path/"result.json"
|
||||
req.write_text(json.dumps(make_external_request(projected())),encoding="utf-8")
|
||||
with pytest.raises(runtime.ExternalAgentRuntimeError,match="MODEL_BUDGET_DENY"):
|
||||
runtime.execute_request(req,res,profile_root=profile_root)
|
||||
assert not res.exists()
|
||||
|
||||
|
||||
def test_provider_failure_routes_to_fallback_provider(tmp_path,monkeypatch):
|
||||
helper_path=helper(tmp_path)
|
||||
profile_root=write_profile(tmp_path,helper_path)
|
||||
db=tmp_path/"governance.sqlite3";seed(db)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
req=tmp_path/"request.json";res=tmp_path/"result.json"
|
||||
req.write_text(json.dumps(make_external_request(projected(),force_fail=True)),encoding="utf-8")
|
||||
runtime.execute_request(req,res,profile_root=profile_root)
|
||||
result=json.loads(res.read_text())
|
||||
mg=result["model_governance"]
|
||||
assert len(mg["attempts"])==2
|
||||
assert mg["attempts"][0]["provider_id"]=="provider-a"
|
||||
assert mg["attempts"][1]["provider_id"]=="provider-b"
|
||||
assert mg["provider_id"]=="provider-b"
|
||||
assert result["output_json"]["provider"]=="provider-b"
|
||||
|
||||
|
||||
def test_profile_provider_allowlist_fails_closed(tmp_path,monkeypatch):
|
||||
helper_path=helper(tmp_path)
|
||||
profile_root=write_profile(tmp_path,helper_path,providers=("provider-b",))
|
||||
db=tmp_path/"governance.sqlite3";seed(db)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
req=tmp_path/"request.json";res=tmp_path/"result.json"
|
||||
req.write_text(json.dumps(make_external_request(projected())),encoding="utf-8")
|
||||
with pytest.raises(runtime.ExternalAgentRuntimeError,match="EXTERNAL_AGENT_MODEL_PROVIDER_PROFILE_DENY"):
|
||||
runtime.execute_request(req,res,profile_root=profile_root)
|
||||
|
||||
|
||||
def test_governance_required_but_runtime_disabled_fails_closed(tmp_path,monkeypatch):
|
||||
helper_path=helper(tmp_path)
|
||||
profile_root=write_profile(tmp_path,helper_path)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",False)
|
||||
req=tmp_path/"request.json";res=tmp_path/"result.json"
|
||||
req.write_text(json.dumps(make_external_request(projected())),encoding="utf-8")
|
||||
with pytest.raises(runtime.ExternalAgentRuntimeError,match="EXTERNAL_AGENT_MODEL_GOVERNANCE_DISABLED"):
|
||||
runtime.execute_request(req,res,profile_root=profile_root)
|
||||
|
||||
|
||||
def test_execution_request_cannot_override_authoritative_task_scope():
|
||||
task_id="task-scope-override"
|
||||
request={
|
||||
"task_id":task_id,
|
||||
"goal":"Return result.",
|
||||
"instructions":"",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
"scope_type":"tenant",
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
"trace_id":"trace-authoritative",
|
||||
"required_capabilities":["agent.runtime.hermes"],
|
||||
}
|
||||
task=project_online_task(
|
||||
binding(task_id),
|
||||
request,
|
||||
{
|
||||
"action":"agent_runtime",
|
||||
"adapter":"hermes",
|
||||
"capability_scope":["agent.runtime.hermes"],
|
||||
"runtime_input":{
|
||||
"goal":"Return result.",
|
||||
"instructions":"",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
"scope":{
|
||||
"scope_type":"tenant",
|
||||
"tenant_id":"tenant-b",
|
||||
"project_id":"project-b",
|
||||
},
|
||||
"trace_id":"trace-forged",
|
||||
},
|
||||
},
|
||||
)
|
||||
runtime_input=task["metadata"]["execution"]["runtime_input"]
|
||||
assert runtime_input["scope"]["tenant_id"]=="tenant-a"
|
||||
assert runtime_input["scope"]["project_id"]=="project-a"
|
||||
assert runtime_input["trace_id"]=="trace-authoritative"
|
||||
|
||||
|
||||
def test_openclaw_nested_usage_is_extracted_exactly():
|
||||
value={
|
||||
"meta":{
|
||||
"agentMeta":{
|
||||
"usage":{"input":7770,"output":13,"total":7783},
|
||||
"lastCallUsage":{"input":7770,"output":13,"total":7783},
|
||||
"costUsd":0,
|
||||
}
|
||||
}
|
||||
}
|
||||
assert runtime._extract_runtime_usage(value)==(7770,13,0)
|
||||
|
||||
|
||||
def test_governance_required_profile_needs_provider_allowlist_and_output_limit(tmp_path):
|
||||
path=tmp_path/"helper.py";path.write_text("print('ok')\\n")
|
||||
root=tmp_path/"profiles";root.mkdir()
|
||||
(root/"hermes.json").write_text(json.dumps({
|
||||
"schema":"aios.external-agent-runtime.profile.v1",
|
||||
"runtime":"hermes",
|
||||
"argv":["/usr/bin/python3",str(path),"{model_id}"],
|
||||
"input_mode":"stdin_text",
|
||||
"output_mode":"stdout_text",
|
||||
"model_governance":{"required":True},
|
||||
}))
|
||||
with pytest.raises(
|
||||
runtime.ExternalAgentRuntimeError,
|
||||
match="EXTERNAL_AGENT_MODEL_PROVIDER_IDS_REQUIRED",
|
||||
):
|
||||
runtime.RuntimeProfile.load("hermes",root)
|
||||
|
||||
|
||||
def _openclaw_context_helper(tmp_path, *, input_tokens=7770, output_tokens=13):
|
||||
path=tmp_path/"openclaw_context_helper.py"
|
||||
path.write_text(
|
||||
"import json,sys\n"
|
||||
"model=sys.argv[1]\n"
|
||||
"provider=sys.argv[2]\n"
|
||||
"json.load(sys.stdin)\n"
|
||||
f"print(json.dumps({{'answer':'ok','model':model,'provider':provider,"
|
||||
f"'meta':{{'agentMeta':{{'usage':{{'input':{input_tokens},'output':{output_tokens},"
|
||||
f"'total':{input_tokens+output_tokens}}},'costUsd':0}}}}}},sort_keys=True))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _openclaw_floor_profile(tmp_path, helper_path, *, floor=16384):
|
||||
root=tmp_path/"openclaw-profiles"
|
||||
root.mkdir(exist_ok=True)
|
||||
value={
|
||||
"schema":"aios.external-agent-runtime.profile.v1",
|
||||
"runtime":"openclaw",
|
||||
"argv":["/usr/bin/python3",str(helper_path),"{model_id}","{provider_id}"],
|
||||
"input_mode":"stdin_json",
|
||||
"output_mode":"stdout_json",
|
||||
"timeout_seconds":30,
|
||||
"max_output_bytes":65536,
|
||||
"environment":{},
|
||||
"credential_env":{},
|
||||
"model_governance":{
|
||||
"required":True,
|
||||
"provider_ids":["openai"],
|
||||
"model_capabilities":["text"],
|
||||
"max_output_tokens":256,
|
||||
"input_reservation_floor_tokens":floor,
|
||||
},
|
||||
}
|
||||
(root/"openclaw.json").write_text(json.dumps(value),encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _seed_openclaw_floor_db(db_path):
|
||||
store=ModelGovernanceStore(db_path,now_fn=lambda:1_800_000_000.0)
|
||||
store.register_provider(
|
||||
ProviderRecord(
|
||||
"openai",
|
||||
"runtime-profile:openclaw",
|
||||
max_failures=2,
|
||||
cooldown_seconds=60,
|
||||
)
|
||||
)
|
||||
store.register_model(
|
||||
ModelRecord(
|
||||
"openai/deepseek-v4-flash",
|
||||
"openai",
|
||||
"v1",
|
||||
("agent.runtime.openclaw","text"),
|
||||
input_cost_micros_per_1k=0,
|
||||
output_cost_micros_per_1k=0,
|
||||
max_input_tokens=32768,
|
||||
max_output_tokens=256,
|
||||
evaluation_sha256="c"*64,
|
||||
priority=10,
|
||||
)
|
||||
)
|
||||
store.set_scope_policy(
|
||||
ScopePolicy(
|
||||
"tenant",
|
||||
"model-governance-live",
|
||||
"staging",
|
||||
allowed_models=("openai/deepseek-v4-flash",),
|
||||
required_capabilities=("text",),
|
||||
max_input_tokens_per_request=32768,
|
||||
max_output_tokens_per_request=256,
|
||||
daily_token_budget=250000,
|
||||
daily_cost_budget_micros=0,
|
||||
fallback_limit=1,
|
||||
)
|
||||
)
|
||||
store.close()
|
||||
|
||||
|
||||
def _openclaw_floor_request(*, attempt=2):
|
||||
return {
|
||||
"schema":"aios.external-agent-runtime.request.v1",
|
||||
"request_id":f"external-openclaw-floor-attempt-{attempt:02d}",
|
||||
"runtime":"openclaw",
|
||||
"task_id":"r42-model-governance-openclaw-live-20260818",
|
||||
"attempt":attempt,
|
||||
"execution_digest":"d"*64,
|
||||
"payload":{
|
||||
"goal":"Return AIOS_MODEL_GOVERNANCE_OPENCLAW_OK.",
|
||||
"instructions":"No side effects.",
|
||||
"success_criteria":[],
|
||||
"constraints":{},
|
||||
"scope":{
|
||||
"scope_type":"tenant",
|
||||
"tenant_id":"model-governance-live",
|
||||
"project_id":"staging",
|
||||
},
|
||||
"trace_id":"trace-openclaw-model-governance-live",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_openclaw_profile_reservation_floor_covers_injected_context(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
helper_path=_openclaw_context_helper(tmp_path,input_tokens=7770,output_tokens=13)
|
||||
profile_root=_openclaw_floor_profile(tmp_path,helper_path,floor=16384)
|
||||
db=tmp_path/"openclaw-governance.sqlite3"
|
||||
_seed_openclaw_floor_db(db)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
request_path=tmp_path/"request.json"
|
||||
result_path=tmp_path/"result.json"
|
||||
request_path.write_text(json.dumps(_openclaw_floor_request()),encoding="utf-8")
|
||||
runtime.execute_request(request_path,result_path,profile_root=profile_root)
|
||||
|
||||
result=json.loads(result_path.read_text())
|
||||
mg=result["model_governance"]
|
||||
assert mg["input_reservation_floor_tokens"]==16384
|
||||
assert mg["prompt_estimated_input_tokens"] < 16384
|
||||
assert mg["reserved_estimated_input_tokens"]==16384
|
||||
assert mg["settlement"]["state"]=="SETTLED"
|
||||
assert mg["settlement"]["usage_source"]=="runtime"
|
||||
assert mg["settlement"]["input_tokens"]==7770
|
||||
assert mg["settlement"]["output_tokens"]==13
|
||||
|
||||
store=ModelGovernanceStore(db)
|
||||
row=store.conn.execute(
|
||||
"""
|
||||
select reserved_input_tokens,state,actual_input_tokens,actual_output_tokens
|
||||
from model_governance_reservations
|
||||
where request_id=?
|
||||
""",
|
||||
(result["request_id"]+":model",),
|
||||
).fetchone()
|
||||
assert int(row["reserved_input_tokens"])==16384
|
||||
assert row["state"]=="SETTLED"
|
||||
assert int(row["actual_input_tokens"])==7770
|
||||
assert int(row["actual_output_tokens"])==13
|
||||
store.close()
|
||||
|
||||
|
||||
def test_openclaw_reservation_floor_still_fails_closed_above_floor(
|
||||
tmp_path,monkeypatch
|
||||
):
|
||||
helper_path=_openclaw_context_helper(tmp_path,input_tokens=17000,output_tokens=13)
|
||||
profile_root=_openclaw_floor_profile(tmp_path,helper_path,floor=16384)
|
||||
db=tmp_path/"openclaw-governance.sqlite3"
|
||||
_seed_openclaw_floor_db(db)
|
||||
monkeypatch.setattr(runtime,"MODEL_GOVERNANCE_ENABLED",True)
|
||||
monkeypatch.setattr(runtime,"DEFAULT_MODEL_GOVERNANCE_DB",db)
|
||||
|
||||
request_path=tmp_path/"request.json"
|
||||
result_path=tmp_path/"result.json"
|
||||
request_path.write_text(json.dumps(_openclaw_floor_request()),encoding="utf-8")
|
||||
with pytest.raises(
|
||||
runtime.ExternalAgentRuntimeError,
|
||||
match="MODEL_USAGE_EXCEEDS_RESERVATION",
|
||||
):
|
||||
runtime.execute_request(request_path,result_path,profile_root=profile_root)
|
||||
assert not result_path.exists()
|
||||
|
||||
|
||||
def test_model_governance_profile_rejects_invalid_input_reservation_floor(tmp_path):
|
||||
path=tmp_path/"helper.py"
|
||||
path.write_text("print('ok')\n",encoding="utf-8")
|
||||
root=tmp_path/"profiles"
|
||||
root.mkdir()
|
||||
(root/"openclaw.json").write_text(json.dumps({
|
||||
"schema":"aios.external-agent-runtime.profile.v1",
|
||||
"runtime":"openclaw",
|
||||
"argv":["/usr/bin/python3",str(path),"{model_id}"],
|
||||
"input_mode":"stdin_text",
|
||||
"output_mode":"stdout_text",
|
||||
"model_governance":{
|
||||
"required":True,
|
||||
"provider_ids":["openai"],
|
||||
"max_output_tokens":256,
|
||||
"input_reservation_floor_tokens":262145,
|
||||
},
|
||||
}))
|
||||
with pytest.raises(
|
||||
runtime.ExternalAgentRuntimeError,
|
||||
match="EXTERNAL_AGENT_MODEL_INPUT_RESERVATION_FLOOR_INVALID",
|
||||
):
|
||||
runtime.RuntimeProfile.load("openclaw",root)
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_executor_has_single_model_governance_sqlite_write_path():
|
||||
unit=(ROOT/"deploy/systemd/aios-external-agent-executor.service").read_text()
|
||||
assert "AIOS_MODEL_GOVERNANCE_DB=/var/lib/aios-model-governance/state.sqlite3" in unit
|
||||
assert "AIOS_MODEL_GOVERNANCE_ENABLED=1" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-model-governance" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-external-agent-executor" in unit
|
||||
assert "InaccessiblePaths=/etc/aios-gitops" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops" in unit
|
||||
|
||||
|
||||
def test_tmpfiles_owner_matches_external_executor():
|
||||
line=(ROOT/"deploy/tmpfiles.d/aios-model-governance.conf").read_text().strip()
|
||||
assert line == (
|
||||
"d /var/lib/aios-model-governance 0750 "
|
||||
"aios-engineering-executor aios-executor-exchange -"
|
||||
)
|
||||
|
||||
|
||||
def test_staging_models_use_real_e2e_evaluation_evidence():
|
||||
value=json.loads(
|
||||
(ROOT/"config/model-governance/model-governance.staging.json").read_text()
|
||||
)
|
||||
assert value["schema"]=="aios.model-governance.bootstrap.v1"
|
||||
models={item["model_id"]:item for item in value["models"]}
|
||||
assert models["deepseek-v4-flash"]["evaluation_sha256"] == (
|
||||
"43ae0db1640daeb6d33e2787769732844f366d36070065d3a2239905bd9a823b"
|
||||
)
|
||||
assert models["openai/deepseek-v4-flash"]["evaluation_sha256"] == (
|
||||
"7b1c89634eb9c0e7cb5a3d75323d904ebbd91b3ae5494ce938bd8316a4cf5b12"
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_profiles_bind_model_from_governance_not_task():
|
||||
for name in ("hermes","openclaw"):
|
||||
value=json.loads(
|
||||
(ROOT/"config/external-agent-runtime.d"/f"{name}.profile.json").read_text()
|
||||
)
|
||||
governance=value["model_governance"]
|
||||
assert governance["required"] is True
|
||||
assert governance["provider_ids"]
|
||||
assert governance["max_output_tokens"] > 0
|
||||
serialized=json.dumps(value["argv"])+json.dumps(value.get("environment",{}))
|
||||
assert "{model_id}" in serialized or "{provider_id}" in serialized
|
||||
|
||||
|
||||
def test_staging_bootstrap_has_explicit_tenant_live_policy():
|
||||
value=json.loads(
|
||||
(ROOT/"config/model-governance/model-governance.staging.json").read_text()
|
||||
)
|
||||
keys={
|
||||
(
|
||||
item["scope_type"],
|
||||
item.get("tenant_id"),
|
||||
item.get("project_id"),
|
||||
)
|
||||
for item in value["policies"]
|
||||
}
|
||||
assert ("legacy",None,None) in keys
|
||||
assert ("tenant","model-governance-live","staging") in keys
|
||||
|
||||
|
||||
def test_staging_policy_input_limit_covers_root_owned_openclaw_reservation_floor():
|
||||
config=json.loads(
|
||||
(ROOT/"config/model-governance/model-governance.staging.json").read_text()
|
||||
)
|
||||
profile=json.loads(
|
||||
(ROOT/"config/external-agent-runtime.d/openclaw.profile.json").read_text()
|
||||
)
|
||||
floor=int(profile["model_governance"]["input_reservation_floor_tokens"])
|
||||
assert floor==16384
|
||||
openclaw_model="openai/deepseek-v4-flash"
|
||||
relevant=[
|
||||
policy for policy in config["policies"]
|
||||
if openclaw_model in policy["allowed_models"]
|
||||
]
|
||||
assert relevant
|
||||
for policy in relevant:
|
||||
assert int(policy["max_input_tokens_per_request"]) >= floor
|
||||
|
||||
|
||||
def test_staging_openclaw_scope_limit_is_least_privilege_floor_not_model_ceiling():
|
||||
config=json.loads(
|
||||
(ROOT/"config/model-governance/model-governance.staging.json").read_text()
|
||||
)
|
||||
models={item["model_id"]:item for item in config["models"]}
|
||||
model=models["openai/deepseek-v4-flash"]
|
||||
assert int(model["max_input_tokens"])==32768
|
||||
for policy in config["policies"]:
|
||||
if "openai/deepseek-v4-flash" in policy["allowed_models"]:
|
||||
assert int(policy["max_input_tokens_per_request"])==16384
|
||||
assert int(policy["max_input_tokens_per_request"]) < int(model["max_input_tokens"])
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_openclaw_profile_uses_conservative_input_reservation_floor():
|
||||
value=json.loads(
|
||||
(ROOT/"config/external-agent-runtime.d/openclaw.profile.json").read_text()
|
||||
)
|
||||
mg=value["model_governance"]
|
||||
assert mg["required"] is True
|
||||
assert mg["input_reservation_floor_tokens"]==16384
|
||||
assert mg["max_output_tokens"]==256
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_capability_contract import ( # noqa: E402
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
normalize_worker_capabilities,
|
||||
normalized_capability_matrix,
|
||||
required_capabilities_subset,
|
||||
)
|
||||
|
||||
|
||||
def test_release_candidate_is_known_to_task_and_worker_vocabularies():
|
||||
capability="release:candidate"
|
||||
assert normalize_required_capabilities([capability])==frozenset({capability})
|
||||
assert normalize_worker_capabilities([capability])==frozenset({capability})
|
||||
assert has_capabilities([capability],[capability]) is True
|
||||
assert normalized_capability_matrix([capability],[capability])=={
|
||||
"worker_effective":["release:candidate"],
|
||||
"task_required":["release:candidate"],
|
||||
"eligible":True,
|
||||
}
|
||||
|
||||
|
||||
def test_release_candidate_capability_does_not_imply_high_risk_release_operations():
|
||||
worker=["release:candidate"]
|
||||
for forbidden in (
|
||||
"submitOwnerApproval",
|
||||
"startDeployment",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize",
|
||||
"runtime_switch",
|
||||
"deploy.production",
|
||||
):
|
||||
assert has_capabilities(worker,[forbidden]) is False
|
||||
|
||||
|
||||
def test_release_wildcards_and_nearby_unknown_names_remain_fail_closed():
|
||||
assert has_capabilities(["release:candidate"],["release:*"]) is False
|
||||
assert has_capabilities(["release:candidate"],["release:candidate.extra"]) is False
|
||||
assert has_capabilities(["release:*"],["release:candidate"]) is False
|
||||
|
||||
|
||||
def test_release_candidate_delegation_subset_is_exact():
|
||||
assert required_capabilities_subset(
|
||||
["release:candidate"],
|
||||
["release:candidate"],
|
||||
) is True
|
||||
assert required_capabilities_subset(
|
||||
["release:candidate"],
|
||||
["startDeployment"],
|
||||
) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"worker,required",
|
||||
[
|
||||
(["agent.runtime.hermes"],["agent.runtime.hermes"]),
|
||||
(["agent.runtime.openclaw"],["agent.runtime.openclaw"]),
|
||||
(["git.read"],["repository_read"]),
|
||||
(["git.test"],["test_execution"]),
|
||||
(
|
||||
["governed.write.branch","governed.write.file"],
|
||||
["governed.git.write"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_existing_known_capability_semantics_are_non_regressed(worker,required):
|
||||
assert has_capabilities(worker,required) is True
|
||||
|
||||
|
||||
def test_release_candidate_worker_does_not_match_empty_or_unrelated_task_by_escalation():
|
||||
assert has_capabilities(["release:candidate"],[]) is True
|
||||
assert has_capabilities(["release:candidate"],["git.read"]) is False
|
||||
assert has_capabilities(["release:candidate"],["agent.runtime.hermes"]) is False
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_release_lineage_contract import ( # noqa: E402
|
||||
FIXED_V2R2C2,
|
||||
ReleaseLineageError,
|
||||
validate_fixed_v2r2c2_task,
|
||||
)
|
||||
|
||||
|
||||
def fixed_task(source=FIXED_V2R2C2.source_commit, *, status="FAILED", attempt=1, max_attempts=1, revision=4):
|
||||
return {
|
||||
"task_id":FIXED_V2R2C2.task_id,
|
||||
"status":status,
|
||||
"attempt":attempt,
|
||||
"max_attempts":max_attempts,
|
||||
"revision":revision,
|
||||
"required_capabilities":["release:candidate"],
|
||||
"constraints":{
|
||||
"release_candidate":{
|
||||
"candidate_id":FIXED_V2R2C2.candidate_id,
|
||||
"source_commit":source,
|
||||
"version":FIXED_V2R2C2.version,
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fixed_v2r2c2_lineage_is_the_original_release_worker_candidate():
|
||||
assert FIXED_V2R2C2.source_commit=="71e736b05fab13efc06af7a4fe2dc022c1cf9a85"
|
||||
result=validate_fixed_v2r2c2_task(fixed_task())
|
||||
assert result["status"]=="VERIFIED"
|
||||
assert result["lineage"]["version"]=="V2R2C2"
|
||||
|
||||
|
||||
def test_later_capability_remediation_is_not_retroactively_v2r2c2_source():
|
||||
with pytest.raises(ReleaseLineageError,match="FIXED_RELEASE_LINEAGE_DRIFT"):
|
||||
validate_fixed_v2r2c2_task(
|
||||
fixed_task("17deb91ca7a187dc81766945b1174e854f86bd25")
|
||||
)
|
||||
|
||||
|
||||
def test_acp_engineering_candidate_is_not_retroactively_v2r2c2_source():
|
||||
with pytest.raises(ReleaseLineageError,match="FIXED_RELEASE_LINEAGE_DRIFT"):
|
||||
validate_fixed_v2r2c2_task(
|
||||
fixed_task("b4b4416c93fedbe451d655d49e28f915b8fd38e6")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,attempt,max_attempts,revision",
|
||||
[
|
||||
("FAILED",1,1,4),
|
||||
("QUEUED",1,2,5),
|
||||
("CLAIMED",2,2,6),
|
||||
("VERIFYING",2,2,9),
|
||||
("SUCCEEDED",2,2,10),
|
||||
],
|
||||
)
|
||||
def test_lineage_contract_does_not_confuse_lifecycle_state_with_release_identity(
|
||||
status,attempt,max_attempts,revision
|
||||
):
|
||||
result=validate_fixed_v2r2c2_task(
|
||||
fixed_task(
|
||||
status=status,
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
revision=revision,
|
||||
)
|
||||
)
|
||||
assert result["status"]=="VERIFIED"
|
||||
assert result["task_status"]==status
|
||||
assert result["attempt"]==attempt
|
||||
assert result["max_attempts"]==max_attempts
|
||||
assert result["revision"]==revision
|
||||
|
||||
|
||||
def test_capability_and_task_identity_are_bound():
|
||||
value=fixed_task()
|
||||
value["required_capabilities"]=[]
|
||||
with pytest.raises(ReleaseLineageError,match="FIXED_TASK_CAPABILITY_DRIFT"):
|
||||
validate_fixed_v2r2c2_task(value)
|
||||
|
||||
value=fixed_task()
|
||||
value["task_id"]="replacement-v2r2c2"
|
||||
with pytest.raises(ReleaseLineageError,match="FIXED_TASK_ID_DRIFT"):
|
||||
validate_fixed_v2r2c2_task(value)
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_release_online_agent_worker as worker
|
||||
|
||||
|
||||
def task(**overrides):
|
||||
value={
|
||||
"task_id":"r42-release-candidate-live-acceptance-20260819",
|
||||
"required_capabilities":["release:candidate"],
|
||||
"constraints":{
|
||||
"release_candidate":{
|
||||
"candidate_id":"r42-release-candidate-v2r2c2-20260819",
|
||||
"source_commit":"5"*40,
|
||||
"version":"V2R2C2",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
}
|
||||
}
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def test_release_request_accepts_only_exact_release_capability():
|
||||
req=worker.ReleaseRequest.from_task(task())
|
||||
assert req.candidate_id=="r42-release-candidate-v2r2c2-20260819"
|
||||
assert req.source_commit=="5"*40
|
||||
assert req.version=="V2R2C2"
|
||||
assert req.projection_id=="projection-"+"a"*32
|
||||
|
||||
with pytest.raises(worker.ReleaseWorkerError,match="TASK_CAPABILITY_SCOPE_INVALID"):
|
||||
worker.ReleaseRequest.from_task(task(required_capabilities=["release:candidate","git.read"]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{
|
||||
"candidate_id":"r42-release-candidate-v2r2c2-20260819",
|
||||
"source_commit":"short",
|
||||
"version":"V2R2C2",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
},
|
||||
{
|
||||
"candidate_id":"bad",
|
||||
"source_commit":"5"*40,
|
||||
"version":"V2R2C2",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
},
|
||||
{
|
||||
"candidate_id":"r42-release-candidate-v2r2c2-20260819",
|
||||
"source_commit":"5"*40,
|
||||
"version":"bad",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
},
|
||||
{
|
||||
"candidate_id":"r42-release-candidate-v2r2c2-20260819",
|
||||
"source_commit":"5"*40,
|
||||
"version":"V2R2C2",
|
||||
"projection_id":"bad",
|
||||
},
|
||||
{
|
||||
"candidate_id":"r42-release-candidate-v2r2c2-20260819",
|
||||
"source_commit":"5"*40,
|
||||
"version":"V2R2C2",
|
||||
"projection_id":"projection-"+"a"*32,
|
||||
"execute":True,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_release_request_rejects_invalid_or_extra_fields(payload):
|
||||
value=task()
|
||||
value["constraints"]={"release_candidate":payload}
|
||||
with pytest.raises(worker.ReleaseWorkerError):
|
||||
worker.ReleaseRequest.from_task(value)
|
||||
|
||||
|
||||
def test_result_payload_preserves_high_risk_denials():
|
||||
release=worker.ReleaseRequest.from_task(task())
|
||||
result=worker.result_payload(
|
||||
"r42-release-candidate-live-acceptance-20260819",
|
||||
release,
|
||||
{"candidate_id":release.candidate_id,"status":"BUILT"},
|
||||
)
|
||||
assert result["status"]=="SUCCEEDED"
|
||||
assert result["classification"]=="RELEASE_CANDIDATE_CREATED"
|
||||
assert result["writer_lease"]=="NONE"
|
||||
assert result["changed_files"]==[]
|
||||
assert result["direct_main_write"] is False
|
||||
assert result["production_write"] is False
|
||||
assert result["tag_write"] is False
|
||||
assert result["baseline_write"] is False
|
||||
assert result["runtime_switch"] is False
|
||||
assert result["owner_approval_submitted"] is False
|
||||
assert result["credential_material_exposed"] is False
|
||||
|
||||
|
||||
def test_result_payload_rejects_builder_candidate_mismatch():
|
||||
release=worker.ReleaseRequest.from_task(task())
|
||||
with pytest.raises(worker.ReleaseWorkerError,match="BUILDER_CANDIDATE_BINDING_INVALID"):
|
||||
worker.result_payload(
|
||||
"r42-release-candidate-live-acceptance-20260819",
|
||||
release,
|
||||
{"candidate_id":"other-release-candidate"},
|
||||
)
|
||||
|
||||
|
||||
def test_worker_calls_only_fixed_release_candidate_route(monkeypatch):
|
||||
release=worker.ReleaseRequest.from_task(task())
|
||||
calls=[]
|
||||
|
||||
def fake_http(base,method,route,token,body=None,timeout=30):
|
||||
calls.append((base,method,route,token,body))
|
||||
if route=="/health":
|
||||
return 200,{"status":"ok"}
|
||||
if route=="/createReleaseCandidate" and body=={}:
|
||||
return 400,{"error":"request_invalid"}
|
||||
if route.endswith("/claim"):
|
||||
return 200,{"lease_token":"l"*32,"attempt":1}
|
||||
if route.endswith("/heartbeat"):
|
||||
return 200,{}
|
||||
if route=="/createReleaseCandidate":
|
||||
return 200,{
|
||||
"candidate_id":release.candidate_id,
|
||||
"artifact_sha256":"f"*64,
|
||||
}
|
||||
if route.endswith("/result"):
|
||||
return 200,{"status":"VERIFYING"}
|
||||
raise AssertionError(route)
|
||||
|
||||
monkeypatch.setattr(worker,"http_json",fake_http)
|
||||
monkeypatch.setattr(worker,"audit",lambda *args,**kwargs:None)
|
||||
monkeypatch.setattr(worker,"write_liveness",lambda *args,**kwargs:None)
|
||||
monkeypatch.setattr(worker.Heartbeat,"first",lambda self:None)
|
||||
monkeypatch.setattr(worker.Heartbeat,"close",lambda self:None)
|
||||
monkeypatch.setattr(worker.Heartbeat,"ensure",lambda self:None)
|
||||
monkeypatch.setattr(worker.threading.Thread,"start",lambda self:None)
|
||||
|
||||
assert worker.execute_task(
|
||||
task(),
|
||||
agent_token="a"*32,
|
||||
release_token="r"*32,
|
||||
) is True
|
||||
|
||||
release_calls=[
|
||||
item for item in calls
|
||||
if item[0]==worker.RELEASE_API
|
||||
and item[2]=="/createReleaseCandidate"
|
||||
and item[4]!={}
|
||||
]
|
||||
assert len(release_calls)==1
|
||||
assert release_calls[0][1]=="POST"
|
||||
assert release_calls[0][2]=="/createReleaseCandidate"
|
||||
assert release_calls[0][4]=={
|
||||
"candidate_id":release.candidate_id,
|
||||
"source_commit":release.source_commit,
|
||||
"version":release.version,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_write_liveness_records_release_capability(tmp_path,monkeypatch):
|
||||
path=tmp_path/"worker-liveness.json"
|
||||
monkeypatch.setattr(worker,"STATE_ROOT",tmp_path)
|
||||
monkeypatch.setattr(worker,"LIVENESS_PATH",path)
|
||||
worker.write_liveness(
|
||||
"IDLE",
|
||||
stage="poll",
|
||||
visible_count=2,
|
||||
)
|
||||
value=json.loads(path.read_text())
|
||||
assert value["schema"]=="aios.online-agent.worker-liveness.v1"
|
||||
assert value["agent_type"]=="release-preparation-worker"
|
||||
assert value["capabilities"]==["release:candidate"]
|
||||
assert value["state"]=="IDLE"
|
||||
assert value["visible_count"]==2
|
||||
|
||||
|
||||
def test_sd_notify_is_noop_without_systemd_socket(monkeypatch):
|
||||
monkeypatch.delenv("NOTIFY_SOCKET",raising=False)
|
||||
worker.sd_notify("WATCHDOG=1")
|
||||
|
||||
def test_systemd_unit_uses_two_encrypted_credentials_and_loopback_release_api():
|
||||
unit=(ROOT/"deploy/systemd/aios-release-online-agent-worker.service").read_text()
|
||||
assert "LoadCredentialEncrypted=agent_token:" in unit
|
||||
assert "LoadCredentialEncrypted=release_candidate_token:" in unit
|
||||
assert "AIOS_RELEASE_API_URL=http://127.0.0.1:18890" in unit
|
||||
assert "AIOS_RELEASE_WORKER_AGENT_ID=online-release-worker-cn-network-mkt-20260819" in unit
|
||||
assert "Type=notify" in unit
|
||||
assert "WatchdogSec=90" in unit
|
||||
assert "Restart=always" in unit
|
||||
assert "IPAddressDeny=any" in unit
|
||||
assert "IPAddressAllow=127.0.0.0/8" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops /home" in unit
|
||||
assert "submitOwnerApproval" not in unit
|
||||
assert "startDeployment" not in unit
|
||||
|
||||
|
||||
def test_source_contains_no_high_risk_release_routes():
|
||||
source=(ROOT/"active-runtime/app/aios_release_online_agent_worker.py").read_text()
|
||||
assert '"/createReleaseCandidate"' in source
|
||||
for forbidden in (
|
||||
"/submitOwnerApproval",
|
||||
"/startDeployment",
|
||||
"/requestOrCreateTag",
|
||||
"/buildProductionArtifact",
|
||||
"/executeApprovedProductionRelease",
|
||||
):
|
||||
assert forbidden not in source
|
||||
|
||||
|
||||
def test_docs_keep_release_candidate_distinct_from_production():
|
||||
doc=(ROOT/"docs/superpowers/specs/2026-08-19-release-preparation-online-agent.md").read_text()
|
||||
assert "release:candidate" in doc
|
||||
assert "Owner signature" in doc
|
||||
assert "never\nstarts deployment" in doc or "never starts deployment" in doc
|
||||
assert "LoadCredentialEncrypted" in doc
|
||||
@@ -0,0 +1,592 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import aios_trusted_candidate_builder as builder_runtime # noqa: E402
|
||||
from aios_trusted_candidate_builder import ( # noqa: E402
|
||||
BuilderError,
|
||||
BuilderKeyring,
|
||||
DeterministicEvidenceCollector,
|
||||
PeerIdentity,
|
||||
ServerOwnedEvidenceSource,
|
||||
TrustedCandidateBuilder,
|
||||
TrustedCandidateBuilderConfig,
|
||||
UnixTrustedCandidateBuilderClient,
|
||||
load_builder_policy,
|
||||
)
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
BuilderAttestation,
|
||||
BuilderAttestationKeyRecord,
|
||||
BuilderAttestationKeyRing,
|
||||
BuilderAttestationVerifier,
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
TagPlan,
|
||||
TrustedCandidateBundle,
|
||||
)
|
||||
|
||||
|
||||
POLICY_PATH = ROOT / "config" / "gitops" / "trusted-candidate-builder-policy.json"
|
||||
NOW = 1_725_000_000
|
||||
|
||||
|
||||
class RecordingDomainSigner:
|
||||
key_id = "test-builder-key"
|
||||
key_generation = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, bytes]] = []
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
self.calls.append((purpose, payload))
|
||||
return hashlib.sha256(b"test-signature\0" + payload).digest()
|
||||
|
||||
|
||||
def make_seam(*, peer: PeerIdentity | None = None, cgroup: str = "/system.slice/aios-gitops-model-api.service", process=("/usr/bin/python3.12", ("/usr/bin/python3", "/opt/aios/gitops-model-api/test/runtime/app/aios_model_api_staging.py")), server_clock=lambda: NOW, client_clock=lambda: NOW, collector=None, nonce_ledger_path=None, signer_override=None) -> tuple[TrustedCandidateBuilder, UnixTrustedCandidateBuilderClient, RecordingDomainSigner]:
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
keyring = BuilderKeyring(builder_hmac=b"h" * 32)
|
||||
signer = signer_override or RecordingDomainSigner()
|
||||
server = TrustedCandidateBuilder(
|
||||
config,
|
||||
keyring,
|
||||
collector=collector or DeterministicEvidenceCollector(config, evidence_source=passing_bundle),
|
||||
signer=signer,
|
||||
peer_identity_resolver=lambda _connection: peer or PeerIdentity(pid=77, uid=1000, gid=1000),
|
||||
cgroup_resolver=lambda _pid: cgroup,
|
||||
process_identity_resolver=lambda _pid: process,
|
||||
clock=server_clock,
|
||||
nonce_ledger_path=nonce_ledger_path,
|
||||
)
|
||||
return server, UnixTrustedCandidateBuilderClient(config, keyring, clock=client_clock), signer
|
||||
|
||||
|
||||
def passing_bundle(recipe_name, recipe):
|
||||
base_oid = recipe["base_oid"]
|
||||
digest = lambda label: hashlib.sha256(f"{label}\0{base_oid}".encode()).hexdigest()
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=recipe["candidate_id"], source_commit=base_oid,
|
||||
tree_oid=digest("tree")[:40], artifact_sha256=digest("artifact"),
|
||||
manifest_sha256=digest("manifest"), tests={"passed": True, "receipt": digest("tests")},
|
||||
canary={"passed": True, "receipt": digest("canary")}, rollback_ready=True,
|
||||
git_consistency={"consistent": True}, gitea_consistency={"consistent": True},
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
)
|
||||
policy_sha = digest("policy")
|
||||
plan = ReleasePlan(
|
||||
deployment=DeploymentPlan(
|
||||
service="aios-silent-release-controller", release_path="/srv/release/r1",
|
||||
release_root="/srv/release", cutover_link="/srv/release/current",
|
||||
dropin_path="/srv/release/r1/deploy/systemd/controller.service",
|
||||
dropin_sha256=digest("dropin"), systemd_unit_sha256=digest("unit"),
|
||||
rollback_path="/srv/release/rollback-r1", rollback_sha256=digest("rollback"),
|
||||
source_commit=base_oid, tree_oid=candidate.tree_oid,
|
||||
artifact_sha256=candidate.artifact_sha256, manifest_sha256=candidate.manifest_sha256,
|
||||
package_closure_sha256=digest("closure"), authoritative_git_dir="/data/aios-gitops/repo.git",
|
||||
repository_identity="aios/aios", baseline_before_sha256=digest("baseline"),
|
||||
registry_before_sha256=digest("registry"), production_tag_refset_before_sha256=digest("tagset"),
|
||||
health_contract_version="trusted-builder/v1", timeout_policy={"seconds": 10},
|
||||
retry_policy={"attempts": 1}, policy_sha256=policy_sha, health_contract={"checked": True},
|
||||
),
|
||||
tag=TagPlan(
|
||||
version="trusted-v2r2c2-001", tag_type="annotated", expected_tag_oid=digest("tag")[:40],
|
||||
expected_target_oid=base_oid, evidence_closure_oid=digest("closure-oid")[:40],
|
||||
approval_plan_id="owner-plan-trusted-v2r2c2", repository_identity="aios/aios",
|
||||
gitea_owner="aios", gitea_repository="aios", current_remote_tag_oid=None,
|
||||
remote_absent_evidence_sha256=digest("tag-absence"), verification_contract="trusted-builder/v1",
|
||||
),
|
||||
finalization=FinalizationPlan(
|
||||
version="trusted-v2r2c2-001", expected_tag_oid=digest("tag")[:40], expected_target_oid=base_oid,
|
||||
expected_previous_baseline_sha256=digest("baseline"), registry_expected_record={"base": base_oid},
|
||||
baseline_expected_record={"base": base_oid}, final_consistency_contract="trusted-builder/v1",
|
||||
), policy_sha256=policy_sha,
|
||||
)
|
||||
checks = {name: True for name in [
|
||||
"authoritative_git", "artifact", "canary", "gitea", "manifest",
|
||||
"package_closure", "rollback", "secret_scan", "tests",
|
||||
]}
|
||||
TrustedCandidateBundle(candidate, plan, checks).validate()
|
||||
canonical_digest = lambda value: hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
evidence_digests = {
|
||||
"artifact": candidate.artifact_sha256,
|
||||
"manifest": candidate.manifest_sha256,
|
||||
"package_closure": plan.deployment.package_closure_sha256,
|
||||
"tests": canonical_digest(candidate.tests),
|
||||
"canary": canonical_digest(candidate.canary),
|
||||
"gitea": canonical_digest(candidate.gitea_consistency),
|
||||
"rollback": canonical_digest({
|
||||
"ready": True,
|
||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
||||
"source_commit": candidate.source_commit,
|
||||
}),
|
||||
"policy": plan.policy_sha256,
|
||||
"secret_scan": digest("secret-scan"),
|
||||
}
|
||||
return {
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": plan.to_dict(),
|
||||
"checks": checks,
|
||||
"evidence_digests": evidence_digests,
|
||||
}
|
||||
|
||||
|
||||
def rehmac(request: dict) -> None:
|
||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
||||
request["hmac"] = hmac.new(
|
||||
b"h" * 32,
|
||||
json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_public_build_seam_accepts_only_the_fixed_v2r2c2_recipe_and_returns_server_owned_evidence() -> None:
|
||||
server, client, signer = make_seam()
|
||||
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="a" * 32)
|
||||
result = server.handle_request(request, connection=object())
|
||||
|
||||
assert result["schema"] == "aios.trusted-candidate-builder.response.v1"
|
||||
assert result["status"] == "BUILT"
|
||||
assert result["recipe"] == "V2R2C2"
|
||||
assert result["repository"] == "aios/aios"
|
||||
assert result["task_id"] == "r42-release-candidate-v2r2c2-live-20260819"
|
||||
assert result["silent_release_bundle"]["candidate"]["candidate_id"] == "r42-release-candidate-v2r2c2-20260819"
|
||||
assert result["base_oid"] == "71e736b05fab13efc06af7a4fe2dc022c1cf9a85"
|
||||
assert set(result["checks"]) == set(server.config.required_checks)
|
||||
assert all(result["checks"].values())
|
||||
assert result["attestation"]["trust_domain"] == "aios.trusted-builder.attestation.v1"
|
||||
assert signer.calls and signer.calls[0][0] == "builder_attestation"
|
||||
assert result["attestation"]["fingerprint"] == hashlib.sha256(signer.calls[0][1]).hexdigest()
|
||||
assert BuilderAttestation.from_dict(result["attestation"]).canonical_bytes() == signer.calls[0][1]
|
||||
silent_bundle = result["silent_release_bundle"]
|
||||
round_trip = TrustedCandidateBundle(
|
||||
ReleaseCandidate.from_dict(silent_bundle["candidate"]),
|
||||
ReleasePlan.from_dict(silent_bundle["release_plan"]),
|
||||
silent_bundle["checks"],
|
||||
)
|
||||
assert round_trip.validate().candidate.candidate_id == result["evidence"]["candidate_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate, error",
|
||||
[
|
||||
(lambda request: request.update({"base_oid": "f" * 40}), "REQUEST_HMAC_INVALID"),
|
||||
(lambda request: request.update({"artifact_sha256": "0" * 64}), "REQUEST_SCHEMA_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_public_build_seam_rejects_tampering_and_caller_owned_evidence(mutate, error) -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="b" * 32)
|
||||
mutate(request)
|
||||
|
||||
with pytest.raises(BuilderError, match=error):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("repository", "other/repository"),
|
||||
("task_id", "other-task"),
|
||||
("base_oid", "f" * 40),
|
||||
("purpose", "owner_approval"),
|
||||
])
|
||||
def test_public_build_seam_rejects_reauthenticated_scope_rebinding(field, value) -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="s" * 32,
|
||||
)
|
||||
request[field] = value
|
||||
rehmac(request)
|
||||
with pytest.raises(BuilderError, match="REQUEST_SCOPE_INVALID"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_rejects_wrong_hmac() -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="w" * 32,
|
||||
)
|
||||
request["hmac"] = "0" * 64
|
||||
with pytest.raises(BuilderError, match="REQUEST_HMAC_INVALID"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_returns_the_original_result_for_exact_nonce_replay_and_rejects_conflicts() -> None:
|
||||
server, client, _ = make_seam()
|
||||
request = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="c" * 32)
|
||||
first = server.handle_request(request, connection=object())
|
||||
assert server.handle_request(request, connection=object()) == first
|
||||
|
||||
conflict = client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="c" * 32)
|
||||
conflict["issued_at"] += 1
|
||||
unsigned = {key: value for key, value in conflict.items() if key != "hmac"}
|
||||
conflict["hmac"] = hmac.new(b"h" * 32, json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
with pytest.raises(BuilderError, match="REPLAY_CONFLICT"):
|
||||
server.handle_request(conflict, connection=object())
|
||||
|
||||
|
||||
def test_exact_replay_survives_restart_and_the_original_freshness_window(tmp_path) -> None:
|
||||
ledger = tmp_path / "nonce-ledger.json"
|
||||
server, client, _ = make_seam(nonce_ledger_path=ledger)
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="r" * 32,
|
||||
)
|
||||
first = server.handle_request(request, connection=object())
|
||||
|
||||
restarted, _, _ = make_seam(
|
||||
server_clock=lambda: NOW + 3600,
|
||||
nonce_ledger_path=ledger,
|
||||
)
|
||||
assert restarted.handle_request(request, connection=object()) == first
|
||||
|
||||
|
||||
def test_readiness_probe_checks_collector_and_signer_without_signing() -> None:
|
||||
server, client, signer = make_seam()
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="q" * 32,
|
||||
operation=server.config.readiness_operation,
|
||||
)
|
||||
result = server.handle_request(request, connection=object())
|
||||
|
||||
assert result["status"] == "READY"
|
||||
assert result["collector_ready"] is True
|
||||
assert result["signer_ready"] is True
|
||||
assert result["builder_key_id"] == signer.key_id
|
||||
assert result["policy_sha256"] == server.config.policy_sha256
|
||||
assert "signature" not in result
|
||||
assert "silent_release_bundle" not in result
|
||||
assert signer.calls == []
|
||||
|
||||
|
||||
def test_readiness_fails_closed_when_signer_is_unavailable() -> None:
|
||||
class UnavailableSigner:
|
||||
@property
|
||||
def key_id(self):
|
||||
raise BuilderError("SIGNER_UNAVAILABLE")
|
||||
|
||||
@property
|
||||
def key_generation(self):
|
||||
return 1
|
||||
|
||||
def sign_for_purpose(self, purpose, payload):
|
||||
raise AssertionError("readiness must not attempt signing")
|
||||
|
||||
server, client, _ = make_seam(signer_override=UnavailableSigner())
|
||||
request = client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="u" * 32,
|
||||
operation=server.config.readiness_operation,
|
||||
)
|
||||
with pytest.raises(BuilderError, match="SIGNER_UNAVAILABLE"):
|
||||
server.handle_request(request, connection=object())
|
||||
|
||||
|
||||
def test_unix_client_applies_bounded_timeout_and_fails_closed(monkeypatch) -> None:
|
||||
observed = {}
|
||||
|
||||
class TimeoutSocket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def settimeout(self, seconds):
|
||||
observed["timeout"] = seconds
|
||||
|
||||
def connect(self, _path):
|
||||
raise TimeoutError("bounded test timeout")
|
||||
|
||||
monkeypatch.setattr(builder_runtime.socket, "socket", lambda *_args: TimeoutSocket())
|
||||
monkeypatch.setattr(builder_runtime.socket, "AF_UNIX", 1, raising=False)
|
||||
monkeypatch.setattr(builder_runtime.socket, "SOCK_STREAM", 1, raising=False)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
config,
|
||||
BuilderKeyring(builder_hmac=b"h" * 32),
|
||||
socket_path=Path("/run/aios-trusted-candidate-builder.sock"),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
with pytest.raises(BuilderError, match="BUILDER_TRANSPORT_UNAVAILABLE"):
|
||||
client.build(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="t" * 32,
|
||||
)
|
||||
assert observed["timeout"] == config.request_timeout_seconds
|
||||
|
||||
|
||||
def test_backend_attestation_round_trip_verifies_with_production_keyring() -> None:
|
||||
private = Ed25519PrivateKey.generate()
|
||||
|
||||
class Ed25519Signer:
|
||||
key_id = "builder-key-001"
|
||||
key_generation = 1
|
||||
|
||||
def sign_for_purpose(self, purpose, payload):
|
||||
assert purpose == "builder_attestation"
|
||||
return private.sign(payload)
|
||||
|
||||
server, client, _ = make_seam(signer_override=Ed25519Signer())
|
||||
response = server.handle_request(
|
||||
client.create_request(
|
||||
"V2R2C2",
|
||||
"r42-release-candidate-v2r2c2-20260819",
|
||||
"71e736b05fab13efc06af7a4fe2dc022c1cf9a85",
|
||||
nonce="v" * 32,
|
||||
),
|
||||
connection=object(),
|
||||
)
|
||||
public_key = private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
verifier = BuilderAttestationVerifier(
|
||||
BuilderAttestationKeyRing(
|
||||
[BuilderAttestationKeyRecord(
|
||||
key_id="builder-key-001",
|
||||
public_key=public_key,
|
||||
algorithm="Ed25519",
|
||||
purpose="builder_attestation",
|
||||
generation=1,
|
||||
not_before="2020-01-01T00:00:00Z",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
status="ACTIVE",
|
||||
)],
|
||||
require_attestation_metadata=True,
|
||||
)
|
||||
)
|
||||
raw = response["silent_release_bundle"]
|
||||
bundle = TrustedCandidateBundle(
|
||||
ReleaseCandidate.from_dict(raw["candidate"]),
|
||||
ReleasePlan.from_dict(raw["release_plan"]),
|
||||
raw["checks"],
|
||||
BuilderAttestation.from_dict(raw["attestation"], require_fingerprint=True),
|
||||
)
|
||||
assert bundle.validate(verifier).candidate.candidate_id == response["evidence"]["candidate_id"]
|
||||
|
||||
|
||||
def test_public_build_seam_rejects_direct_unbound_v2r2c3_and_authentication_boundaries() -> None:
|
||||
server, client, _ = make_seam()
|
||||
with pytest.raises(BuilderError, match="GOVERNED_V2R2C3_DERIVATION_REQUIRED"):
|
||||
client.create_request("V2R2C3", "unknown", "f" * 40, nonce="d" * 32)
|
||||
denied_peer, denied_client, _ = make_seam(peer=PeerIdentity(pid=77, uid=0, gid=0))
|
||||
with pytest.raises(BuilderError, match="PEER_IDENTITY_DENIED"):
|
||||
denied_peer.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="e" * 32), connection=object())
|
||||
denied_cgroup, denied_client, _ = make_seam(cgroup="/system.slice/untrusted.service")
|
||||
with pytest.raises(BuilderError, match="PEER_CGROUP_DENIED"):
|
||||
denied_cgroup.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="f" * 32), connection=object())
|
||||
denied_process, denied_client, _ = make_seam(
|
||||
process=("/usr/bin/python3.12", ("/usr/bin/python3", "/tmp/aios_model_api_staging.py"))
|
||||
)
|
||||
with pytest.raises(BuilderError, match="PEER_PROCESS_DENIED"):
|
||||
denied_process.handle_request(denied_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="p" * 32), connection=object())
|
||||
stale_server, stale_client, _ = make_seam(client_clock=lambda: NOW - 61)
|
||||
with pytest.raises(BuilderError, match="REQUEST_STALE"):
|
||||
stale_server.handle_request(stale_client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="g" * 32), connection=object())
|
||||
|
||||
|
||||
def test_public_build_seam_fails_closed_when_server_owned_collector_is_incomplete() -> None:
|
||||
def incomplete_evidence(recipe_name, recipe):
|
||||
value = passing_bundle(recipe_name, recipe)
|
||||
value["checks"]["canary"] = False
|
||||
return value
|
||||
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
server, client, _ = make_seam(
|
||||
collector=DeterministicEvidenceCollector(config, evidence_source=incomplete_evidence)
|
||||
)
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_CHECKS_INCOMPLETE"):
|
||||
server.handle_request(client.create_request("V2R2C2", "r42-release-candidate-v2r2c2-20260819", "71e736b05fab13efc06af7a4fe2dc022c1cf9a85", nonce="h" * 32), connection=object())
|
||||
|
||||
|
||||
def test_server_owned_source_rehashes_every_fixed_evidence_file_and_rejects_tampering(tmp_path) -> None:
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
||||
recipe = config.fixed_recipe("V2R2C2")
|
||||
raw = passing_bundle("V2R2C2", recipe)
|
||||
candidate = ReleaseCandidate.from_dict(raw["candidate"])
|
||||
plan = ReleasePlan.from_dict(raw["release_plan"])
|
||||
evidence_root = tmp_path / "evidence"
|
||||
version_root = evidence_root / "V2R2C2"
|
||||
version_root.mkdir(parents=True)
|
||||
|
||||
def write_bytes(name: str, value: bytes) -> str:
|
||||
path = version_root / name
|
||||
path.write_bytes(value)
|
||||
path.chmod(0o600)
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
def canonical(value: dict) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
tests = {"passed": True, "receipt": "tests-fixed"}
|
||||
canary = {"passed": True, "receipt": "canary-fixed"}
|
||||
gitea = {
|
||||
"consistent": True,
|
||||
"remote_ref_oid": recipe["source_commit"],
|
||||
"repository": "aios/aios",
|
||||
"source_commit": recipe["source_commit"],
|
||||
"tree_oid": candidate.tree_oid,
|
||||
}
|
||||
git_consistency = {
|
||||
"consistent": True,
|
||||
"repository": "aios/aios",
|
||||
"source_commit": recipe["source_commit"],
|
||||
"tree_oid": candidate.tree_oid,
|
||||
}
|
||||
rollback = {
|
||||
"ready": True,
|
||||
"rollback_sha256": plan.deployment.rollback_sha256,
|
||||
"source_commit": recipe["source_commit"],
|
||||
}
|
||||
artifact_bytes = b"immutable-artifact"
|
||||
manifest_bytes = b'{"manifest":"fixed"}\n'
|
||||
closure_bytes = b"closure artifact.tar.gz\n"
|
||||
policy_bytes = b'{"policy":"fixed"}\n'
|
||||
artifact_sha = write_bytes("artifact.tar.gz", artifact_bytes)
|
||||
manifest_sha = write_bytes("manifest.json", manifest_bytes)
|
||||
closure_sha = write_bytes("checksums.sha256", closure_bytes)
|
||||
tests_sha = write_bytes("tests.json", canonical(tests))
|
||||
canary_sha = write_bytes("canary.json", canonical(canary))
|
||||
gitea_sha = write_bytes("gitea.json", canonical(gitea))
|
||||
rollback_sha = write_bytes("rollback.json", canonical(rollback))
|
||||
policy_sha = write_bytes("silent-release-policy.json", policy_bytes)
|
||||
secret_scan = {"artifact_sha256": artifact_sha, "passed": True}
|
||||
secret_sha = write_bytes("secret-scan.json", canonical(secret_scan))
|
||||
|
||||
candidate.artifact_sha256 = artifact_sha
|
||||
candidate.manifest_sha256 = manifest_sha
|
||||
candidate.tests = tests
|
||||
candidate.canary = canary
|
||||
candidate.git_consistency = git_consistency
|
||||
candidate.gitea_consistency = gitea
|
||||
deployment = dataclasses.replace(
|
||||
plan.deployment,
|
||||
artifact_sha256=artifact_sha,
|
||||
manifest_sha256=manifest_sha,
|
||||
package_closure_sha256=closure_sha,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
plan = dataclasses.replace(plan, deployment=deployment, policy_sha256=policy_sha)
|
||||
receipt = {
|
||||
"schema": "aios.trusted-candidate-builder.evidence.v1",
|
||||
"version": "V2R2C2",
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": plan.to_dict(),
|
||||
}
|
||||
write_bytes("receipt.json", canonical(receipt))
|
||||
|
||||
git_state = {"tree": candidate.tree_oid, "ancestry": 0}
|
||||
|
||||
def git_runner(argv, **_kwargs):
|
||||
if "rev-parse" in argv:
|
||||
return SimpleNamespace(stdout=git_state["tree"] + "\n", returncode=0)
|
||||
return SimpleNamespace(stdout="", returncode=git_state["ancestry"])
|
||||
|
||||
source = ServerOwnedEvidenceSource(
|
||||
config,
|
||||
tmp_path / "repo.git",
|
||||
evidence_root,
|
||||
expected_owner_uid=None,
|
||||
git_runner=git_runner,
|
||||
)
|
||||
collected = source("V2R2C2", recipe)
|
||||
assert collected["candidate"]["artifact_sha256"] == artifact_sha
|
||||
assert collected["evidence_digests"] == {
|
||||
"artifact": artifact_sha,
|
||||
"manifest": manifest_sha,
|
||||
"package_closure": closure_sha,
|
||||
"tests": tests_sha,
|
||||
"canary": canary_sha,
|
||||
"gitea": gitea_sha,
|
||||
"rollback": rollback_sha,
|
||||
"policy": policy_sha,
|
||||
"secret_scan": secret_sha,
|
||||
}
|
||||
|
||||
(version_root / "manifest.json").write_bytes(b"tampered")
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_DIGEST_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
(version_root / "manifest.json").write_bytes(manifest_bytes)
|
||||
|
||||
for name, tampered, expected in (
|
||||
("artifact.tar.gz", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("checksums.sha256", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("silent-release-policy.json", b"tampered", "COLLECTOR_DIGEST_MISMATCH"),
|
||||
("tests.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_TESTS_MISMATCH"),
|
||||
("canary.json", canonical({"passed": True, "receipt": "wrong"}), "COLLECTOR_CANARY_MISMATCH"),
|
||||
("gitea.json", canonical({**gitea, "consistent": False}), "COLLECTOR_GITEA_MISMATCH"),
|
||||
("rollback.json", canonical({**rollback, "ready": False}), "COLLECTOR_ROLLBACK_MISMATCH"),
|
||||
("secret-scan.json", canonical({**secret_scan, "passed": False}), "COLLECTOR_SECRET_SCAN_MISMATCH"),
|
||||
):
|
||||
path = version_root / name
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(tampered)
|
||||
with pytest.raises(BuilderError, match=expected):
|
||||
source("V2R2C2", recipe)
|
||||
path.write_bytes(original)
|
||||
|
||||
git_state["tree"] = "f" * 40
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
git_state["tree"] = candidate.tree_oid
|
||||
git_state["ancestry"] = 1
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_GIT_MISMATCH"):
|
||||
source("V2R2C2", recipe)
|
||||
|
||||
|
||||
def test_systemd_socket_and_service_are_af_unix_hardened_and_credential_bound() -> None:
|
||||
socket_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.socket").read_text()
|
||||
service_unit = (ROOT / "deploy/systemd/aios-trusted-candidate-builder.service").read_text()
|
||||
|
||||
assert "ListenStream=/run/aios-trusted-candidate-builder.sock" in socket_unit
|
||||
assert "SocketMode=0660" in socket_unit
|
||||
assert "LoadCredentialEncrypted=signing_private_key:" in service_unit
|
||||
assert "LoadCredentialEncrypted=builder_hmac:" in service_unit
|
||||
assert "NoNewPrivileges=true" in service_unit
|
||||
assert "ProtectSystem=strict" in service_unit
|
||||
assert "PrivateNetwork=true" in service_unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX" in service_unit
|
||||
assert "StateDirectory=aios-trusted-candidate-builder" in service_unit
|
||||
assert "AIOS_TRUSTED_BUILDER_KEY_GENERATION=1" in service_unit
|
||||
assert "aios-approved-release-executor" not in service_unit
|
||||
assert "--socket-fd 3" in service_unit
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
assert policy["allowed_peer_uid"] == 1000
|
||||
assert policy["allowed_peer_cgroups"] == [
|
||||
"/system.slice/aios-gitops-model-api.service"
|
||||
]
|
||||
Reference in New Issue
Block a user