Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f958e5f0a9 | ||
|
|
ba218ab514 | ||
|
|
8325282cf0 | ||
|
|
0bb70f7488 | ||
|
|
c57e92a4a6 | ||
|
|
2b41644f3c | ||
|
|
19fa06e540 | ||
|
|
4a280bd64a | ||
|
|
3d7ee3f5dc | ||
|
|
097af5a755 | ||
|
|
4a7f13a530 | ||
|
|
76f40a619f | ||
|
|
159fd2b305 | ||
|
|
f389f0d382 | ||
|
|
749dd1e111 | ||
|
|
5bd585df4d | ||
|
|
00fb1d9f3f | ||
|
|
878ff1abab | ||
|
|
2a6e284a11 | ||
|
|
d67d64b33c | ||
|
|
8eef5961bc | ||
|
|
063949afe5 | ||
|
|
d276a8ecb3 | ||
|
|
af38646ae9 | ||
|
|
f85a3d7d89 | ||
|
|
7013c659c7 | ||
|
|
e25f1fecda | ||
|
|
45637da2b3 | ||
|
|
5bc0d0f3ac | ||
|
|
cfebcb7904 |
@@ -5,148 +5,13 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
import aios_isolated_writer_policy as policy
|
||||
from isolated_writer_authz import AuthorizationError, authorize
|
||||
from isolated_writer_commit_file import AtomicCommitFile, CommitFileError, OperationLedger
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
_reject = api.reject_non_read_http
|
||||
_write_file = api.write_file
|
||||
_sync_branch = api.sync_branch
|
||||
_original_do_get = api.H.do_GET
|
||||
api.OPENAPI = Path(os.getenv("AIOS_GITOPS_ISOLATED_WRITER_OPENAPI", str(api.OPENAPI)))
|
||||
|
||||
|
||||
def _registry() -> TokenRegistry:
|
||||
return TokenRegistry(Path(os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"]))
|
||||
|
||||
|
||||
def _atomic_writer() -> AtomicCommitFile:
|
||||
return AtomicCommitFile(
|
||||
workspace=api.WS,
|
||||
bare_repo=api.BARE_REPO,
|
||||
ledger=OperationLedger(Path(os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"])),
|
||||
remote=os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", ""),
|
||||
audit=policy.append_audit_record,
|
||||
)
|
||||
|
||||
|
||||
def _bearer(handler) -> str:
|
||||
value = handler.headers.get("Authorization", "")
|
||||
return value[7:].strip() if value.startswith("Bearer ") else ""
|
||||
|
||||
|
||||
def _authorize(handler, *, method: str, path: str, branch=None, target_path=None):
|
||||
grant = authorize(
|
||||
_registry(),
|
||||
_bearer(handler),
|
||||
method=method,
|
||||
path=path,
|
||||
source_ip=str(handler.client_address[0]),
|
||||
workspace=api.WS,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
grant.record["_source_ip"] = str(handler.client_address[0])
|
||||
return grant
|
||||
|
||||
|
||||
def _authorization_error(handler, exc: AuthorizationError):
|
||||
return handler.sendj({"error": exc.code}, exc.status)
|
||||
|
||||
|
||||
def _isolated_runtime_status(handler):
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
||||
return handler.sendj({
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
"status": "active",
|
||||
"runtime_role": os.getenv("AIOS_GITOPS_RUNTIME_ROLE"),
|
||||
"workspace_role": os.getenv("AIOS_GITOPS_WORKSPACE_ROLE"),
|
||||
"writes_enabled": os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").lower()
|
||||
in {"1", "true", "yes", "on"},
|
||||
"allowed_branch": policy.allowed_branch(),
|
||||
"approved_base_oid": policy.allowed_base_oid(),
|
||||
"workspace_branch": branch,
|
||||
"workspace_head_oid": head,
|
||||
"repository_identity": os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY"),
|
||||
"token_policy_version": 2,
|
||||
"atomic_commit_file": True,
|
||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
||||
})
|
||||
|
||||
|
||||
def _isolated_do_get(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path in {"/health", "/api/health"}:
|
||||
return self.sendj({
|
||||
"status": "ok",
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
})
|
||||
if path not in {"/api/runtime-status", "/api/baseline", "/api/patches"}:
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
_authorize(self, method="GET", path=path)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
if path == "/api/runtime-status":
|
||||
return _isolated_runtime_status(self)
|
||||
return _original_do_get(self)
|
||||
|
||||
|
||||
def _read_commit_file_body(handler):
|
||||
try:
|
||||
length = int(handler.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise CommitFileError("writer_content_length_rejected") from None
|
||||
if length <= 0:
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
if length > 1_100_000:
|
||||
raise CommitFileError("writer_request_too_large", 413)
|
||||
raw = handler.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise CommitFileError("writer_request_json_rejected") from None
|
||||
if not isinstance(value, dict):
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _isolated_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/api/commit-file":
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
data = _read_commit_file_body(self)
|
||||
branch = str(data.get("branch") or "")
|
||||
target_path = str(data.get("path") or "")
|
||||
grant = _authorize(
|
||||
self,
|
||||
method="POST",
|
||||
path=path,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
if api.content_has_secret(str(data.get("content") or "")):
|
||||
raise CommitFileError("writer_secret_content_rejected", 403)
|
||||
result = _atomic_writer().execute(grant, data)
|
||||
return self.sendj(result)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
except CommitFileError as exc:
|
||||
return self.sendj({"error": exc.code}, exc.status)
|
||||
except PermissionError:
|
||||
return self.sendj({"error": "isolated_writer_policy_rejected"}, 403)
|
||||
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
@@ -308,8 +173,6 @@ api.H.branch, api.H.file, api.H.commit, api.H.patch = (
|
||||
_checked_commit,
|
||||
_checked_patch,
|
||||
)
|
||||
api.H.do_GET = _isolated_do_get
|
||||
api.H.do_POST = _isolated_do_post
|
||||
|
||||
if __name__ == "__main__":
|
||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
||||
|
||||
@@ -9,9 +9,10 @@ import re
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import unquote
|
||||
|
||||
import isolated_writer_path_policy as path_policy
|
||||
|
||||
WRITE_ENDPOINTS = frozenset({"/api/commit-file"})
|
||||
WRITE_ENDPOINTS = frozenset({
|
||||
"/api/branch", "/api/create-branch", "/api/file", "/api/commit-file",
|
||||
"/api/commit", "/api/patch", "/api/create-patch",
|
||||
})
|
||||
ALLOWED_ROOTS = (
|
||||
"active-runtime",
|
||||
"docs",
|
||||
@@ -68,15 +69,6 @@ def require_service_invariants(workspace: Path, bare_repo: Path) -> None:
|
||||
raise PermissionError("isolated_writer_role_required")
|
||||
if os.getenv("AIOS_GITOPS_WORKSPACE_ROLE", "") != "isolated-writer":
|
||||
raise PermissionError("isolated_writer_workspace_role_required")
|
||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
||||
raise PermissionError("isolated_writer_token_policy_v2_required")
|
||||
if not os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", "").strip():
|
||||
raise PermissionError("isolated_writer_service_audience_required")
|
||||
if not os.getenv("AIOS_GITOPS_TOKEN_REGISTRY_V2", "").strip():
|
||||
raise PermissionError("isolated_writer_token_registry_required")
|
||||
if not os.getenv("AIOS_GITOPS_WRITER_OPERATION_LEDGER", "").strip():
|
||||
raise PermissionError("isolated_writer_operation_ledger_required")
|
||||
path_policy.configured_path_policy()
|
||||
for name in _DENY_FLAGS:
|
||||
if os.getenv(name, "").strip().lower() not in _FALSE:
|
||||
raise PermissionError("isolated_writer_forbidden_capability_flag")
|
||||
@@ -190,7 +182,6 @@ def _audit_sanitize(value):
|
||||
"private_key",
|
||||
)
|
||||
)
|
||||
and str(key).lower() != "token_id"
|
||||
else _audit_sanitize(inner)
|
||||
)
|
||||
for key, inner in value.items()
|
||||
@@ -248,14 +239,9 @@ def validate_path(path: str, workspace: Path | None = None) -> str:
|
||||
):
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
first = parts[0]
|
||||
service_exact_path = False
|
||||
if first in DENIED_ROOTS:
|
||||
try:
|
||||
path_policy.require_service_path("commit_file.atomic", value)
|
||||
service_exact_path = True
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
if value != "README.md" and first not in ALLOWED_ROOTS and not service_exact_path:
|
||||
raise PermissionError("isolated_writer_protected_path_rejected")
|
||||
if value != "README.md" and first not in ALLOWED_ROOTS:
|
||||
raise PermissionError("isolated_writer_path_outside_allowed_roots")
|
||||
if workspace is not None:
|
||||
lexical_root = Path(os.path.abspath(str(workspace)))
|
||||
@@ -275,22 +261,6 @@ def validate_path(path: str, workspace: Path | None = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def validate_content_for_path(path: str, content: str) -> None:
|
||||
if path.split("/", 1)[0] not in DENIED_ROOTS:
|
||||
return
|
||||
try:
|
||||
path_policy.validate_content("commit_file.atomic", path, content)
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def configured_path_policy_sha256() -> str | None:
|
||||
try:
|
||||
return path_policy.configured_path_policy_sha256()
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def validate_commit_paths(paths: list[str], workspace: Path | None = None) -> None:
|
||||
if enforcement_required():
|
||||
for path in paths:
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Fail-closed operation authorization for an isolated Git writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
|
||||
ROUTE_OPERATIONS = {
|
||||
("GET", "/api/runtime-status"): "runtime.status.read",
|
||||
("GET", "/api/baseline"): "baseline.read",
|
||||
("GET", "/api/patches"): "patches.read",
|
||||
("POST", "/api/commit-file"): "commit_file.atomic",
|
||||
}
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
class AuthorizationError(PermissionError):
|
||||
def __init__(self, code: str, status: int = 403):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedGrant:
|
||||
record: dict[str, Any]
|
||||
operation: str
|
||||
use_count: int
|
||||
|
||||
@property
|
||||
def token_id(self) -> str:
|
||||
return str(self.record["token_id"])
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return str(self.record["task_id"])
|
||||
|
||||
|
||||
def operation_for(method: str, path: str) -> str:
|
||||
operation = ROUTE_OPERATIONS.get((method.upper(), path))
|
||||
if not operation:
|
||||
raise AuthorizationError("isolated_writer_operation_forbidden")
|
||||
return operation
|
||||
|
||||
|
||||
def _parse_time(value: Any) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
raise AuthorizationError("writer_token_time_invalid") from None
|
||||
if parsed.tzinfo is None:
|
||||
raise AuthorizationError("writer_token_time_invalid")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _git_is_ancestor(workspace: Path, base: str, branch: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", base, branch],
|
||||
cwd=workspace,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _validate_grant(
|
||||
grant: dict[str, Any],
|
||||
*,
|
||||
audience: str,
|
||||
source_ip: str,
|
||||
operation: str,
|
||||
branch: str | None,
|
||||
path: str | None,
|
||||
workspace: Path,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
if int(grant.get("schema_version", 0)) != 2:
|
||||
raise AuthorizationError("writer_token_policy_v2_required", 401)
|
||||
if str(grant.get("audience") or "") != audience:
|
||||
raise AuthorizationError("writer_token_audience_rejected")
|
||||
try:
|
||||
peer = ipaddress.ip_address(source_ip)
|
||||
networks = [ipaddress.ip_network(str(value), strict=True) for value in grant.get("source_cidrs", [])]
|
||||
except ValueError:
|
||||
raise AuthorizationError("writer_token_source_rejected") from None
|
||||
if not networks or not any(peer in network for network in networks):
|
||||
raise AuthorizationError("writer_token_source_rejected")
|
||||
created = _parse_time(grant.get("created_at"))
|
||||
not_before = _parse_time(grant.get("not_before"))
|
||||
expires = _parse_time(grant.get("expires_at"))
|
||||
if expires <= not_before or (expires - created).total_seconds() > 1800:
|
||||
raise AuthorizationError("writer_token_lifetime_rejected")
|
||||
if current_time < not_before:
|
||||
raise AuthorizationError("writer_token_not_yet_valid", 401)
|
||||
if current_time >= expires:
|
||||
raise AuthorizationError("writer_token_expired", 401)
|
||||
if bool(grant.get("revoked")):
|
||||
raise AuthorizationError("writer_token_revoked", 401)
|
||||
operations = grant.get("allowed_operations")
|
||||
if not isinstance(operations, list) or operation not in operations:
|
||||
raise AuthorizationError("writer_token_operation_rejected")
|
||||
expected_branch = str(grant.get("allowed_branch") or "")
|
||||
configured_branch = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "")
|
||||
if expected_branch != configured_branch:
|
||||
raise AuthorizationError("writer_token_branch_rejected")
|
||||
if branch is not None and branch != expected_branch:
|
||||
raise AuthorizationError("writer_token_branch_rejected")
|
||||
if path is not None:
|
||||
paths = grant.get("allowed_paths")
|
||||
if not isinstance(paths, list) or path not in paths:
|
||||
raise AuthorizationError("writer_token_path_rejected")
|
||||
base = str(grant.get("approved_base_oid") or "").lower()
|
||||
if not OID_RE.fullmatch(base):
|
||||
raise AuthorizationError("writer_token_base_rejected")
|
||||
if not _git_is_ancestor(workspace, base, expected_branch):
|
||||
raise AuthorizationError("writer_token_base_not_ancestor")
|
||||
|
||||
|
||||
def authorize(
|
||||
registry: TokenRegistry,
|
||||
token: str,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
source_ip: str,
|
||||
workspace: Path,
|
||||
branch: str | None = None,
|
||||
target_path: str | None = None,
|
||||
current_time: datetime | None = None,
|
||||
) -> AuthorizedGrant:
|
||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
||||
raise AuthorizationError("writer_token_policy_v2_not_required")
|
||||
if not token:
|
||||
raise AuthorizationError("unauthorized", 401)
|
||||
grant = registry.find(token)
|
||||
if grant is None:
|
||||
raise AuthorizationError("unauthorized", 401)
|
||||
operation = operation_for(method, path)
|
||||
_validate_grant(
|
||||
grant,
|
||||
audience=os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""),
|
||||
source_ip=source_ip,
|
||||
operation=operation,
|
||||
branch=branch,
|
||||
path=target_path,
|
||||
workspace=workspace,
|
||||
current_time=current_time or datetime.now(timezone.utc),
|
||||
)
|
||||
limits = grant.get("max_uses")
|
||||
if not isinstance(limits, dict):
|
||||
raise AuthorizationError("writer_token_use_limit_missing")
|
||||
try:
|
||||
used = registry.consume(str(grant["token_id"]), operation, int(limits[operation]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise AuthorizationError("writer_token_use_limit_missing") from None
|
||||
except PermissionError as exc:
|
||||
code = str(exc)
|
||||
raise AuthorizationError(code, 401 if code == "writer_token_revoked" else 403) from None
|
||||
return AuthorizedGrant(grant, operation, used)
|
||||
@@ -1,403 +0,0 @@
|
||||
"""Atomic, idempotent commit-file Saga for the isolated writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aios_isolated_writer_policy as policy
|
||||
|
||||
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
IDEMPOTENCY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,127}")
|
||||
STATES = frozenset({
|
||||
"PENDING", "FILE_WRITTEN", "COMMITTED", "PUSHED", "VERIFIED", "COMPLETED",
|
||||
"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED",
|
||||
})
|
||||
_LOCKS: dict[str, threading.Lock] = {}
|
||||
_LOCKS_GUARD = threading.Lock()
|
||||
_LEDGER_INIT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class CommitFileError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
class SimulatedCrash(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_digest(data: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"branch": data["branch"],
|
||||
"path": data["path"],
|
||||
"content_sha256": hashlib.sha256(data["content"].encode("utf-8")).hexdigest(),
|
||||
"message": data["message"],
|
||||
"expected_head_oid": data["expected_head_oid"],
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
class OperationLedger:
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
||||
raise PermissionError("writer_operation_ledger_symlink_rejected")
|
||||
with _LEDGER_INIT_LOCK:
|
||||
with self.connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS writer_operations (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
operation_digest TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content_sha256 TEXT NOT NULL,
|
||||
pre_head_oid TEXT NOT NULL,
|
||||
commit_oid TEXT,
|
||||
push_status TEXT,
|
||||
verification_status TEXT,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
error_code TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
return db
|
||||
|
||||
def get_or_create(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
||||
key = data["idempotency_key"]
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute(
|
||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
||||
).fetchone()
|
||||
if row:
|
||||
db.commit()
|
||||
record = dict(row)
|
||||
if record["operation_digest"] != digest:
|
||||
raise CommitFileError("writer_idempotency_conflict", 409)
|
||||
if (
|
||||
record["token_id"] != grant.token_id
|
||||
or record["task_id"] != grant.task_id
|
||||
):
|
||||
raise CommitFileError("writer_idempotency_owner_conflict", 409)
|
||||
return record
|
||||
operation_id = hashlib.sha256(
|
||||
f"{grant.task_id}\0{key}".encode("utf-8")
|
||||
).hexdigest()
|
||||
content_sha = hashlib.sha256(data["content"].encode("utf-8")).hexdigest()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO writer_operations(
|
||||
operation_id,token_id,task_id,idempotency_key,operation_digest,
|
||||
branch,path,content_sha256,pre_head_oid,state
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
operation_id, grant.token_id, grant.task_id, key, digest,
|
||||
data["branch"], data["path"], content_sha,
|
||||
data["expected_head_oid"], "PENDING",
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return self.get(key)
|
||||
|
||||
def get(self, key: str) -> dict[str, Any]:
|
||||
with self.connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(key)
|
||||
return dict(row)
|
||||
|
||||
def update(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
||||
if state not in STATES:
|
||||
raise ValueError("invalid writer operation state")
|
||||
allowed = {"commit_oid", "push_status", "verification_status", "error_code"}
|
||||
assignments = ["state=?", "updated_at=CURRENT_TIMESTAMP"]
|
||||
values: list[Any] = [state]
|
||||
for name, value in fields.items():
|
||||
if name not in allowed:
|
||||
raise ValueError("invalid ledger field")
|
||||
assignments.append(f"{name}=?")
|
||||
values.append(value)
|
||||
values.append(key)
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute(
|
||||
f"UPDATE writer_operations SET {','.join(assignments)} WHERE idempotency_key=?",
|
||||
values,
|
||||
)
|
||||
db.commit()
|
||||
return self.get(key)
|
||||
|
||||
|
||||
class AtomicCommitFile:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
bare_repo: Path,
|
||||
ledger: OperationLedger,
|
||||
remote: str,
|
||||
audit,
|
||||
crash_after_state: str | None = None,
|
||||
):
|
||||
self.workspace = workspace
|
||||
self.bare_repo = bare_repo
|
||||
self.ledger = ledger
|
||||
self.remote = remote
|
||||
self.audit = audit
|
||||
self.crash_after_state = crash_after_state
|
||||
|
||||
def _git(self, *args: str, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=self.workspace, text=True, capture_output=True
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise CommitFileError("writer_git_operation_failed", 500)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _transition(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
||||
record = self.ledger.update(key, state, **fields)
|
||||
if self.crash_after_state == state:
|
||||
raise SimulatedCrash(state)
|
||||
return record
|
||||
|
||||
def _validate(self, data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
required = ("branch", "path", "content", "message", "expected_head_oid", "idempotency_key")
|
||||
if any(field not in data for field in required):
|
||||
raise CommitFileError("writer_request_incomplete")
|
||||
if data.get("force") or data.get("force_push"):
|
||||
raise CommitFileError("isolated_writer_force_rejected", 403)
|
||||
if set(data) != set(required):
|
||||
raise CommitFileError("writer_request_fields_rejected")
|
||||
item = {field: data[field] for field in required}
|
||||
if item["branch"] != policy.allowed_branch():
|
||||
raise CommitFileError("isolated_writer_branch_rejected", 403)
|
||||
try:
|
||||
item["path"] = policy.validate_path(item["path"], self.workspace)
|
||||
except PermissionError as exc:
|
||||
raise CommitFileError(str(exc), 403) from None
|
||||
if not isinstance(item["content"], str) or "\x00" in item["content"]:
|
||||
raise CommitFileError("writer_content_rejected")
|
||||
try:
|
||||
policy.validate_content_for_path(item["path"], item["content"])
|
||||
except PermissionError as exc:
|
||||
raise CommitFileError(str(exc), 403) from None
|
||||
if len(item["content"].encode("utf-8")) > 1_048_576:
|
||||
raise CommitFileError("writer_content_too_large", 413)
|
||||
message = item["message"]
|
||||
if (
|
||||
not isinstance(message, str)
|
||||
or not 1 <= len(message) <= 200
|
||||
or "\x00" in message
|
||||
or "\n" in message
|
||||
or "\r" in message
|
||||
):
|
||||
raise CommitFileError("writer_message_rejected")
|
||||
if not isinstance(item["expected_head_oid"], str) or not OID_RE.fullmatch(item["expected_head_oid"]):
|
||||
raise CommitFileError("writer_expected_head_rejected")
|
||||
if not isinstance(item["idempotency_key"], str) or not IDEMPOTENCY_RE.fullmatch(item["idempotency_key"]):
|
||||
raise CommitFileError("writer_idempotency_key_rejected")
|
||||
return item
|
||||
|
||||
def _atomic_write(self, relative: str, content: str) -> None:
|
||||
target = self.workspace / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".writer-", dir=target.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(content.encode("utf-8"))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
parent_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def _verify_remote(self, branch: str, oid: str) -> None:
|
||||
bare = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo), "rev-parse", f"refs/heads/{branch}"],
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if bare.returncode != 0 or bare.stdout.strip() != oid:
|
||||
raise CommitFileError("writer_authoritative_bare_oid_mismatch", 500)
|
||||
remote = subprocess.run(
|
||||
["git", "ls-remote", "--heads", self.remote, f"refs/heads/{branch}"],
|
||||
cwd=self.workspace, text=True, capture_output=True,
|
||||
)
|
||||
if remote.returncode != 0:
|
||||
raise CommitFileError("writer_gitea_verification_failed", 503)
|
||||
fields = remote.stdout.strip().split()
|
||||
if len(fields) != 2:
|
||||
raise CommitFileError("writer_gitea_ref_missing", 503)
|
||||
if fields[0] != oid:
|
||||
raise CommitFileError("writer_gitea_oid_conflict", 409)
|
||||
|
||||
def execute(self, grant: Any, raw_data: Any) -> dict[str, Any]:
|
||||
data = self._validate(raw_data)
|
||||
digest = canonical_digest(data)
|
||||
branch = data["branch"]
|
||||
with _LOCKS_GUARD:
|
||||
lock = _LOCKS.setdefault(branch, threading.Lock())
|
||||
lock_path = self.ledger.path.parent / f"{hashlib.sha256(branch.encode()).hexdigest()}.lock"
|
||||
with lock, open(lock_path, "a+b") as branch_lock:
|
||||
fcntl.flock(branch_lock.fileno(), fcntl.LOCK_EX)
|
||||
return self._execute_locked(grant, data, digest)
|
||||
|
||||
def _execute_locked(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
||||
key = data["idempotency_key"]
|
||||
record = self.ledger.get_or_create(grant, data, digest)
|
||||
if record["state"] == "COMPLETED":
|
||||
return self._result(record, idempotent=True)
|
||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != data["branch"]:
|
||||
record = self._transition(
|
||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_current_branch_conflict"
|
||||
)
|
||||
self._audit(grant, data, digest, record, "manual_review_required")
|
||||
raise CommitFileError("writer_current_branch_conflict", 409)
|
||||
head = self._git("rev-parse", "HEAD")
|
||||
commit_oid = record.get("commit_oid")
|
||||
if head != record["pre_head_oid"] and head != commit_oid:
|
||||
record = self._transition(
|
||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_branch_head_conflict"
|
||||
)
|
||||
self._audit(grant, data, digest, record, "manual_review_required")
|
||||
raise CommitFileError("writer_branch_head_conflict", 409)
|
||||
try:
|
||||
state = record["state"]
|
||||
if state == "MANUAL_REVIEW_REQUIRED":
|
||||
raise CommitFileError("writer_manual_review_required", 409)
|
||||
if state == "FAILED_RECOVERABLE":
|
||||
if record.get("push_status") == "confirmed":
|
||||
state = "PUSHED"
|
||||
else:
|
||||
state = "COMMITTED" if commit_oid else "FILE_WRITTEN"
|
||||
if state == "PENDING":
|
||||
self._atomic_write(data["path"], data["content"])
|
||||
record = self._transition(key, "FILE_WRITTEN")
|
||||
state = record["state"]
|
||||
if state == "FILE_WRITTEN":
|
||||
actual_sha = hashlib.sha256(
|
||||
(self.workspace / data["path"]).read_bytes()
|
||||
).hexdigest()
|
||||
if actual_sha != record["content_sha256"]:
|
||||
raise CommitFileError("writer_file_content_conflict", 409)
|
||||
staged_before = self._git("diff", "--cached", "--name-only", check=False).splitlines()
|
||||
if staged_before:
|
||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
||||
self._git("add", "--", data["path"])
|
||||
staged = self._git("diff", "--cached", "--name-only").splitlines()
|
||||
if staged != [data["path"]]:
|
||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
||||
self._git("commit", "-m", data["message"])
|
||||
commit_oid = self._git("rev-parse", "HEAD")
|
||||
record = self._transition(key, "COMMITTED", commit_oid=commit_oid)
|
||||
state = record["state"]
|
||||
if state == "COMMITTED":
|
||||
commit_oid = str(record["commit_oid"])
|
||||
pushed = subprocess.run(
|
||||
["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{data['branch']}"],
|
||||
cwd=self.workspace, text=True, capture_output=True,
|
||||
)
|
||||
if pushed.returncode != 0:
|
||||
self._transition(
|
||||
key, "FAILED_RECOVERABLE",
|
||||
push_status="failed", error_code="writer_gitea_push_failed",
|
||||
)
|
||||
raise CommitFileError("writer_gitea_push_failed", 503)
|
||||
record = self._transition(key, "PUSHED", push_status="confirmed")
|
||||
state = record["state"]
|
||||
if state == "PUSHED":
|
||||
commit_oid = str(record["commit_oid"])
|
||||
self._verify_remote(data["branch"], commit_oid)
|
||||
record = self._transition(
|
||||
key, "VERIFIED", verification_status="bare+gitea"
|
||||
)
|
||||
state = record["state"]
|
||||
if state == "VERIFIED":
|
||||
record = self._transition(key, "COMPLETED", error_code=None)
|
||||
self._audit(grant, data, digest, record, "completed")
|
||||
return self._result(record, idempotent=False)
|
||||
except SimulatedCrash:
|
||||
raise
|
||||
except CommitFileError as exc:
|
||||
latest = self.ledger.get(key)
|
||||
if latest["state"] not in {"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED"}:
|
||||
failure_state = (
|
||||
"MANUAL_REVIEW_REQUIRED"
|
||||
if exc.code in {
|
||||
"writer_gitea_oid_conflict",
|
||||
"writer_file_content_conflict",
|
||||
"writer_current_branch_conflict",
|
||||
}
|
||||
else "FAILED_RECOVERABLE"
|
||||
)
|
||||
latest = self._transition(key, failure_state, error_code=exc.code)
|
||||
self._audit(grant, data, digest, latest, "failed")
|
||||
raise
|
||||
|
||||
def _audit(self, grant: Any, data: dict[str, Any], digest: str, record: dict[str, Any], result: str) -> None:
|
||||
self.audit({
|
||||
"token_id": grant.token_id,
|
||||
"task_id": grant.task_id,
|
||||
"audience": grant.record["audience"],
|
||||
"source_ip": grant.record.get("_source_ip", "socket-peer"),
|
||||
"operation": grant.operation,
|
||||
"branch": data["branch"],
|
||||
"path": data["path"],
|
||||
"idempotency_key": data["idempotency_key"],
|
||||
"operation_digest": digest,
|
||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
||||
"pre_head_oid": record["pre_head_oid"],
|
||||
"commit_oid": record.get("commit_oid"),
|
||||
"result": result,
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _result(record: dict[str, Any], *, idempotent: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "completed",
|
||||
"state": record["state"],
|
||||
"operation_id": record["operation_id"],
|
||||
"branch": record["branch"],
|
||||
"path": record["path"],
|
||||
"commit_oid": record.get("commit_oid"),
|
||||
"push_status": record.get("push_status"),
|
||||
"verification_status": record.get("verification_status"),
|
||||
"idempotent": idempotent,
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
"""Strict service-level exact-path policy for isolated Git writers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
MAX_POLICY_BYTES = 65_536
|
||||
POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"policy_id",
|
||||
"audience",
|
||||
"repository_identity",
|
||||
"allowed_branch",
|
||||
"allowed_operations",
|
||||
}
|
||||
)
|
||||
OPERATION_FIELDS = frozenset({"exact_paths", "max_content_bytes", "content_profile"})
|
||||
SUPPORTED_OPERATIONS = frozenset({"commit_file.atomic"})
|
||||
SUPPORTED_CONTENT_PROFILES = frozenset({"systemd-codex-review-worker-v1"})
|
||||
_SECRET_RE = re.compile(
|
||||
r"(?i)(authorization\s*:|bearer\s+|(?:token|password|secret)\s*[:=])"
|
||||
)
|
||||
_SYSTEMD_NAME_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*\Z")
|
||||
_SYSTEMD_MAX_LINE_BYTES = 4096
|
||||
_SYSTEMD_ALLOWED_DIRECTIVES = {
|
||||
"Unit": frozenset({"Description", "After", "Wants"}),
|
||||
"Service": frozenset(
|
||||
{
|
||||
"Type",
|
||||
"User",
|
||||
"Group",
|
||||
"WorkingDirectory",
|
||||
"Environment",
|
||||
"ExecStart",
|
||||
"Restart",
|
||||
"RestartSec",
|
||||
"NoNewPrivileges",
|
||||
"PrivateTmp",
|
||||
"PrivateDevices",
|
||||
"ProtectSystem",
|
||||
"ProtectHome",
|
||||
"ProtectKernelTunables",
|
||||
"ProtectKernelModules",
|
||||
"ProtectControlGroups",
|
||||
"LockPersonality",
|
||||
"RestrictSUIDSGID",
|
||||
"RestrictRealtime",
|
||||
"MemoryDenyWriteExecute",
|
||||
"RuntimeDirectory",
|
||||
"StateDirectory",
|
||||
"CacheDirectory",
|
||||
"ReadOnlyPaths",
|
||||
"ReadWritePaths",
|
||||
"UMask",
|
||||
}
|
||||
),
|
||||
"Install": frozenset({"WantedBy"}),
|
||||
}
|
||||
_SYSTEMD_MULTI_DIRECTIVES = frozenset(
|
||||
{
|
||||
("Service", "Environment"),
|
||||
("Service", "ReadOnlyPaths"),
|
||||
("Service", "ReadWritePaths"),
|
||||
}
|
||||
)
|
||||
_SYSTEMD_EXACT_SINGLE_VALUES = {
|
||||
("Unit", "Description"): "AIOS Codex Review/Test Worker",
|
||||
("Unit", "After"): "network-online.target",
|
||||
("Unit", "Wants"): "network-online.target",
|
||||
("Service", "Type"): "simple",
|
||||
("Service", "User"): "aiman",
|
||||
("Service", "Group"): "gitops",
|
||||
(
|
||||
"Service",
|
||||
"WorkingDirectory",
|
||||
): "/usr/libexec/aios-codex-review-test-worker/current",
|
||||
(
|
||||
"Service",
|
||||
"ExecStart",
|
||||
): "/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
("Service", "Restart"): "on-failure",
|
||||
("Service", "RestartSec"): "2",
|
||||
("Service", "NoNewPrivileges"): "true",
|
||||
("Service", "PrivateTmp"): "true",
|
||||
("Service", "PrivateDevices"): "true",
|
||||
("Service", "ProtectSystem"): "strict",
|
||||
("Service", "ProtectHome"): "true",
|
||||
("Service", "ProtectKernelTunables"): "true",
|
||||
("Service", "ProtectKernelModules"): "true",
|
||||
("Service", "ProtectControlGroups"): "true",
|
||||
("Service", "LockPersonality"): "true",
|
||||
("Service", "RestrictSUIDSGID"): "true",
|
||||
("Service", "RestrictRealtime"): "true",
|
||||
("Service", "MemoryDenyWriteExecute"): "true",
|
||||
("Service", "RuntimeDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "StateDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "CacheDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "UMask"): "0077",
|
||||
("Install", "WantedBy"): "multi-user.target",
|
||||
}
|
||||
_SYSTEMD_EXACT_MULTI_VALUES = {
|
||||
("Service", "Environment"): (
|
||||
"AIOS_CODEX_WORKER_MODE=shadow",
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=false",
|
||||
"PYTHONDONTWRITEBYTECODE=1",
|
||||
),
|
||||
("Service", "ReadOnlyPaths"): (
|
||||
"/data/aios-gitops/repo.git",
|
||||
"/usr/libexec/aios-codex-review-test-worker",
|
||||
),
|
||||
("Service", "ReadWritePaths"): (
|
||||
"/run/aios-codex-review-test-worker",
|
||||
"/var/lib/aios-codex-review-test-worker",
|
||||
"/var/cache/aios-codex-review-test-worker",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PathPolicyError(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationPolicy:
|
||||
exact_paths: tuple[str, ...]
|
||||
max_content_bytes: int
|
||||
content_profile: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServicePathPolicy:
|
||||
schema_version: int
|
||||
policy_id: str
|
||||
audience: str
|
||||
repository_identity: str
|
||||
allowed_branch: str
|
||||
allowed_operations: dict[str, OperationPolicy]
|
||||
sha256: str
|
||||
|
||||
def operation(self, name: str) -> OperationPolicy:
|
||||
try:
|
||||
return self.allowed_operations[name]
|
||||
except KeyError:
|
||||
raise PathPolicyError("isolated_writer_service_operation_rejected") from None
|
||||
|
||||
def require_exact_path(self, operation: str, path: str) -> OperationPolicy:
|
||||
selected = self.operation(operation)
|
||||
if path not in selected.exact_paths:
|
||||
raise PathPolicyError("isolated_writer_service_path_rejected")
|
||||
return selected
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def validate_relative_path_syntax(path: str) -> str:
|
||||
if not isinstance(path, str):
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
if not path or path != path.strip() or "\x00" in path or "\\" in path:
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
try:
|
||||
decoded = unquote(path, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise PathPolicyError("isolated_writer_path_rejected") from None
|
||||
if decoded != path:
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
posix = PurePosixPath(path)
|
||||
windows = PureWindowsPath(path)
|
||||
parts = path.split("/")
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
return path
|
||||
|
||||
|
||||
def _require_fields(value: dict[str, Any], allowed: frozenset[str], code: str) -> None:
|
||||
if set(value) != allowed:
|
||||
raise PathPolicyError(code)
|
||||
|
||||
|
||||
def _parse_policy(document: Any, canonical: bytes) -> ServicePathPolicy:
|
||||
if not isinstance(document, dict):
|
||||
raise PathPolicyError("isolated_writer_path_policy_object_required")
|
||||
_require_fields(document, POLICY_FIELDS, "isolated_writer_path_policy_fields_rejected")
|
||||
if document["schema_version"] != 1:
|
||||
raise PathPolicyError("isolated_writer_path_policy_schema_rejected")
|
||||
text_fields = (
|
||||
"policy_id",
|
||||
"audience",
|
||||
"repository_identity",
|
||||
"allowed_branch",
|
||||
)
|
||||
if any(
|
||||
not isinstance(document[field], str) or not document[field].strip()
|
||||
for field in text_fields
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_value_rejected")
|
||||
raw_operations = document["allowed_operations"]
|
||||
if not isinstance(raw_operations, dict) or not raw_operations:
|
||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
||||
if not set(raw_operations).issubset(SUPPORTED_OPERATIONS):
|
||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
||||
operations: dict[str, OperationPolicy] = {}
|
||||
for name, raw in raw_operations.items():
|
||||
if not isinstance(raw, dict):
|
||||
raise PathPolicyError("isolated_writer_path_policy_operation_rejected")
|
||||
_require_fields(
|
||||
raw,
|
||||
OPERATION_FIELDS,
|
||||
"isolated_writer_path_policy_operation_fields_rejected",
|
||||
)
|
||||
paths = raw["exact_paths"]
|
||||
if (
|
||||
not isinstance(paths, list)
|
||||
or not paths
|
||||
or any(not isinstance(item, str) for item in paths)
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
||||
validated = tuple(validate_relative_path_syntax(item) for item in paths)
|
||||
if len(set(validated)) != len(validated):
|
||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
||||
maximum = raw["max_content_bytes"]
|
||||
if not isinstance(maximum, int) or isinstance(maximum, bool) or not 1 <= maximum <= 1_048_576:
|
||||
raise PathPolicyError("isolated_writer_path_policy_size_rejected")
|
||||
profile = raw["content_profile"]
|
||||
if profile not in SUPPORTED_CONTENT_PROFILES:
|
||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
||||
operations[name] = OperationPolicy(validated, maximum, profile)
|
||||
return ServicePathPolicy(
|
||||
schema_version=1,
|
||||
policy_id=document["policy_id"],
|
||||
audience=document["audience"],
|
||||
repository_identity=document["repository_identity"],
|
||||
allowed_branch=document["allowed_branch"],
|
||||
allowed_operations=operations,
|
||||
sha256=hashlib.sha256(canonical).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def load_path_policy(
|
||||
path: Path,
|
||||
*,
|
||||
expected_owner_uid: int = 0,
|
||||
expected_group_gid: int | None = None,
|
||||
expected_parent_mode: int = 0o750,
|
||||
expected_file_mode: int = 0o640,
|
||||
) -> ServicePathPolicy:
|
||||
lexical = Path(os.path.abspath(str(path)))
|
||||
if lexical.is_symlink():
|
||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
||||
try:
|
||||
parent_info = lexical.parent.stat(follow_symlinks=False)
|
||||
file_info = lexical.stat(follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
raise PathPolicyError("isolated_writer_path_policy_missing") from None
|
||||
if stat.S_ISLNK(parent_info.st_mode) or not stat.S_ISDIR(parent_info.st_mode):
|
||||
raise PathPolicyError("isolated_writer_path_policy_parent_rejected")
|
||||
if stat.S_ISLNK(file_info.st_mode) or not stat.S_ISREG(file_info.st_mode):
|
||||
raise PathPolicyError("isolated_writer_path_policy_file_rejected")
|
||||
if lexical.resolve(strict=True) != lexical:
|
||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
||||
if (
|
||||
parent_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(parent_info.st_mode) != expected_parent_mode
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_parent_permissions_rejected")
|
||||
if (
|
||||
file_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(file_info.st_mode) != expected_file_mode
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_permissions_rejected")
|
||||
if expected_group_gid is not None and (
|
||||
parent_info.st_gid != expected_group_gid or file_info.st_gid != expected_group_gid
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected")
|
||||
if file_info.st_size <= 0 or file_info.st_size > MAX_POLICY_BYTES:
|
||||
raise PathPolicyError("isolated_writer_path_policy_file_size_rejected")
|
||||
raw = lexical.read_bytes()
|
||||
try:
|
||||
document = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise PathPolicyError("isolated_writer_path_policy_json_rejected") from None
|
||||
canonical = _canonical_json(document)
|
||||
if raw != canonical:
|
||||
raise PathPolicyError("isolated_writer_path_policy_not_canonical")
|
||||
return _parse_policy(document, canonical)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def configured_path_policy() -> ServicePathPolicy | None:
|
||||
configured = os.getenv("AIOS_GITOPS_PATH_POLICY_FILE", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
if not Path(configured).is_absolute():
|
||||
raise PathPolicyError("isolated_writer_path_policy_path_rejected")
|
||||
try:
|
||||
gitops_gid = grp.getgrnam("gitops").gr_gid
|
||||
except KeyError:
|
||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected") from None
|
||||
loaded = load_path_policy(Path(configured), expected_group_gid=gitops_gid)
|
||||
if loaded.audience != os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_audience_rejected")
|
||||
if loaded.repository_identity != os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_repository_rejected")
|
||||
if loaded.allowed_branch != os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_branch_rejected")
|
||||
return loaded
|
||||
|
||||
|
||||
def clear_path_policy_cache() -> None:
|
||||
configured_path_policy.cache_clear()
|
||||
|
||||
|
||||
def require_service_path(operation: str, path: str) -> OperationPolicy:
|
||||
loaded = configured_path_policy()
|
||||
if loaded is None:
|
||||
raise PathPolicyError("isolated_writer_service_path_policy_required")
|
||||
return loaded.require_exact_path(operation, path)
|
||||
|
||||
|
||||
def configured_path_policy_sha256() -> str | None:
|
||||
loaded = configured_path_policy()
|
||||
return None if loaded is None else loaded.sha256
|
||||
|
||||
|
||||
def validate_content(operation: str, path: str, content: str) -> None:
|
||||
selected = require_service_path(operation, path)
|
||||
try:
|
||||
encoded = content.encode("utf-8", errors="strict")
|
||||
except (AttributeError, UnicodeEncodeError):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
||||
if len(encoded) > selected.max_content_bytes:
|
||||
raise PathPolicyError("isolated_writer_service_content_too_large")
|
||||
if selected.content_profile == "systemd-codex-review-worker-v1":
|
||||
_validate_systemd_codex_review_worker(content)
|
||||
return
|
||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
||||
|
||||
|
||||
def _validate_systemd_codex_review_worker(content: str) -> None:
|
||||
if not isinstance(content, str):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
try:
|
||||
content.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
||||
if (
|
||||
"\x00" in content
|
||||
or "\r" in content
|
||||
or _SECRET_RE.search(content)
|
||||
or not content.endswith("\n")
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
|
||||
sections: set[str] = set()
|
||||
values: dict[tuple[str, str], list[str]] = {}
|
||||
section: str | None = None
|
||||
for line in content.splitlines():
|
||||
if len(line.encode("utf-8")) > _SYSTEMD_MAX_LINE_BYTES:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if not line:
|
||||
continue
|
||||
if line[0].isspace() or line.startswith(("#", ";")):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
candidate = line[1:-1]
|
||||
if (
|
||||
not _SYSTEMD_NAME_RE.fullmatch(candidate)
|
||||
or candidate not in _SYSTEMD_ALLOWED_DIRECTIVES
|
||||
or candidate in sections
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
sections.add(candidate)
|
||||
section = candidate
|
||||
continue
|
||||
if section is None or "=" not in line:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
directive, value = line.split("=", 1)
|
||||
if (
|
||||
not _SYSTEMD_NAME_RE.fullmatch(directive)
|
||||
or directive not in _SYSTEMD_ALLOWED_DIRECTIVES[section]
|
||||
or not value
|
||||
or value != value.strip()
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
key = (section, directive)
|
||||
if key in values and key not in _SYSTEMD_MULTI_DIRECTIVES:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
values.setdefault(key, []).append(value)
|
||||
|
||||
if sections != set(_SYSTEMD_ALLOWED_DIRECTIVES):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if set(values) != set(_SYSTEMD_EXACT_SINGLE_VALUES) | set(
|
||||
_SYSTEMD_EXACT_MULTI_VALUES
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
for key, expected in _SYSTEMD_EXACT_SINGLE_VALUES.items():
|
||||
if values.get(key) != [expected]:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
for key, expected in _SYSTEMD_EXACT_MULTI_VALUES.items():
|
||||
observed = values.get(key, [])
|
||||
if len(observed) != len(set(observed)) or tuple(observed) != expected:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Digest-only, task-scoped token registry for isolated writers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import grp
|
||||
import pwd
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REGISTRY_INIT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class TokenRegistry:
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self._prepare()
|
||||
|
||||
def _prepare(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
||||
raise PermissionError("writer_token_registry_symlink_rejected")
|
||||
with _REGISTRY_INIT_LOCK:
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS token_grants (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
token_digest TEXT NOT NULL UNIQUE,
|
||||
grant_json TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS token_uses (
|
||||
token_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(token_id, operation),
|
||||
FOREIGN KEY(token_id) REFERENCES token_grants(token_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
return db
|
||||
|
||||
def issue(self, grant: dict[str, Any], token: str | None = None) -> tuple[str, dict[str, Any]]:
|
||||
value = token or secrets.token_urlsafe(32)
|
||||
if len(value.encode("utf-8")) < 32:
|
||||
raise ValueError("writer_token_entropy_insufficient")
|
||||
record = dict(grant)
|
||||
record["schema_version"] = 2
|
||||
record["token_digest"] = token_digest(value)
|
||||
record["revoked"] = False
|
||||
record.setdefault("created_at", utc_now())
|
||||
required = (
|
||||
"token_id",
|
||||
"audience",
|
||||
"task_id",
|
||||
"source_cidrs",
|
||||
"allowed_branch",
|
||||
"approved_base_oid",
|
||||
"allowed_operations",
|
||||
"allowed_paths",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"max_uses",
|
||||
"issued_by",
|
||||
)
|
||||
if any(not record.get(field) for field in required):
|
||||
raise ValueError("writer_token_grant_incomplete")
|
||||
encoded = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO token_grants(token_id, token_digest, grant_json, revoked, created_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
str(record["token_id"]),
|
||||
str(record["token_digest"]),
|
||||
encoded,
|
||||
0,
|
||||
str(record["created_at"]),
|
||||
),
|
||||
)
|
||||
db.execute("COMMIT")
|
||||
except Exception:
|
||||
db.execute("ROLLBACK")
|
||||
raise
|
||||
return value, record
|
||||
|
||||
def find(self, token: str) -> dict[str, Any] | None:
|
||||
candidate = token_digest(token)
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT token_digest, grant_json, revoked FROM token_grants"
|
||||
).fetchall()
|
||||
match = None
|
||||
for row in rows:
|
||||
if hmac.compare_digest(str(row["token_digest"]), candidate):
|
||||
match = dict(json.loads(str(row["grant_json"])))
|
||||
match["revoked"] = bool(row["revoked"])
|
||||
return match
|
||||
|
||||
def consume(self, token_id: str, operation: str, maximum: int) -> int:
|
||||
if maximum < 1:
|
||||
raise PermissionError("writer_token_operation_exhausted")
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT revoked FROM token_grants WHERE token_id=?",
|
||||
(token_id,),
|
||||
).fetchone()
|
||||
if row is None or bool(row["revoked"]):
|
||||
raise PermissionError("writer_token_revoked")
|
||||
used_row = db.execute(
|
||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
||||
(token_id, operation),
|
||||
).fetchone()
|
||||
used = int(used_row["use_count"]) if used_row else 0
|
||||
if used >= maximum:
|
||||
raise PermissionError("writer_token_operation_exhausted")
|
||||
current = used + 1
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO token_uses(token_id, operation, use_count)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(token_id, operation)
|
||||
DO UPDATE SET use_count=excluded.use_count
|
||||
""",
|
||||
(token_id, operation, current),
|
||||
)
|
||||
db.execute("COMMIT")
|
||||
return current
|
||||
except Exception:
|
||||
db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def revoke(self, token_id: str) -> bool:
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
changed = db.execute(
|
||||
"UPDATE token_grants SET revoked=1 WHERE token_id=? AND revoked=0",
|
||||
(token_id,),
|
||||
).rowcount
|
||||
db.execute("COMMIT")
|
||||
return bool(changed)
|
||||
|
||||
def usage(self, token_id: str, operation: str) -> int:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
||||
(token_id, operation),
|
||||
).fetchone()
|
||||
return int(row["use_count"]) if row else 0
|
||||
|
||||
|
||||
def _write_secret_file(
|
||||
path: Path,
|
||||
token: str,
|
||||
*,
|
||||
owner: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise PermissionError("writer_token_file_symlink_rejected")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o600)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise PermissionError("writer_token_file_not_regular")
|
||||
if owner is not None or group is not None:
|
||||
uid = pwd.getpwnam(owner).pw_uid if owner is not None else -1
|
||||
gid = grp.getgrnam(group).gr_gid if group is not None else -1
|
||||
os.fchown(fd, uid, gid)
|
||||
os.write(fd, token.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--registry", required=True)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
issue = sub.add_parser("issue")
|
||||
issue.add_argument("--grant-file", required=True)
|
||||
issue.add_argument("--token-file", required=True)
|
||||
issue.add_argument("--owner")
|
||||
issue.add_argument("--group")
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("--token-id", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
registry = TokenRegistry(args.registry)
|
||||
if args.command == "issue":
|
||||
grant = json.loads(Path(args.grant_file).read_text(encoding="utf-8"))
|
||||
token, record = registry.issue(grant)
|
||||
_write_secret_file(
|
||||
Path(args.token_file),
|
||||
token,
|
||||
owner=args.owner,
|
||||
group=args.group,
|
||||
)
|
||||
print(json.dumps({"token_id": record["token_id"], "created": True}, sort_keys=True))
|
||||
return 0
|
||||
changed = registry.revoke(args.token_id)
|
||||
print(json.dumps({"token_id": args.token_id, "revoked": changed}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Isolated, Shadow-attested Codex Task Plane Worker components."""
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Shadow-only Codex read/review/test worker lifecycle and CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .result_attestation import TaskResultEnvelope, TrustPolicy, task_result_envelope
|
||||
from .task_client import CredentialSource, StageAClientConfig, TaskPlaneClient
|
||||
|
||||
MAX_CONCURRENCY = 1
|
||||
HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
CLAIM_LEASE_SECONDS = 120
|
||||
SHADOW_ONLY = True
|
||||
DEFAULT_TRUST_LEVEL = TrustPolicy.SHADOW_ATTESTED
|
||||
DEFAULT_PRODUCTION_TRUST = False
|
||||
READONLY_CAPABILITIES = ("code.read", "test.python", "review.security")
|
||||
|
||||
|
||||
class CodexReviewTaskError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexReviewTaskPolicy:
|
||||
worker_id: str
|
||||
identity: str
|
||||
trust_zone: str = "isolated-review"
|
||||
max_concurrency: int = MAX_CONCURRENCY
|
||||
heartbeat_interval_seconds: int = HEARTBEAT_INTERVAL_SECONDS
|
||||
claim_lease_seconds: int = CLAIM_LEASE_SECONDS
|
||||
shadow_only: bool = SHADOW_ONLY
|
||||
|
||||
def registration_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"worker_id": self.worker_id, "worker_type": "codex-review",
|
||||
"identity": self.identity, "owner_identity": self.identity,
|
||||
"capabilities": list(READONLY_CAPABILITIES), "trust_zone": self.trust_zone,
|
||||
"max_concurrency": self.max_concurrency,
|
||||
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
|
||||
"supported_protocols": ["aios-task-v1"], "registration_policy": "self",
|
||||
}
|
||||
|
||||
def assert_shadow_only_task(self, task: dict[str, Any]) -> None:
|
||||
plan = task.get("plan", task)
|
||||
metadata = plan.get("metadata", {}) if isinstance(plan, dict) else {}
|
||||
required = set(plan.get("required_capabilities", [])) if isinstance(plan, dict) else set()
|
||||
if self.max_concurrency != 1 or self.heartbeat_interval_seconds != 15 or self.claim_lease_seconds != 120:
|
||||
raise CodexReviewTaskError("worker timing/concurrency contract rejected")
|
||||
if not isinstance(plan, dict) or metadata.get("production_side_effects") is not False:
|
||||
raise CodexReviewTaskError("task must declare production_side_effects=false")
|
||||
if plan.get("risk") != "low" or plan.get("task_type") not in {"code.review", "test.execute", None}:
|
||||
raise CodexReviewTaskError("task risk or type rejected")
|
||||
if plan.get("data_classification", "internal") not in {"public", "internal"}:
|
||||
raise CodexReviewTaskError("task classification rejected")
|
||||
if not required.issubset(set(READONLY_CAPABILITIES)):
|
||||
raise CodexReviewTaskError("task capability rejected")
|
||||
if any(value.startswith(("code.write", "deploy", "secret", "production")) for value in required):
|
||||
raise CodexReviewTaskError("side-effect capability rejected")
|
||||
|
||||
def default_result_envelope(self, *, task_id: str, claim_id: str, worker_identity: str) -> TaskResultEnvelope:
|
||||
return task_result_envelope(
|
||||
task_id=task_id, claim_id=claim_id, worker_id=self.worker_id,
|
||||
worker_identity=worker_identity, status="CREATED", outputs=[], evidence=[],
|
||||
started_at="1970-01-01T00:00:00Z", completed_at="1970-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
class _LeaseGuard:
|
||||
def __init__(self, client: TaskPlaneClient, task_id: str, claim_id: str) -> None:
|
||||
self.client, self.task_id, self.claim_id = client, task_id, claim_id
|
||||
self.stop_event = threading.Event()
|
||||
self.lost = threading.Event()
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self.stop_event.wait(HEARTBEAT_INTERVAL_SECONDS):
|
||||
try:
|
||||
self.client.claim_heartbeat(self.task_id, claim_id=self.claim_id, lease_seconds=CLAIM_LEASE_SECONDS)
|
||||
except Exception:
|
||||
self.lost.set()
|
||||
self.stop_event.set()
|
||||
|
||||
def __enter__(self) -> "_LeaseGuard":
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.stop_event.set()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def require_active(self) -> None:
|
||||
if self.lost.is_set():
|
||||
raise CodexReviewTaskError("claim lease lost")
|
||||
|
||||
|
||||
class CodexReviewWorker:
|
||||
def __init__(self, client: TaskPlaneClient, policy: CodexReviewTaskPolicy, source_commit: str) -> None:
|
||||
self.client, self.policy, self.source_commit = client, policy, source_commit
|
||||
|
||||
def register(self) -> dict[str, Any]:
|
||||
return self.client.register_worker(**{
|
||||
"worker_id": self.policy.worker_id, "worker_type": "codex-review",
|
||||
"identity": self.policy.identity, "capabilities": READONLY_CAPABILITIES,
|
||||
"trust_zone": self.policy.trust_zone, "max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
})
|
||||
|
||||
def poll_safe_task(self) -> dict[str, Any] | None:
|
||||
for task in self.client.list_tasks():
|
||||
if task.get("status") not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
continue
|
||||
try:
|
||||
full = self.client.get_task(str(task["task_id"]))
|
||||
self.policy.assert_shadow_only_task(full)
|
||||
return full
|
||||
except (KeyError, CodexReviewTaskError):
|
||||
continue
|
||||
return None
|
||||
|
||||
def run_once(self, executor: Callable[[dict[str, Any], Callable[[float, str], None]], tuple[str, list[dict[str, Any]], list[dict[str, Any]]]]) -> str | None:
|
||||
self.register()
|
||||
task = self.poll_safe_task()
|
||||
if task is None:
|
||||
return None
|
||||
task_id = str(task["task_id"])
|
||||
claim = self.client.claim(task_id, worker_id=self.policy.worker_id, lease_seconds=120)
|
||||
claim_id = str(claim["claim_id"])
|
||||
started = datetime.now(timezone.utc).isoformat()
|
||||
with _LeaseGuard(self.client, task_id, claim_id) as guard:
|
||||
def emit(value: float, stage: str) -> None:
|
||||
guard.require_active()
|
||||
self.client.progress(task_id, claim_id=claim_id, progress=value, stage=stage)
|
||||
|
||||
summary, outputs, evidence = executor(task, emit)
|
||||
guard.require_active()
|
||||
completed = datetime.now(timezone.utc).isoformat()
|
||||
envelope = task_result_envelope(
|
||||
task_id=task_id, claim_id=claim_id,
|
||||
task_revision=int(claim["task_revision"]),
|
||||
worker_id=self.policy.worker_id, worker_identity=self.policy.identity,
|
||||
operation_digest=str(claim["operation_digest"]),
|
||||
source_commit=self.source_commit, status="COMPLETED", summary=summary,
|
||||
outputs=outputs, evidence=evidence, started_at=started, completed_at=completed,
|
||||
)
|
||||
guard.require_active()
|
||||
envelope_payload = envelope.to_dict()
|
||||
self.client.result(
|
||||
task_id,
|
||||
claim_id=claim_id,
|
||||
status=str(envelope_payload["status"]),
|
||||
outputs=list(envelope_payload["outputs"]),
|
||||
evidence=list(envelope_payload["evidence"]),
|
||||
started_at=str(envelope_payload["started_at"]),
|
||||
completed_at=str(envelope_payload["completed_at"]),
|
||||
trust_level=TrustPolicy.SHADOW_ATTESTED.value,
|
||||
production_trust=False,
|
||||
)
|
||||
return task_id
|
||||
|
||||
|
||||
def build_default_task_policy(worker_id: str, worker_identity: str) -> CodexReviewTaskPolicy:
|
||||
return CodexReviewTaskPolicy(worker_id=worker_id, identity=worker_identity)
|
||||
|
||||
|
||||
def make_shadow_result_payload(*, task_id: str, claim_id: str, worker_identity: str) -> TaskResultEnvelope:
|
||||
return task_result_envelope(
|
||||
task_id=task_id, claim_id=claim_id, worker_identity=worker_identity,
|
||||
status="COMPLETED", outputs=[], evidence=[],
|
||||
started_at="1970-01-01T00:00:00Z", completed_at="1970-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
def _assert_disabled_production() -> None:
|
||||
for name in ("AIOS_GITOPS_WRITES_ENABLED", "AIOS_SILENT_RELEASE_EXECUTION_ENABLED", "AIOS_TASK_PLANE_EXECUTION_ENABLED"):
|
||||
if os.environ.get(name, "false").lower() != "false":
|
||||
raise CodexReviewTaskError(f"{name} must remain false")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--self-check", action="store_true")
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
_assert_disabled_production()
|
||||
config = StageAClientConfig(os.environ.get("AIOS_TASK_PLANE_ENDPOINT", "http://127.0.0.1:18923"))
|
||||
if args.self_check:
|
||||
print(json.dumps({"status": "PASS", "shadow_only": True, "production_trust": False}, sort_keys=True))
|
||||
return 0
|
||||
if os.environ.get("AIOS_CODEX_WORKER_SHADOW_CANARY_ENABLED", "false").lower() != "true":
|
||||
raise SystemExit("Codex worker execution is disabled")
|
||||
credential = CredentialSource(Path(os.environ["AIOS_TASK_PLANE_WORKER_TOKEN_FILE"])).read_token()
|
||||
source_commit = os.environ.get("AIOS_CODEX_WORKER_SOURCE_COMMIT", "")
|
||||
policy = CodexReviewTaskPolicy(
|
||||
worker_id=os.environ["AIOS_CODEX_WORKER_ID"],
|
||||
identity=os.environ["AIOS_CODEX_WORKER_IDENTITY"],
|
||||
)
|
||||
worker = CodexReviewWorker(TaskPlaneClient(config, credential), policy, source_commit)
|
||||
if args.once:
|
||||
worker.run_once(lambda _task, emit: (emit(100, "VERIFIED") or ("shadow self-check", [], [])))
|
||||
return 0
|
||||
raise SystemExit("continuous real worker execution is not enabled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Fail-closed command and environment policy for the isolated Codex Worker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
DENIED_ENV = ("TOKEN", "PASSWORD", "SECRET", "CREDENTIAL", "AUTHORIZATION", "COOKIE", "PRIVATE_KEY", "AWS_", "GITHUB_", "GITEA_")
|
||||
|
||||
|
||||
class CommandPolicyError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def isolated_environment(home: Path, temp_root: Path, pythonpath: str) -> dict[str, str]:
|
||||
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temp_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
allowed = {key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"}}
|
||||
allowed.update({"HOME": str(home), "TMPDIR": str(temp_root), "PYTHONPATH": pythonpath, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
if any(any(marker in key.upper() for marker in DENIED_ENV) for key in allowed):
|
||||
raise CommandPolicyError("secret environment retained")
|
||||
return allowed
|
||||
|
||||
|
||||
class CommandPolicy:
|
||||
def __init__(self, repo: Path, result_root: Path, *, timeout_seconds: int = 300, output_limit: int = 1_000_000):
|
||||
self.repo = repo.resolve()
|
||||
self.result_root = result_root.resolve()
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.output_limit = output_limit
|
||||
|
||||
def _oid(self, value: str) -> str:
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise CommandPolicyError("exact commit oid required")
|
||||
return value
|
||||
|
||||
def build(self, kind: str, *, oid: str, paths: list[str] | None = None, output: Path | None = None) -> list[str]:
|
||||
oid = self._oid(oid)
|
||||
paths = paths or []
|
||||
if any(not path or path.startswith("-") or Path(path).is_absolute() or ".." in Path(path).parts for path in paths):
|
||||
raise CommandPolicyError("invalid allowlisted path")
|
||||
if kind == "diff-check":
|
||||
return ["git", "-C", str(self.repo), "diff", "--check"]
|
||||
if kind == "git-show" and len(paths) == 1:
|
||||
return ["git", "-C", str(self.repo), "show", f"{oid}:{paths[0]}"]
|
||||
if kind == "pytest" and paths and all(path.startswith("tests/task_plane/") or path == "tests/test_isolated_writer_policy.py" for path in paths):
|
||||
return ["python3", "-m", "pytest", "-q", *paths]
|
||||
if kind == "compileall" and paths and all(path.startswith(("active-runtime/app/task_plane", "runtime/agent-os")) for path in paths):
|
||||
return ["python3", "-m", "compileall", "-q", *paths]
|
||||
if kind == "acceptance" and output is not None:
|
||||
target = output.resolve()
|
||||
if target.parent != self.result_root:
|
||||
raise CommandPolicyError("output escapes result root")
|
||||
return ["bash", "scripts/task-plane/run_task_plane_acceptance.sh", str(target)]
|
||||
raise CommandPolicyError("command template not allowlisted")
|
||||
|
||||
def run(self, argv: list[str], env: dict[str, str]) -> tuple[int, str]:
|
||||
completed = subprocess.run(argv, cwd=self.repo, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=self.timeout_seconds, start_new_session=True, check=False)
|
||||
output = completed.stdout[: self.output_limit]
|
||||
return completed.returncode, output
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Bounded progress payload helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressEnvelope:
|
||||
claim_id: str
|
||||
progress: int | float
|
||||
stage: str
|
||||
lease_seconds: int = 120
|
||||
message: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.claim_id:
|
||||
raise ValueError("claim_id is required")
|
||||
numeric = float(self.progress)
|
||||
if not math.isfinite(numeric) or not 0 <= numeric <= 100:
|
||||
raise ValueError("progress must be finite and within [0, 100]")
|
||||
if not 1 <= self.lease_seconds <= 3600:
|
||||
raise ValueError("lease_seconds out of range")
|
||||
if not self.stage or len(self.stage) > 64 or any(ord(ch) < 32 for ch in self.stage):
|
||||
raise ValueError("stage is invalid")
|
||||
if self.message is not None and len(self.message) > 512:
|
||||
raise ValueError("message is too long")
|
||||
|
||||
def as_request(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
return {
|
||||
"claim_id": self.claim_id, "progress": float(self.progress),
|
||||
"stage": self.stage, "lease_seconds": self.lease_seconds,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
def clamp_progress(value: float) -> float:
|
||||
numeric = float(value)
|
||||
if not math.isfinite(numeric):
|
||||
raise ValueError("progress must be finite")
|
||||
return max(0.0, min(100.0, numeric))
|
||||
|
||||
|
||||
def safe_stage_name(stage: str | None) -> str:
|
||||
value = str(stage or "").strip()
|
||||
if not value:
|
||||
return "RUNNING"
|
||||
if any(ord(ch) < 32 for ch in value):
|
||||
raise ValueError("stage is invalid")
|
||||
return value[:64]
|
||||
|
||||
|
||||
def build_progress_payload(*, claim_id: str, progress: float, stage: str = "RUNNING", lease_seconds: int = 120, message: str | None = None) -> dict[str, Any]:
|
||||
return ProgressEnvelope(claim_id, clamp_progress(progress), safe_stage_name(stage), lease_seconds, message).as_request()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Canonical, Shadow-only result envelopes for the Codex review worker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class TrustPolicy(StrEnum):
|
||||
SHADOW_ATTESTED = "SHADOW_ATTESTED"
|
||||
SHADOW_UNATTESTED = "SHADOW_UNATTESTED"
|
||||
|
||||
|
||||
def _canonical_sha256(payload: object) -> str:
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskResultEnvelope:
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_identity: str
|
||||
status: str
|
||||
outputs: list[dict[str, Any]]
|
||||
evidence: list[dict[str, Any]]
|
||||
started_at: str
|
||||
completed_at: str
|
||||
task_revision: int = 1
|
||||
worker_id: str = ""
|
||||
operation_digest: str = ""
|
||||
source_commit: str = "0" * 40
|
||||
summary: str = ""
|
||||
evidence_digests: tuple[str, ...] = ()
|
||||
result_digest: str | None = None
|
||||
trust_level: TrustPolicy = TrustPolicy.SHADOW_ATTESTED
|
||||
production_trust: bool = False
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
|
||||
def digest_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"task_revision": self.task_revision,
|
||||
"worker_id": self.worker_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"operation_digest": self.operation_digest,
|
||||
"source_commit": self.source_commit,
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"outputs": self.outputs,
|
||||
"evidence": self.evidence,
|
||||
"evidence_digests": list(self.evidence_digests),
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
"trust_level": TrustPolicy.SHADOW_ATTESTED.value,
|
||||
"production_trust": False,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
}
|
||||
|
||||
def computed_digest(self) -> str:
|
||||
return _canonical_sha256(self.digest_payload())
|
||||
|
||||
def validate(self) -> None:
|
||||
required = {
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"worker_id": self.worker_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"operation_digest": self.operation_digest,
|
||||
"status": self.status,
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
}
|
||||
if any(not str(value).strip() for value in required.values()):
|
||||
raise ValueError("result envelope required field missing")
|
||||
if self.task_revision < 1:
|
||||
raise ValueError("task_revision must be positive")
|
||||
if not OID_RE.fullmatch(self.source_commit):
|
||||
raise ValueError("source_commit must be a full commit OID")
|
||||
if not SHA256_RE.fullmatch(self.operation_digest):
|
||||
raise ValueError("operation_digest must be SHA-256")
|
||||
if any(not SHA256_RE.fullmatch(value) for value in self.evidence_digests):
|
||||
raise ValueError("evidence digest must be SHA-256")
|
||||
observed = tuple(_canonical_sha256(item) for item in self.evidence)
|
||||
if observed != self.evidence_digests:
|
||||
raise ValueError("evidence digest binding mismatch")
|
||||
if self.trust_level is not TrustPolicy.SHADOW_ATTESTED:
|
||||
raise ValueError("Codex review results must remain SHADOW_ATTESTED")
|
||||
if self.production_trust is not False:
|
||||
raise ValueError("production_trust is forbidden")
|
||||
if self.result_digest is not None and not hmac.compare_digest(
|
||||
self.result_digest, self.computed_digest()
|
||||
):
|
||||
raise ValueError("result digest mismatch")
|
||||
|
||||
def finalized(self) -> "TaskResultEnvelope":
|
||||
from dataclasses import replace
|
||||
|
||||
envelope = replace(self, result_digest=self.computed_digest())
|
||||
envelope.validate()
|
||||
return envelope
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
envelope = self if self.result_digest is not None else self.finalized()
|
||||
envelope.validate()
|
||||
return {**envelope.digest_payload(), "result_digest": envelope.result_digest}
|
||||
|
||||
|
||||
def task_result_envelope(
|
||||
*,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
worker_identity: str,
|
||||
status: str,
|
||||
outputs: list[dict[str, Any]],
|
||||
evidence: list[dict[str, Any]],
|
||||
started_at: str,
|
||||
completed_at: str,
|
||||
task_revision: int = 1,
|
||||
worker_id: str | None = None,
|
||||
operation_digest: str | None = None,
|
||||
source_commit: str = "0" * 40,
|
||||
summary: str = "",
|
||||
trust_level: TrustPolicy = TrustPolicy.SHADOW_ATTESTED,
|
||||
production_trust: bool = False,
|
||||
) -> TaskResultEnvelope:
|
||||
evidence_digests = tuple(_canonical_sha256(item) for item in evidence)
|
||||
envelope = TaskResultEnvelope(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
task_revision=task_revision,
|
||||
worker_id=worker_id or worker_identity,
|
||||
worker_identity=worker_identity,
|
||||
operation_digest=operation_digest or ("0" * 64),
|
||||
source_commit=source_commit,
|
||||
status=status,
|
||||
summary=summary,
|
||||
outputs=outputs,
|
||||
evidence=evidence,
|
||||
evidence_digests=evidence_digests,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
trust_level=trust_level,
|
||||
production_trust=production_trust,
|
||||
)
|
||||
return envelope.finalized()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Fixed-command, no-shell runner for a read-only Codex workspace."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from .command_policy import CommandPolicyError
|
||||
from .workspace import WorkspacePermissionError, assert_private_path
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
FORBIDDEN_SNIPPETS = ("http://", "https://", ";", "&&", "||", "|", "`", "${", "$(")
|
||||
|
||||
|
||||
class CommandRunnerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandDisallowedError(CommandRunnerError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandPolicyViolation(CommandRunnerError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
returncode: int
|
||||
output: str
|
||||
|
||||
|
||||
class SafeCommandRunner:
|
||||
allowed_kinds = ("git-diff", "diff-check", "git-show", "pytest", "compileall", "acceptance")
|
||||
|
||||
def __init__(self, repo: Path, result_root: Path, *, policy=None, timeout_seconds: int = 300, output_limit: int = 1_000_000) -> None:
|
||||
self.repo = assert_private_path(repo)
|
||||
self.result_root = assert_private_path(result_root)
|
||||
self.timeout_seconds, self.output_limit = timeout_seconds, output_limit
|
||||
|
||||
@staticmethod
|
||||
def _oid(value: str) -> str:
|
||||
if not OID_RE.fullmatch(str(value)):
|
||||
raise CommandPolicyError("exact commit oid required")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _forbidden(value: str) -> bool:
|
||||
lowered = value.lower()
|
||||
return (
|
||||
re.search(r"\b(?:bash|eval)\b", lowered) is not None
|
||||
or re.search(r"(^|\s)-c(\s|$)", lowered) is not None
|
||||
or any(marker in value for marker in FORBIDDEN_SNIPPETS)
|
||||
)
|
||||
|
||||
def _path(self, value: str, *, allowed_prefixes: tuple[str, ...]) -> str:
|
||||
if not value or value.startswith("-") or "\\" in value or self._forbidden(value):
|
||||
raise CommandDisallowedError("unsafe path input")
|
||||
pure = PurePosixPath(value)
|
||||
if pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
|
||||
raise CommandPolicyError("path traversal is forbidden")
|
||||
if not any(value == prefix or value.startswith(prefix + "/") for prefix in allowed_prefixes):
|
||||
raise CommandPolicyError("path outside allowlist")
|
||||
try:
|
||||
candidate = assert_private_path(self.repo / value)
|
||||
except WorkspacePermissionError as exc:
|
||||
raise CommandPolicyError("symlink path is forbidden") from exc
|
||||
try:
|
||||
candidate.relative_to(self.repo)
|
||||
except ValueError:
|
||||
raise CommandPolicyError("path escapes repository") from None
|
||||
return value
|
||||
|
||||
def _output(self, output: Path) -> Path:
|
||||
if Path(output).is_absolute() or Path(output).name != str(output):
|
||||
raise CommandPolicyError("output must be a direct result file")
|
||||
target = assert_private_path(self.result_root / output)
|
||||
try:
|
||||
target.relative_to(self.result_root)
|
||||
except ValueError:
|
||||
raise CommandPolicyError("output escapes result root") from None
|
||||
return target
|
||||
|
||||
def build(self, kind: str, *, oid: str, other_oid: str | None = None, paths: list[str] | None = None, output: Path | None = None) -> list[str]:
|
||||
if kind not in self.allowed_kinds:
|
||||
raise CommandDisallowedError("command kind not allowed")
|
||||
first = self._oid(oid)
|
||||
values = paths or []
|
||||
if kind == "git-diff":
|
||||
second = self._oid(str(other_oid or ""))
|
||||
return ["git", "-C", str(self.repo), "diff", "--no-ext-diff", "--no-textconv", first, second]
|
||||
if kind == "diff-check":
|
||||
return ["git", "-C", str(self.repo), "diff", "--check"]
|
||||
if kind == "git-show" and len(values) == 1:
|
||||
path = self._path(values[0], allowed_prefixes=("active-runtime", "runtime", "scripts", "tests", "docs"))
|
||||
return ["git", "-C", str(self.repo), "show", f"{first}:{path}"]
|
||||
if kind == "pytest" and values:
|
||||
safe = [self._path(value, allowed_prefixes=("tests/task_plane", "tests/test_isolated_writer_policy.py")) for value in values]
|
||||
return ["python3", "-m", "pytest", "-q", *safe]
|
||||
if kind == "compileall" and values:
|
||||
safe = [self._path(value, allowed_prefixes=("active-runtime/app/task_plane", "runtime/agent-os")) for value in values]
|
||||
return ["python3", "-m", "compileall", "-q", *safe]
|
||||
if kind == "acceptance" and output is not None and not values:
|
||||
return ["bash", "scripts/task-plane/run_task_plane_acceptance.sh", str(self._output(output))]
|
||||
raise CommandPolicyError("command template not allowlisted")
|
||||
|
||||
def run(self, kind: str, *, oid: str, other_oid: str | None = None, paths: list[str] | None = None, output: Path | None = None, env: dict[str, str] | None = None) -> CommandResult:
|
||||
if env and any(marker in key.upper() for key in env for marker in ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "COOKIE", "PRIVATE_KEY")):
|
||||
raise CommandPolicyViolation("secret environment rejected")
|
||||
command = self.build(kind, oid=oid, other_oid=other_oid, paths=paths, output=output)
|
||||
safe_env = {
|
||||
"HOME": str(self.result_root),
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"LANG": "C.UTF-8",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
process = subprocess.Popen(
|
||||
command, cwd=self.repo, env=safe_env, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
text, _ = process.communicate(timeout=self.timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
text, _ = process.communicate()
|
||||
raise CommandRunnerError("command timeout")
|
||||
return CommandResult(process.returncode, text[: self.output_limit])
|
||||
|
||||
|
||||
Runner = SafeCommandRunner
|
||||
FixedCommandRunner = SafeCommandRunner
|
||||
CommandResultEnvelope = CommandResult
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Fail-closed loopback HTTP client for the Codex review worker."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
MAX_HTTP_BODY_BYTES = 1_000_000
|
||||
_RETRYABLE_STATUS = {429, 503}
|
||||
_RETRY_BACKOFF_SECONDS = (0.25, 0.5, 1.0, 2.0)
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
|
||||
|
||||
class TaskClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskClientProtocolError(TaskClientError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskClientAuthError(TaskClientError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskClientResponseTooLargeError(TaskClientError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_identifier(value: str, name: str) -> str:
|
||||
candidate = str(value or "")
|
||||
if not _IDENTIFIER_RE.fullmatch(candidate):
|
||||
raise TaskClientProtocolError(f"invalid {name}")
|
||||
return urllib.parse.quote(candidate, safe="")
|
||||
|
||||
|
||||
def _stable_key(operation: str, payload: object) -> str:
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return f"codex-worker:{operation}:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialSource:
|
||||
token_path: Path
|
||||
token_owner: str = "aiman"
|
||||
token_group: str = "gitops"
|
||||
expected_mode: int = 0o600
|
||||
|
||||
def read_token(self) -> str:
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
path = Path(self.token_path)
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise TaskClientAuthError("credential file unavailable") from exc
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise TaskClientAuthError("credential file must be regular")
|
||||
if stat.S_IMODE(info.st_mode) != self.expected_mode:
|
||||
raise TaskClientAuthError("credential file mode rejected")
|
||||
try:
|
||||
owner = pwd.getpwuid(info.st_uid).pw_name
|
||||
group = grp.getgrgid(info.st_gid).gr_name
|
||||
except KeyError as exc:
|
||||
raise TaskClientAuthError("credential ownership unresolved") from exc
|
||||
if owner != self.token_owner or group != self.token_group:
|
||||
raise TaskClientAuthError("credential ownership rejected")
|
||||
value = os.read(fd, 4097)
|
||||
if len(value) > 4096:
|
||||
raise TaskClientAuthError("credential file too large")
|
||||
token = value.decode("utf-8", errors="strict").strip()
|
||||
if len(token.encode("utf-8")) < 32:
|
||||
raise TaskClientAuthError("credential unavailable")
|
||||
return token
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageAClientConfig:
|
||||
base_url: str = "http://127.0.0.1:18923"
|
||||
timeout_seconds: float = 12.0
|
||||
max_request_bytes: int = MAX_HTTP_BODY_BYTES
|
||||
max_response_bytes: int = MAX_HTTP_BODY_BYTES
|
||||
max_retry_attempts: int = 4
|
||||
default_lease_seconds: int = 120
|
||||
default_worker_heartbeat_interval_seconds: int = 15
|
||||
default_max_concurrency: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
parsed = urllib.parse.urlsplit(self.base_url)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or parsed.hostname not in {"127.0.0.1", "::1"}
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.port is None
|
||||
):
|
||||
raise ValueError("Task Plane endpoint must be explicit loopback HTTP")
|
||||
|
||||
|
||||
class TaskPlaneClient:
|
||||
def __init__(self, config: StageAClientConfig, token: str) -> None:
|
||||
if len(str(token).encode("utf-8")) < 16:
|
||||
raise TaskClientAuthError("credential is required")
|
||||
self.config = config
|
||||
self.__token = str(token)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TaskPlaneClient(base_url={self.config.base_url!r}, credential=<redacted>)"
|
||||
|
||||
def _encode(self, payload: dict[str, Any] | None) -> bytes | None:
|
||||
if payload is None:
|
||||
return None
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(body) > self.config.max_request_bytes:
|
||||
raise TaskClientResponseTooLargeError("request payload exceeds configured limit")
|
||||
return body
|
||||
|
||||
def _read_response(self, handle: Any) -> tuple[int, Any]:
|
||||
status_code = int(getattr(handle, "status", 200))
|
||||
raw = handle.read(self.config.max_response_bytes + 1)
|
||||
if len(raw) > self.config.max_response_bytes:
|
||||
raise TaskClientResponseTooLargeError("response payload exceeds configured limit")
|
||||
if not raw:
|
||||
return status_code, {}
|
||||
try:
|
||||
return status_code, json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise TaskClientProtocolError("response is not valid JSON") from exc
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
idempotency_key: str | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
if not path.startswith("/") or "//" in path or "?" in path or "#" in path:
|
||||
raise TaskClientProtocolError("invalid API path")
|
||||
body = self._encode(payload)
|
||||
attempts = max(1, min(self.config.max_retry_attempts, 4))
|
||||
for index in range(attempts):
|
||||
headers = {"Authorization": f"Bearer {self.__token}", "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
request = urllib.request.Request(
|
||||
f"{self.config.base_url.rstrip('/')}{path}",
|
||||
method=method,
|
||||
data=body,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.config.timeout_seconds) as response:
|
||||
return self._read_response(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status_code, response = self._read_response(exc)
|
||||
if status_code in {401, 403}:
|
||||
raise TaskClientAuthError(f"request rejected: HTTP {status_code}")
|
||||
if status_code in _RETRYABLE_STATUS and index + 1 < attempts:
|
||||
time.sleep(_RETRY_BACKOFF_SECONDS[index])
|
||||
continue
|
||||
code = response.get("error") if isinstance(response, dict) else "request_failed"
|
||||
raise TaskClientProtocolError(f"request failed: HTTP {status_code} ({code})")
|
||||
except urllib.error.URLError as exc:
|
||||
if index + 1 < attempts:
|
||||
time.sleep(_RETRY_BACKOFF_SECONDS[index])
|
||||
continue
|
||||
raise TaskClientProtocolError("request transport error") from exc
|
||||
raise TaskClientProtocolError("request failed")
|
||||
|
||||
def _post(self, path: str, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
|
||||
status_code, response = self._request(
|
||||
"POST", path, payload, idempotency_key=idempotency_key or _stable_key(path, payload)
|
||||
)
|
||||
if status_code not in {200, 201} or not isinstance(response, dict):
|
||||
raise TaskClientProtocolError("unexpected API response")
|
||||
return response
|
||||
|
||||
def _get(self, path: str) -> Any:
|
||||
status_code, response = self._request("GET", path)
|
||||
if status_code != 200:
|
||||
raise TaskClientProtocolError("unexpected API response")
|
||||
return response
|
||||
|
||||
def list_tasks(self) -> list[dict[str, Any]]:
|
||||
response = self._get("/tasks")
|
||||
if not isinstance(response, list) or any(not isinstance(item, dict) for item in response):
|
||||
raise TaskClientProtocolError("invalid task list")
|
||||
return response
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
response = self._get(f"/tasks/{_safe_identifier(task_id, 'task_id')}")
|
||||
if not isinstance(response, dict):
|
||||
raise TaskClientProtocolError("invalid task response")
|
||||
return response
|
||||
|
||||
def register_worker(
|
||||
self, *, worker_id: str, worker_type: str, identity: str,
|
||||
capabilities: Iterable[str], trust_zone: str, max_concurrency: int,
|
||||
heartbeat_interval_seconds: int, supported_protocols: Iterable[str] = ("aios-task-v1",),
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"worker_id": worker_id, "worker_type": worker_type, "identity": identity,
|
||||
"capabilities": sorted(set(capabilities)), "trust_zone": trust_zone,
|
||||
"max_concurrency": max_concurrency,
|
||||
"heartbeat_interval_seconds": heartbeat_interval_seconds,
|
||||
"supported_protocols": list(supported_protocols),
|
||||
"owner_identity": identity, "registration_policy": "self",
|
||||
}
|
||||
return self._post("/workers/register", payload, idempotency_key=_stable_key("register", payload))
|
||||
|
||||
def worker_heartbeat(self, worker_id: str, *, heartbeat_interval_seconds: int | None = None) -> dict[str, Any]:
|
||||
payload = {} if heartbeat_interval_seconds is None else {"heartbeat_interval_seconds": heartbeat_interval_seconds}
|
||||
return self._post(f"/workers/{_safe_identifier(worker_id, 'worker_id')}/heartbeat", payload)
|
||||
|
||||
def claim(self, task_id: str, *, worker_id: str, lease_seconds: int | None = None) -> dict[str, Any]:
|
||||
payload = {"worker_id": worker_id, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
|
||||
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/claim", payload)
|
||||
|
||||
def claim_heartbeat(self, task_id: str, *, claim_id: str, lease_seconds: int | None = None) -> dict[str, Any]:
|
||||
if not claim_id:
|
||||
raise TaskClientProtocolError("claim_id is required")
|
||||
payload = {"claim_id": claim_id, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
|
||||
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/heartbeat", payload)
|
||||
|
||||
def progress(self, task_id: str, *, claim_id: str, progress: int | float, stage: str, message: str | None = None, lease_seconds: int | None = None) -> dict[str, Any]:
|
||||
if not claim_id:
|
||||
raise TaskClientProtocolError("claim_id is required for progress")
|
||||
payload = {"claim_id": claim_id, "progress": progress, "stage": stage, "message": message, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
|
||||
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/progress", payload)
|
||||
|
||||
def result(self, task_id: str, *, claim_id: str, status: str, outputs: list[dict[str, Any]], evidence: list[dict[str, Any]], started_at: str, completed_at: str, result_digest: str | None = None, operation_digest: str | None = None, trust_level: str | None = None, production_trust: bool | None = None, **_: Any) -> dict[str, Any]:
|
||||
if not claim_id:
|
||||
raise TaskClientProtocolError("claim_id is required for result submission")
|
||||
if production_trust not in {None, False}:
|
||||
raise TaskClientProtocolError("production_trust is forbidden")
|
||||
payload = {"claim_id": claim_id, "status": status, "outputs": outputs, "evidence": evidence, "started_at": started_at, "completed_at": completed_at}
|
||||
if result_digest:
|
||||
payload["result_digest"] = result_digest
|
||||
if operation_digest:
|
||||
payload["operation_digest"] = operation_digest
|
||||
if trust_level:
|
||||
payload["trust_level"] = trust_level
|
||||
payload["production_trust"] = False
|
||||
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/result", payload)
|
||||
|
||||
def evidence(self, task_id: str, *, claim_id: str, evidence: list[dict[str, Any]], status: str = "VERIFYING", started_at: str, completed_at: str) -> dict[str, Any]:
|
||||
return self.result(task_id, claim_id=claim_id, status=status, outputs=[], evidence=evidence, started_at=started_at, completed_at=completed_at)
|
||||
|
||||
def fail(self, task_id: str, *, claim_id: str, reason: str = "failed", recoverable: bool = False) -> dict[str, Any]:
|
||||
if not claim_id:
|
||||
raise TaskClientProtocolError("claim_id is required for fail")
|
||||
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/fail", {"claim_id": claim_id, "reason": reason, "recoverable": recoverable})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerConfig:
|
||||
worker_id: str
|
||||
worker_type: str
|
||||
identity: str
|
||||
trust_zone: str
|
||||
max_concurrency: int = 1
|
||||
heartbeat_interval_seconds: int = 15
|
||||
claim_lease_seconds: int = 120
|
||||
supported_protocols: tuple[str, ...] = ("aios-task-v1",)
|
||||
capabilities: tuple[str, ...] = ("code.read", "test.python", "review.security")
|
||||
|
||||
def to_registration_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"worker_id": self.worker_id, "worker_type": self.worker_type,
|
||||
"identity": self.identity, "capabilities": list(self.capabilities),
|
||||
"trust_zone": self.trust_zone, "max_concurrency": self.max_concurrency,
|
||||
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
|
||||
"supported_protocols": list(self.supported_protocols),
|
||||
"owner_identity": self.identity, "registration_policy": "self",
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Private, symlink-safe per-claim workspaces."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .command_policy import isolated_environment
|
||||
|
||||
WORKSPACE_DIR_MODE = 0o700
|
||||
RESULT_FILE_MODE = 0o600
|
||||
|
||||
|
||||
class WorkspaceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspacePermissionError(WorkspaceError):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceCleanupError(WorkspaceError):
|
||||
pass
|
||||
|
||||
|
||||
def _lexical(path: Path) -> Path:
|
||||
return Path(os.path.abspath(os.fspath(path)))
|
||||
|
||||
|
||||
def assert_private_path(path: Path, *, expected_mode: int = WORKSPACE_DIR_MODE) -> Path:
|
||||
"""Reject every existing symlink before resolving the final path."""
|
||||
lexical = _lexical(Path(path))
|
||||
current = Path(lexical.anchor)
|
||||
for component in lexical.parts[1:]:
|
||||
current = current / component
|
||||
try:
|
||||
info = current.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise WorkspacePermissionError("symlink path is forbidden")
|
||||
return lexical
|
||||
|
||||
|
||||
def _ensure_directory(path: Path, *, mode: int = WORKSPACE_DIR_MODE) -> None:
|
||||
lexical = assert_private_path(path)
|
||||
lexical.mkdir(mode=mode, parents=True, exist_ok=True)
|
||||
if lexical.is_symlink() or not lexical.is_dir():
|
||||
raise WorkspacePermissionError("workspace directory rejected")
|
||||
os.chmod(lexical, mode)
|
||||
if stat.S_IMODE(lexical.stat().st_mode) != mode:
|
||||
raise WorkspacePermissionError("workspace directory mode rejected")
|
||||
|
||||
|
||||
def _ensure_result_name(name: str) -> str:
|
||||
safe = str(name).strip()
|
||||
if not safe or safe in {".", ".."} or "/" in safe or "\\" in safe or "\0" in safe:
|
||||
raise WorkspaceError("invalid result file name")
|
||||
return safe
|
||||
|
||||
|
||||
def _within(path: Path, base: Path) -> Path:
|
||||
lexical = assert_private_path(path)
|
||||
root = assert_private_path(base)
|
||||
try:
|
||||
lexical.relative_to(root)
|
||||
except ValueError:
|
||||
raise WorkspacePermissionError("path escapes workspace") from None
|
||||
return lexical
|
||||
|
||||
|
||||
def _copy_readonly_tree(source: Path, destination: Path) -> None:
|
||||
source = assert_private_path(source)
|
||||
if not source.is_dir():
|
||||
raise WorkspaceError("source archive tree unavailable")
|
||||
for item in source.rglob("*"):
|
||||
if item.is_symlink():
|
||||
raise WorkspacePermissionError("source archive symlink rejected")
|
||||
shutil.copytree(source, destination, dirs_exist_ok=True, symlinks=False)
|
||||
for item in destination.rglob("*"):
|
||||
if item.is_file():
|
||||
os.chmod(item, 0o400)
|
||||
elif item.is_dir():
|
||||
os.chmod(item, 0o500)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceLayout:
|
||||
workspace_root: Path
|
||||
repo_root: Path
|
||||
result_root: Path
|
||||
home: Path
|
||||
tmp: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskWorkspace:
|
||||
layout: WorkspaceLayout
|
||||
task_id: str
|
||||
claim_id: str
|
||||
|
||||
def __init__(self, root: Path, task_id: str, claim_id: str, *, repo_subdir: str = "repo") -> None:
|
||||
workspace_root = assert_private_path(root)
|
||||
_ensure_directory(workspace_root)
|
||||
self.layout = WorkspaceLayout(
|
||||
workspace_root, workspace_root / repo_subdir, workspace_root / "results",
|
||||
workspace_root / "home", workspace_root / "tmp",
|
||||
)
|
||||
for path in (self.layout.repo_root, self.layout.result_root, self.layout.home, self.layout.tmp):
|
||||
_ensure_directory(path)
|
||||
self.task_id, self.claim_id = task_id, claim_id
|
||||
|
||||
@classmethod
|
||||
def create(cls, root: Path, task_id: str, claim_id: str, *, repo_subdir: str = "repo") -> "TaskWorkspace":
|
||||
return cls(root, task_id, claim_id, repo_subdir=repo_subdir)
|
||||
|
||||
def __enter__(self) -> "TaskWorkspace":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.cleanup()
|
||||
|
||||
def materialize_readonly_archive(self, source: Path) -> None:
|
||||
if any(self.layout.repo_root.iterdir()):
|
||||
raise WorkspaceError("workspace repository is not empty")
|
||||
_copy_readonly_tree(source, self.layout.repo_root)
|
||||
|
||||
def environ(self) -> dict[str, str]:
|
||||
return isolated_environment(self.layout.home, self.layout.tmp, "active-runtime")
|
||||
|
||||
def result_path(self, name: str) -> Path:
|
||||
return _within(self.layout.result_root / _ensure_result_name(name), self.layout.result_root)
|
||||
|
||||
def write_result(self, *, name: str, payload: str) -> Path:
|
||||
path = self.result_path(name)
|
||||
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, RESULT_FILE_MODE)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise WorkspacePermissionError("result path is not regular")
|
||||
os.write(fd, payload.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
os.fchmod(fd, RESULT_FILE_MODE)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
def cleanup(self) -> None:
|
||||
root = assert_private_path(self.layout.workspace_root)
|
||||
if not root.exists():
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(root)
|
||||
except OSError as exc:
|
||||
raise WorkspaceCleanupError("workspace cleanup failed") from exc
|
||||
|
||||
|
||||
Workspace = TaskWorkspace
|
||||
@@ -1,42 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Release Candidate API (Dormant/Shadow Only)
|
||||
After=network.target
|
||||
ConditionPathExists=/opt/aios-autonomous-release-candidate/aios_autonomous_release_candidate
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
UMask=0077
|
||||
WorkingDirectory=/opt/aios-autonomous-release-candidate
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=/opt/aios-autonomous-release-candidate
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_PRODUCTION_DEPLOYMENT_ENABLED=false
|
||||
ExecStart=/usr/bin/python3 -m aios_autonomous_release_candidate.service --host 127.0.0.1 --port 19951 --state /var/lib/aios-autonomous-release-candidate/state.sqlite3
|
||||
StateDirectory=aios-autonomous-release-candidate
|
||||
RuntimeDirectory=aios-autonomous-release-candidate
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
ReadOnlyPaths=/opt/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/var/lib/aios-autonomous-release-candidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,51 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Release Candidate Worker (Dormant/Shadow Only)
|
||||
After=network.target
|
||||
ConditionPathExists=/opt/aios-autonomous-release-candidate/aios_autonomous_release_candidate
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
UMask=0077
|
||||
WorkingDirectory=/opt/aios-autonomous-release-candidate
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=/opt/aios-autonomous-release-candidate
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_PRODUCTION_DEPLOYMENT_ENABLED=false
|
||||
Environment=AIOS_CANDIDATE_CONTROL_ENDPOINT=127.0.0.1:19951
|
||||
Environment=AIOS_OPENCLAW_CREDENTIAL_REFERENCE=systemd-credential:openclaw-gateway-candidate
|
||||
Environment=AIOS_CODEX_CREDENTIAL_REFERENCE=systemd-credential:codex-app-server-candidate
|
||||
Environment=AIOS_NATIVE_CONTROL_CREDENTIAL_REFERENCE=systemd-credential:session-native-adapter-control-candidate
|
||||
Environment=AIOS_GITEA_CREDENTIAL_REFERENCE=systemd-credential:gitea-admin-api-candidate
|
||||
ImportCredential=openclaw-gateway-candidate
|
||||
ImportCredential=codex-app-server-candidate
|
||||
ImportCredential=session-native-adapter-control-candidate
|
||||
ImportCredential=gitea-admin-api-candidate
|
||||
ExecStart=/usr/bin/python3 -m aios_autonomous_release_candidate.worker_service --state-directory /var/lib/aios-autonomous-release-worker-candidate
|
||||
StateDirectory=aios-autonomous-release-worker-candidate
|
||||
RuntimeDirectory=aios-autonomous-release-worker-candidate
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
ReadOnlyPaths=/opt/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/var/lib/aios-autonomous-release-worker-candidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=AIOS Codex Review/Test Worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/usr/libexec/aios-codex-review-test-worker/current
|
||||
Environment=AIOS_CODEX_WORKER_MODE=shadow
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RuntimeDirectory=aios-codex-review-test-worker
|
||||
StateDirectory=aios-codex-review-test-worker
|
||||
CacheDirectory=aios-codex-review-test-worker
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadOnlyPaths=/usr/libexec/aios-codex-review-test-worker
|
||||
ReadWritePaths=/run/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/lib/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/cache/aios-codex-review-test-worker
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,71 +0,0 @@
|
||||
# AIOS Autonomous Release Agent Deployment Instructions
|
||||
|
||||
## Objective
|
||||
|
||||
Deploy only candidate/shadow environments first. Production activation requires a separate owner release approval.
|
||||
|
||||
## Agent execution order
|
||||
|
||||
1. Verify git references:
|
||||
- local
|
||||
- authoritative bare
|
||||
- remote
|
||||
- Gitea API
|
||||
|
||||
2. Build candidate artifact.
|
||||
|
||||
3. Run:
|
||||
- unit tests
|
||||
- security scans
|
||||
- secret scans
|
||||
- artifact verification
|
||||
|
||||
4. Start candidate workers:
|
||||
- OpenClaw native adapter
|
||||
- Codex native adapter
|
||||
- dedicated writer
|
||||
|
||||
5. Execute shadow task:
|
||||
|
||||
```
|
||||
TaskPlan
|
||||
-> Planner
|
||||
-> Worker Router
|
||||
-> Session lifecycle
|
||||
-> Evidence chain
|
||||
-> Artifact verification
|
||||
-> ReleasePlan
|
||||
```
|
||||
|
||||
## Hard restrictions
|
||||
|
||||
Never:
|
||||
|
||||
- write main directly
|
||||
- force push
|
||||
- rewrite tags
|
||||
- export secrets
|
||||
- bypass owner approval
|
||||
- modify production flags during candidate validation
|
||||
|
||||
## Production release gate
|
||||
|
||||
Require:
|
||||
|
||||
```
|
||||
owner_approval=true
|
||||
artifact_verified=true
|
||||
evidence_closure=true
|
||||
rollback_ready=true
|
||||
remote_tag_verified=true
|
||||
baseline_verified=true
|
||||
registry_verified=true
|
||||
```
|
||||
|
||||
Until then keep:
|
||||
|
||||
```
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
@@ -1,34 +0,0 @@
|
||||
# AIOS Autonomous Release Candidate 架构
|
||||
|
||||
本 Candidate 把规划、工作流、凭据引用、原生会话、证据和发布控制拆成独立边界。它只负责验证约束,不拥有生产授权。
|
||||
|
||||
## 信任边界
|
||||
|
||||
- Owner Approval 的签名和撤销状态来自外部 HSM/KMS 或可信 Approval Plane。Candidate 只有 verifier,没有 signer。
|
||||
- Secret Manager V2 只返回 `systemd-credential:*` 或受控的 Candidate reference,不返回认证正文。
|
||||
- OpenClaw、Codex 和 Gitea provider 身份由管理面供应;模拟 provider 永远不能把原生 canary 标成已验证。
|
||||
- Writer 只写任务绑定分支和允许路径。生产 main、tag、baseline、registry 和部署不属于 Candidate 权限。
|
||||
|
||||
## 核心模块
|
||||
|
||||
- `models.py`:TaskPlan、Approval、Credential、Result 和 ReleasePlan 强类型约束。
|
||||
- `store.py`:私有 SQLite、WAL、全同步、schema 检查、事务和原子 step transition。
|
||||
- `workflow.py`:依赖、claim、lease、retry、timeout recovery 和终态。
|
||||
- `credential.py` / `secret_manager.py`:reference-only 生命周期、补偿撤销、跨任务拒绝和 post-revocation 验证。
|
||||
- `native_adapters.py`:OpenClaw/Codex 原生 provider ID、parent/child tree、status、interrupt、close 和纯文本回执。
|
||||
- `gitea_canary.py`:仅通过 deploy-key provider 的 list/create/delete 操作完成 canary 与撤销验证。
|
||||
- `evidence.py`:落库的哈希链、重启回放、篡改检测和 required-event closure。
|
||||
- `artifact.py`:确定性 tar、逐文件哈希、package closure、链接/特殊文件/敏感标记拒绝。
|
||||
- `release.py` / `release_gate.py`:部署审批与回滚审批分离,tag object 与 peeled target 分离验证。
|
||||
|
||||
## 会话真实性
|
||||
|
||||
以下状态必须分别记录,不能互相替代:
|
||||
|
||||
```text
|
||||
broker_session_verified
|
||||
openclaw_native_child_session_verified
|
||||
codex_child_session_verified
|
||||
```
|
||||
|
||||
Shadow provider 只能证明 Broker 合同和清理路径,不能证明 OpenClaw 或 Codex 原生会话。
|
||||
@@ -1,47 +0,0 @@
|
||||
# AIOS Autonomous Release Execution Package
|
||||
|
||||
## Mode
|
||||
|
||||
Candidate/shadow first. Production changes require an independent release approval.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Create TaskPlan.
|
||||
2. Validate policy and approval envelope.
|
||||
3. Route workers by capability and trust zone.
|
||||
4. Execute on isolated candidate branch.
|
||||
5. Collect evidence chain and artifact manifest.
|
||||
6. Verify release plan.
|
||||
7. Wait for owner approval.
|
||||
8. Deploy only after approval.
|
||||
|
||||
## Hard stops
|
||||
|
||||
- main writes
|
||||
- force push
|
||||
- tag rewrite
|
||||
- baseline mutation
|
||||
- registry mutation
|
||||
- plaintext credential handling
|
||||
- missing evidence closure
|
||||
|
||||
## Required evidence
|
||||
|
||||
- git ref closure
|
||||
- test report
|
||||
- artifact checksum
|
||||
- manifest checksum
|
||||
- credential revoke proof
|
||||
- production invariant snapshot
|
||||
|
||||
## Production activation
|
||||
|
||||
The executor must keep:
|
||||
|
||||
```
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
until release approval is independently verified.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Candidate 运维与验收
|
||||
|
||||
## 安全默认值
|
||||
|
||||
两个 systemd 模板均为 Dormant/Shadow-only,绑定 loopback,默认关闭 Task Plane 执行、GitOps 写入和生产部署。模板进入仓库不等于安装或启动服务。
|
||||
|
||||
外部信任根缺失时必须保留以下阻断:
|
||||
|
||||
```text
|
||||
BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE
|
||||
BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED
|
||||
BLOCKED_EXTERNAL_OWNER_SIGNATURE
|
||||
```
|
||||
|
||||
不得用模拟身份、个人账户或生产 provider 身份替代这些根。
|
||||
|
||||
## 独立验收
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```text
|
||||
PYTHONPATH=services/aios-autonomous-release-candidate \
|
||||
python3 -m pytest -p no:cacheprovider -q tests/autonomous_release
|
||||
|
||||
PYTHONPATH=services/aios-autonomous-release-candidate \
|
||||
python3 scripts/autonomous-release/run_acceptance.py \
|
||||
--repo-root . \
|
||||
--output /task-evidence/candidate-acceptance.json
|
||||
```
|
||||
|
||||
Acceptance report 必须满足:
|
||||
|
||||
```text
|
||||
all_passed=true
|
||||
skipped=0
|
||||
direct_gitea_database_access=false
|
||||
production_path_hits=[]
|
||||
secret_hits=[]
|
||||
production_side_effects=false
|
||||
```
|
||||
|
||||
## Artifact
|
||||
|
||||
Artifact builder 拒绝覆盖现有文件、链接、硬链接、特殊文件、仓库元数据和敏感内容标记。输出 tar 和 manifest 后必须再次执行 `ArtifactBuilder.verify`。
|
||||
|
||||
## 外部 canary
|
||||
|
||||
真实 native session 与 Gitea canary 只能在任务绑定的 runtime credential 投影存在时运行。每次 canary 完成后必须关闭 provider session、撤销上游对象、删除 projection,并保留非敏感回执。
|
||||
@@ -1,26 +0,0 @@
|
||||
# Governed ReleasePlan 关闭条件
|
||||
|
||||
ReleasePlan 必须绑定 task、repository、integration branch、完整 target/tree OID、artifact/manifest/evidence/rollback digest、生产配置 digest,以及互不相同的部署和回滚 ApprovalPlan ID。
|
||||
|
||||
## Candidate 与 Integration
|
||||
|
||||
1. Candidate 分支先完成测试、扫描、Shadow、Artifact 和外部门禁盘点。
|
||||
2. 重新读取 authoritative `main` 完整 OID。
|
||||
3. 从该 OID 创建 integration 分支。
|
||||
4. 使用普通 `merge --no-ff` 合入 Candidate;禁止 squash、rebase、amend 和 force push。
|
||||
5. 在 integration 上重新执行全部测试、扫描和 artifact closure。
|
||||
|
||||
## 生产停止线
|
||||
|
||||
下列项目任一为 false,流程必须停止在 `READY_FOR_OWNER_RELEASE_APPROVAL`,不得部署或创建生产 tag:
|
||||
|
||||
```text
|
||||
openclaw_native_child_session_verified
|
||||
codex_child_session_verified
|
||||
gitea_admin_api_lifecycle_canary
|
||||
owner_signer_separation_verified
|
||||
credential_incident_closed
|
||||
external_owner_release_approval_verified
|
||||
```
|
||||
|
||||
聊天文本、Candidate 自签记录或模拟 canary 不能改变这条停止线。
|
||||
@@ -1,68 +0,0 @@
|
||||
# Isolated Writer Exact-Path Policy V1
|
||||
|
||||
This policy is an additional, service-owned authorization layer for
|
||||
`POST /api/commit-file`. It never expands a Token Policy V2 grant.
|
||||
|
||||
An atomic write is permitted only when all of the following agree:
|
||||
|
||||
1. socket peer, audience, operation, branch, base and exact path in the
|
||||
Token Policy V2 grant;
|
||||
2. service audience, repository identity, branch, operation and exact path
|
||||
in `AIOS_GITOPS_PATH_POLICY_FILE`;
|
||||
3. lexical path, resolved containment and symlink checks;
|
||||
4. the configured content-size and content-profile checks;
|
||||
5. operation digest, expected branch HEAD and durable operation ledger.
|
||||
|
||||
Any failed layer returns a fail-closed policy error. Coarse `/api/file` and
|
||||
`/api/commit` routes remain forbidden.
|
||||
|
||||
## File contract
|
||||
|
||||
The configured path must be absolute. The parent directory must be
|
||||
`root:gitops 0750`; the regular, non-symlink file must be
|
||||
`root:gitops 0640`. JSON must use UTF-8 canonical encoding with sorted keys,
|
||||
compact separators and one trailing newline. Unknown fields, unsupported
|
||||
operations or profiles, duplicate paths, oversized policies and
|
||||
group/world-writable files are rejected during Writer startup.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{"allowed_branch":"patch/codex-review-test-worker-20260724","allowed_operations":{"commit_file.atomic":{"content_profile":"systemd-codex-review-worker-v1","exact_paths":["deploy/systemd/aios-codex-review-test-worker.service"],"max_content_bytes":32768}},"audience":"aios-codex-review-worker-writer.service","policy_id":"codex-review-worker-path-policy-v1","repository_identity":"aios/aios","schema_version":1}
|
||||
```
|
||||
|
||||
The SHA-256 of the canonical policy is exposed in the isolated runtime
|
||||
status and written to the operation audit record. The request body cannot
|
||||
select or override the policy file.
|
||||
|
||||
## Content profile
|
||||
|
||||
`systemd-codex-review-worker-v1` is an exact, fail-closed unit grammar. The
|
||||
only sections are `[Unit]`, `[Service]` and `[Install]`. Each section has a
|
||||
directive allowlist; every single-value directive and every repeated
|
||||
`Environment`, `ReadOnlyPaths` and `ReadWritePaths` entry must match the
|
||||
governed Shadow unit contract exactly.
|
||||
|
||||
The profile fixes the Python module
|
||||
`task_plane.workers.codex_review_test`, `User=aiman`, `Group=gitops`,
|
||||
the controlled `/usr/libexec/aios-codex-review-test-worker/current`
|
||||
working directory, all hardening switches and the three permitted
|
||||
environment variables. It grants read access only to the authoritative
|
||||
bare repository and the versioned worker artifact, and write access only
|
||||
to the worker runtime, state and cache directories.
|
||||
|
||||
Unknown sections or directives are rejected, including lifecycle hooks,
|
||||
credential injection, environment imports, root images, bind mounts,
|
||||
capability expansion, devices, sockets and top-level writable paths. The
|
||||
parser also rejects duplicate sections, duplicate single-value directives,
|
||||
empty resets, continuation lines, CRLF, NUL, non-UTF-8 surrogate content,
|
||||
overlong lines and Unicode lookalike directive names. It does not use
|
||||
`ConfigParser(strict=False)` or silently merge repeated values.
|
||||
|
||||
Production credentials, if later authorized, must be supplied by a
|
||||
separately governed deployment drop-in. They are not permitted in the
|
||||
source unit or this content profile.
|
||||
|
||||
Policy changes require a separate maintenance branch and Owner-reviewed
|
||||
ordinary merge commit. A task-scoped Writer must not edit its own policy
|
||||
file.
|
||||
@@ -51,31 +51,6 @@ components:
|
||||
path: {type: string, example: docs/gitops/chatgpt-change.md}
|
||||
content: {type: string}
|
||||
message: {type: string}
|
||||
AtomicFileCommitRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [branch, path, content, message, expected_head_oid, idempotency_key]
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
pattern: '^patch/[A-Za-z0-9._/-]+$'
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 512
|
||||
content:
|
||||
type: string
|
||||
maxLength: 1048576
|
||||
message:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
expected_head_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{40}$'
|
||||
idempotency_key:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'
|
||||
CommitRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -453,25 +428,6 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit-file:
|
||||
post:
|
||||
operationId: commitFileAtomic
|
||||
summary: Atomically write one allowlisted file, commit, push, and verify using Token Policy V2
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AtomicFileCommitRequest'}
|
||||
responses:
|
||||
'200': {description: Completed or idempotently replayed}
|
||||
'400': {description: Invalid canonical request}
|
||||
'401': {description: Missing, invalid, expired, or revoked Token Policy V2 grant}
|
||||
'403': {description: Audience, source, branch, operation, path, or policy denied}
|
||||
'409': {description: Idempotency conflict or manual review required}
|
||||
'413': {description: Request too large}
|
||||
'503': {description: Push or remote verification unavailable; recoverable}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task": "codex-review-test-worker",
|
||||
"writer_role": "isolated-writer",
|
||||
"status": "READY"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Codex Read/Review/Test Worker
|
||||
|
||||
This package defines a Shadow-only Task Plane worker for allowlisted code reading,
|
||||
review, Python tests, compile checks, and Task Plane acceptance. It is not a
|
||||
production execution authorization.
|
||||
|
||||
Safety invariants:
|
||||
|
||||
- `max_concurrency=1`, heartbeat 15 seconds, claim lease 120 seconds.
|
||||
- Task Plane endpoint must be explicit loopback HTTP.
|
||||
- credentials are read only from a regular, non-symlink `0600` file.
|
||||
- only low-risk `code.review` and `test.execute` tasks with
|
||||
`production_side_effects=false` and public/internal data are eligible.
|
||||
- credential-secret, write, deployment, production, arbitrary shell, network,
|
||||
path traversal, symlink escape, revision expressions, and secret environment
|
||||
variables are rejected.
|
||||
- `ResultEnvelope` is canonical-digest-bound and always has
|
||||
`trust_level=SHADOW_ATTESTED`, `production_trust=false`.
|
||||
|
||||
The committed systemd unit is deliberately non-installable and runs only
|
||||
`--self-check`. Enabling a real Worker or changing any production execution flag
|
||||
requires a separate Owner approval and a separately bound artifact.
|
||||
|
||||
Artifact build:
|
||||
|
||||
```bash
|
||||
python3 scripts/build_codex_review_worker_artifact.py \
|
||||
--git-dir /data/aios-gitops/repo.git \
|
||||
--commit <exact-branch-commit> \
|
||||
--output <new-empty-output-directory>
|
||||
```
|
||||
|
||||
The temporary canary uses `127.0.0.1:18923`, an isolated state root, an ephemeral
|
||||
credential file, and a no-`.git` source tree. Remove the unit, token, and state
|
||||
after acceptance. Do not deploy this package to the existing Task Plane Shadow
|
||||
runtime as part of build/canary acceptance.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"component": "aios-codex-review-test-worker",
|
||||
"mode": "SHADOW_ONLY",
|
||||
"production_trust": false,
|
||||
"production_execution_authorized": false,
|
||||
"required_acceptance": [
|
||||
"exact_commit_artifact",
|
||||
"no_git_canary",
|
||||
"worker_registration",
|
||||
"claim_lease_heartbeat",
|
||||
"progress",
|
||||
"result_envelope",
|
||||
"lease_loss_stops_result",
|
||||
"unsafe_tasks_rejected",
|
||||
"secret_scan",
|
||||
"production_side_effects_false"
|
||||
],
|
||||
"runtime_result": "GENERATED_OUTSIDE_GIT_DURING_CANARY"
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic Candidate artifact without overwriting evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder
|
||||
|
||||
|
||||
def exclusive_write(path: Path, content: bytes, mode: int) -> None:
|
||||
if path.is_symlink():
|
||||
raise RuntimeError("artifact output symlink rejected")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, mode)
|
||||
try:
|
||||
os.write(descriptor, content)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", required=True, type=Path)
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
parser.add_argument("--tree-oid", required=True)
|
||||
parser.add_argument("--output-directory", required=True, type=Path)
|
||||
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
output = args.output_directory
|
||||
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
if output.is_symlink():
|
||||
raise RuntimeError("artifact output directory symlink rejected")
|
||||
os.chmod(output, output.stat().st_mode & 0o750)
|
||||
builder = ArtifactBuilder()
|
||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
||||
builder.verify(built)
|
||||
artifact_path = output / f"{args.name}.tar"
|
||||
manifest_path = output / f"{args.name}.manifest.json"
|
||||
exclusive_write(artifact_path, built.content, 0o640)
|
||||
exclusive_write(manifest_path, built.manifest.as_json_bytes(), 0o640)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": str(artifact_path),
|
||||
"manifest": str(manifest_path),
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"secrets_included": False,
|
||||
"production_side_effects": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,511 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Candidate acceptance and emit a non-overwriting JSON evidence record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
ALLOWED_PREFIXES = (
|
||||
"services/aios-autonomous-release-candidate/",
|
||||
"tests/autonomous_release/",
|
||||
"tests/autonomous-release/",
|
||||
"scripts/autonomous-release/",
|
||||
"docs/autonomous-release/",
|
||||
)
|
||||
ALLOWED_EXACT = {
|
||||
"deploy/systemd/aios-autonomous-release-candidate.service",
|
||||
"deploy/systemd/aios-autonomous-release-worker-candidate.service",
|
||||
}
|
||||
SCAN_PREFIXES = (
|
||||
"services/aios-autonomous-release-candidate",
|
||||
"tests/autonomous_release",
|
||||
"tests/autonomous-release",
|
||||
"scripts/autonomous-release",
|
||||
"docs/autonomous-release",
|
||||
)
|
||||
|
||||
|
||||
def command(repo: Path, argv: Sequence[str], environment: dict[str, str]) -> dict[str, object]:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=repo,
|
||||
env=environment,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"argv": list(argv),
|
||||
"exit_code": completed.returncode,
|
||||
"output": completed.stdout[-12000:],
|
||||
"passed": completed.returncode == 0,
|
||||
}
|
||||
|
||||
|
||||
def text_files(repo: Path):
|
||||
for prefix in SCAN_PREFIXES:
|
||||
root = repo / prefix
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file() and not path.is_symlink() and "__pycache__" not in path.parts:
|
||||
try:
|
||||
yield path, path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def scans(repo: Path) -> dict[str, object]:
|
||||
forbidden_source = (
|
||||
"GITEA_" + "DATABASE",
|
||||
"sqlite3.connect(" + "GITEA_" + "DATABASE)",
|
||||
"SELECT " + "* FROM access_token",
|
||||
"DELETE " + "FROM access_token",
|
||||
"gitea admin " + "user generate-access-token",
|
||||
"ssh-key" + "scan",
|
||||
)
|
||||
secret_patterns = (
|
||||
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
|
||||
re.compile(r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
||||
re.compile(r"(?i)(?:api[_-]?key|password|token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{20,}"),
|
||||
)
|
||||
forbidden_hits: list[str] = []
|
||||
secret_hits: list[str] = []
|
||||
for path, content in text_files(repo):
|
||||
relative = path.relative_to(repo).as_posix()
|
||||
if any(marker in content for marker in forbidden_source):
|
||||
forbidden_hits.append(relative)
|
||||
if any(pattern.search(content) for pattern in secret_patterns):
|
||||
secret_hits.append(relative)
|
||||
changed = subprocess.run(
|
||||
["git", "diff", "--name-only", "main...HEAD"],
|
||||
cwd=repo,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
changed_paths = tuple(item for item in changed.stdout.splitlines() if item)
|
||||
production_paths = [
|
||||
path
|
||||
for path in changed_paths
|
||||
if path not in ALLOWED_EXACT and not any(path.startswith(prefix) for prefix in ALLOWED_PREFIXES)
|
||||
]
|
||||
return {
|
||||
"direct_gitea_database_access": False if not forbidden_hits else True,
|
||||
"forbidden_source_hits": sorted(set(forbidden_hits)),
|
||||
"secret_hits": sorted(set(secret_hits)),
|
||||
"production_path_hits": production_paths,
|
||||
"passed": not forbidden_hits and not secret_hits and not production_paths,
|
||||
}
|
||||
|
||||
|
||||
def exclusive_json(path: Path, payload: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o640)
|
||||
try:
|
||||
content = (json.dumps(payload, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||
os.write(descriptor, content)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def git_object_oid(kind: str, content: bytes) -> str:
|
||||
header = f"{kind} {len(content)}\0".encode("ascii")
|
||||
return hashlib.sha1(header + content, usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
def run_shadow_e2e(repo: Path, package: Path, checks: list[dict[str, object]]) -> dict[str, object]:
|
||||
"""Drive every Candidate contract without claiming a provider-native session."""
|
||||
if subprocess.check_output(["git", "status", "--porcelain"], cwd=repo, text=True).strip():
|
||||
raise RuntimeError("shadow acceptance requires a clean worktree")
|
||||
|
||||
sys.path.insert(0, str(package))
|
||||
from aios_autonomous_release_candidate.adapters import ShadowAdapter
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder
|
||||
from aios_autonomous_release_candidate.credential import (
|
||||
CredentialBroker,
|
||||
InMemoryCredentialProvider,
|
||||
)
|
||||
from aios_autonomous_release_candidate.errors import ReleaseBlocked
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
CredentialRequest,
|
||||
ReleasePlan,
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
TaskPlan,
|
||||
WorkerDescriptor,
|
||||
WorkflowStep,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_autonomous_release_candidate.release import ReleaseController
|
||||
from aios_autonomous_release_candidate.router import RouteRequest, WorkerRouter
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
from aios_autonomous_release_candidate.workflow import WorkflowEngine
|
||||
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
tree = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True
|
||||
).strip()
|
||||
task_id = "AIOS-AUTONOMOUS-RELEASE-FULL-IMPLEMENTATION-20260726-SHADOW"
|
||||
roles = ("hermes", "writer", "codex", "openclaw")
|
||||
steps = tuple(
|
||||
WorkflowStep(
|
||||
step_id=f"{role}-step",
|
||||
worker_role=role,
|
||||
required_capabilities=(f"{role}.shadow",),
|
||||
depends_on=(() if index == 0 else (f"{roles[index - 1]}-step",)),
|
||||
max_attempts=2,
|
||||
timeout_seconds=60,
|
||||
allowed_paths=("services/aios-autonomous-release-candidate",),
|
||||
)
|
||||
for index, role in enumerate(roles)
|
||||
)
|
||||
binding = ResourceBinding(
|
||||
repository="aios/aios",
|
||||
branch="patch/chatgpt-autonomous-completion-contract-20260726",
|
||||
base_oid=head,
|
||||
allowed_paths=("services/aios-autonomous-release-candidate",),
|
||||
)
|
||||
plan = TaskPlan(
|
||||
task_id=task_id,
|
||||
intent="candidate shadow contract closure",
|
||||
binding=binding,
|
||||
risk=RiskClass.HIGH,
|
||||
steps=steps,
|
||||
evidence_requirements=(
|
||||
"task_plan",
|
||||
"worker_route",
|
||||
"step_result",
|
||||
"credential_revoked",
|
||||
"session_closed",
|
||||
"artifact",
|
||||
"test_report",
|
||||
"git_ref",
|
||||
),
|
||||
production_side_effects_allowed=False,
|
||||
)
|
||||
workers = [
|
||||
WorkerDescriptor(
|
||||
worker_id=f"shadow-{role}-worker",
|
||||
role=role,
|
||||
capabilities=frozenset({f"{role}.shadow"}),
|
||||
trust_zone="candidate-shadow",
|
||||
endpoint_id=f"loopback:{role}",
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
router = WorkerRouter(workers)
|
||||
provider = InMemoryCredentialProvider()
|
||||
now = datetime.now(timezone.utc)
|
||||
worker_routes: dict[str, str] = {}
|
||||
session_ids: dict[str, dict[str, str]] = {}
|
||||
credential_ids: list[str] = []
|
||||
credential_revocation: dict[str, dict[str, object]] = {}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="aios-autonomous-shadow-") as temporary:
|
||||
store = StateStore(Path(temporary) / "state.sqlite3")
|
||||
workflow = WorkflowEngine(store)
|
||||
workflow.plan(plan)
|
||||
evidence = EvidenceChain(store, task_id)
|
||||
evidence.append(
|
||||
"task_plan",
|
||||
task_id,
|
||||
{"task_plan_sha256": plan.digest(), "secrets_included": False},
|
||||
)
|
||||
broker = CredentialBroker(store, provider)
|
||||
adapters = {role: ShadowAdapter(role) for role in roles}
|
||||
|
||||
while True:
|
||||
decision = workflow.next_steps(plan)
|
||||
states = store.list_steps(task_id)
|
||||
if states and all(item.state.value == "COMPLETED" for item in states.values()):
|
||||
break
|
||||
if not decision.ready_steps:
|
||||
raise RuntimeError("shadow workflow stalled")
|
||||
for step_id in decision.ready_steps:
|
||||
step = next(item for item in steps if item.step_id == step_id)
|
||||
worker = router.route(
|
||||
RouteRequest(task_id, step.required_capabilities, "candidate-shadow")
|
||||
)
|
||||
adapter = adapters[step.worker_role]
|
||||
session = adapter.create_session({"task_id": task_id, "step_id": step_id})
|
||||
worker_routes[step_id] = worker.worker_id
|
||||
session_ids[step_id] = {
|
||||
"broker_session_id": session.session_id,
|
||||
"provider_session_id": session.provider_session_id,
|
||||
"provider": session.provider,
|
||||
}
|
||||
evidence.append(
|
||||
"worker_route",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"worker_id": worker.worker_id,
|
||||
"trust_zone": worker.trust_zone,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
request = CredentialRequest(
|
||||
request_id=f"request-{step_id}",
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
worker_id=worker.worker_id,
|
||||
audience=f"{step.worker_role}-candidate-shadow",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
binding=binding,
|
||||
operations=(f"{step.worker_role}.shadow",),
|
||||
provider=f"{step.worker_role}-candidate-mock",
|
||||
runtime_user="aiman",
|
||||
approval_plan_id="candidate-shadow-verifier-20260726",
|
||||
not_before=now - timedelta(seconds=1),
|
||||
expires_at=now + timedelta(seconds=299),
|
||||
nonce=f"nonce-{step_id}",
|
||||
ttl_seconds=300,
|
||||
max_uses=1,
|
||||
)
|
||||
projection = broker.issue(request, now)
|
||||
broker.project(projection)
|
||||
broker.authorize(
|
||||
projection,
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
worker_id=worker.worker_id,
|
||||
audience=request.audience,
|
||||
consumer_service=request.consumer_service,
|
||||
operation=request.operations[0],
|
||||
path="services/aios-autonomous-release-candidate",
|
||||
now=now,
|
||||
)
|
||||
workflow.claim(task_id, step_id, worker.worker_id, session.session_id)
|
||||
workflow.start(task_id, step_id, worker.worker_id, session.session_id, now=now)
|
||||
workflow.heartbeat(task_id, step_id, worker.worker_id, session.session_id, now=now)
|
||||
result = adapter.send(session, {"task_id": task_id, "step_id": step_id})
|
||||
result_digest = sha256_json(result)
|
||||
evidence.append(
|
||||
"step_result",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"worker_id": worker.worker_id,
|
||||
"result_sha256": result_digest,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
workflow.complete(task_id, step_id, result_digest=result_digest)
|
||||
revoked = broker.revoke(projection.credential_id, projection.reference, now)
|
||||
broker.verify_revoked(projection.credential_id, projection.reference)
|
||||
credential_ids.append(projection.credential_id)
|
||||
credential_revocation[projection.credential_id] = {
|
||||
"state": revoked.state.value,
|
||||
"post_revocation_auth": "REJECTED",
|
||||
}
|
||||
evidence.append(
|
||||
"credential_revoked",
|
||||
task_id,
|
||||
{
|
||||
"credential_id": projection.credential_id,
|
||||
"state": revoked.state.value,
|
||||
"post_revocation_auth": "REJECTED",
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
adapter.close(session)
|
||||
evidence.append(
|
||||
"session_closed",
|
||||
task_id,
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"provider_session_id": session.provider_session_id,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
|
||||
built = ArtifactBuilder().build_from_directory(head, tree, package)
|
||||
ArtifactBuilder().verify(built)
|
||||
evidence.append(
|
||||
"artifact",
|
||||
task_id,
|
||||
{
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
evidence.append(
|
||||
"test_report",
|
||||
task_id,
|
||||
{
|
||||
"checks_passed": all(bool(item["passed"]) for item in checks),
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
evidence.append(
|
||||
"git_ref",
|
||||
task_id,
|
||||
{"target_oid": head, "tree_oid": tree, "secrets_included": False},
|
||||
)
|
||||
evidence_closure = evidence.close(plan.evidence_requirements)
|
||||
evidence_bytes = json.dumps(
|
||||
evidence.export(), sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
evidence_closure_oid = git_object_oid("blob", evidence_bytes)
|
||||
tag_body = (
|
||||
f"object {head}\ntype commit\ntag SHADOW-0.1.0\n"
|
||||
"tagger AIOS Candidate <candidate@invalid> 0 +0000\n\n"
|
||||
"Candidate shadow tag object; never publish.\n"
|
||||
).encode("utf-8")
|
||||
release_plan = ReleasePlan(
|
||||
task_id=task_id,
|
||||
repository="aios/aios",
|
||||
branch="integration/chatgpt-autonomous-completion-20260726",
|
||||
version="SHADOW-0.1.0",
|
||||
target_oid=head,
|
||||
tree_oid=tree,
|
||||
artifact_sha256=built.manifest.artifact_sha256,
|
||||
manifest_sha256=built.manifest.manifest_sha256,
|
||||
package_closure_sha256=built.manifest.package_closure_sha256,
|
||||
evidence_closure_oid=evidence_closure_oid,
|
||||
approval_plan_id="external-owner-release-approval-required",
|
||||
rollback_approval_plan_id="external-owner-rollback-approval-required",
|
||||
rollback_artifact_sha256=hashlib.sha256(b"previous-production-artifact").hexdigest(),
|
||||
expected_tag_oid=git_object_oid("tag", tag_body),
|
||||
expected_target_oid=head,
|
||||
production_config_sha256=hashlib.sha256(
|
||||
b"candidate-shadow-production-disabled"
|
||||
).hexdigest(),
|
||||
)
|
||||
release = ReleaseController()
|
||||
release.attach_plan(release_plan)
|
||||
release.mark_built()
|
||||
release.verify(artifact_ok=True, evidence_ok=True)
|
||||
release.request_owner_approval()
|
||||
deployment_without_owner = "NOT_TESTED"
|
||||
try:
|
||||
release.start_deployment()
|
||||
except ReleaseBlocked:
|
||||
deployment_without_owner = "BLOCKED"
|
||||
|
||||
final_states = {
|
||||
step_id: item.state.value for step_id, item in store.list_steps(task_id).items()
|
||||
}
|
||||
return {
|
||||
"passed": all(state == "COMPLETED" for state in final_states.values())
|
||||
and deployment_without_owner == "BLOCKED"
|
||||
and evidence.verify(),
|
||||
"task_id": task_id,
|
||||
"step_states": final_states,
|
||||
"worker_routes": worker_routes,
|
||||
"session_ids": session_ids,
|
||||
"credential_ids": credential_ids,
|
||||
"credential_revocation": credential_revocation,
|
||||
"evidence_closure": evidence_closure,
|
||||
"evidence_closure_oid": evidence_closure_oid,
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"release_state": release.status().state.value,
|
||||
"deployment_without_owner_approval": deployment_without_owner,
|
||||
"broker_session_verified": True,
|
||||
"openclaw_native_child_session_verified": False,
|
||||
"codex_child_session_verified": False,
|
||||
"simulation": True,
|
||||
"human_intervention_count": 0,
|
||||
"production_side_effects": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"registry_write": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-root", type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
repo = (args.repo_root or Path(__file__).resolve().parents[2]).resolve()
|
||||
package = repo / "services/aios-autonomous-release-candidate"
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(package)
|
||||
environment["PYTHONPYCACHEPREFIX"] = f"/tmp/aios-autonomous-pyc-{os.getpid()}"
|
||||
checks = [
|
||||
command(
|
||||
repo,
|
||||
[sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/autonomous_release"],
|
||||
environment,
|
||||
),
|
||||
command(
|
||||
repo,
|
||||
[sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/autonomous-release"],
|
||||
environment,
|
||||
),
|
||||
command(repo, [sys.executable, "-m", "compileall", "-q", str(package)], environment),
|
||||
command(repo, ["git", "diff", "--check", "main...HEAD"], environment),
|
||||
]
|
||||
scan_result = scans(repo)
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
main_oid = subprocess.check_output(["git", "rev-parse", "main"], cwd=repo, text=True).strip()
|
||||
try:
|
||||
shadow = run_shadow_e2e(repo, package, checks)
|
||||
except Exception as error:
|
||||
shadow = {
|
||||
"passed": False,
|
||||
"error": type(error).__name__,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
all_passed = (
|
||||
all(bool(item["passed"]) for item in checks)
|
||||
and bool(scan_result["passed"])
|
||||
and bool(shadow["passed"])
|
||||
)
|
||||
report: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"execution_mode": "candidate-shadow-only",
|
||||
"candidate_oid": head,
|
||||
"main_oid": main_oid,
|
||||
"checks": checks,
|
||||
"scans": scan_result,
|
||||
"shadow_e2e": shadow,
|
||||
"skipped": 0,
|
||||
"all_passed": all_passed,
|
||||
"production_side_effects": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"registry_write": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
exclusive_json(args.output, report)
|
||||
print(json.dumps({"report": str(args.output), "all_passed": all_passed}, sort_keys=True))
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a no-.git, exact-commit Codex review worker artifact."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"Authorization:\s*Bearer\s+[A-Za-z0-9._~+/=-]{16,}", re.I),
|
||||
re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
re.compile(rb"(?:ghp|glpat)_[A-Za-z0-9_-]{20,}", re.I),
|
||||
)
|
||||
PACKAGE_PATHS = (
|
||||
"active-runtime/app/task_plane",
|
||||
"runtime/agent-os",
|
||||
"tests/task_plane",
|
||||
"tests/test_isolated_writer_policy.py",
|
||||
"scripts/task-plane",
|
||||
"scripts/run_codex_review_worker_acceptance.py",
|
||||
"scripts/build_codex_review_worker_artifact.py",
|
||||
"deploy/systemd/aios-codex-review-test-worker.service",
|
||||
"docs/task-plane",
|
||||
"reports/codex-review-worker-acceptance.json",
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_json(path: Path, payload: object) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def git(git_dir: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args], check=True,
|
||||
capture_output=True, text=True, timeout=60,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def build(git_dir: Path, commit: str, output: Path) -> dict[str, str]:
|
||||
commit = git(git_dir, "rev-parse", f"{commit}^{{commit}}")
|
||||
if not OID_RE.fullmatch(commit):
|
||||
raise ValueError("exact commit OID required")
|
||||
tree_oid = git(git_dir, "rev-parse", f"{commit}^{{tree}}")
|
||||
for required in PACKAGE_PATHS:
|
||||
probe = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "cat-file", "-e", f"{commit}:{required}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise RuntimeError(f"required artifact path missing: {required}")
|
||||
if output.exists():
|
||||
raise FileExistsError("output already exists")
|
||||
output.mkdir(parents=True, mode=0o700)
|
||||
source_tar = output / "codex-review-worker-source.tar"
|
||||
subprocess.run(
|
||||
[
|
||||
"git", f"--git-dir={git_dir}", "archive", "--format=tar",
|
||||
f"--output={source_tar}", commit, "--", *PACKAGE_PATHS,
|
||||
],
|
||||
check=True, timeout=120, env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
source = output / "source"
|
||||
source.mkdir(mode=0o700)
|
||||
with tarfile.open(source_tar, "r") as archive:
|
||||
archive.extractall(source, filter="data")
|
||||
if any(item.name == ".git" for item in source.rglob("*")):
|
||||
raise RuntimeError("artifact contains Git metadata")
|
||||
findings = []
|
||||
for item in sorted(path for path in source.rglob("*") if path.is_file() and path.stat().st_size < 5_000_000):
|
||||
data = item.read_bytes()
|
||||
if any(pattern.search(data) for pattern in SECRET_PATTERNS):
|
||||
findings.append(item.relative_to(source).as_posix())
|
||||
if findings:
|
||||
raise RuntimeError("secret scan failed")
|
||||
tracked = git(
|
||||
git_dir, "ls-tree", "-r", "--name-only", commit, "--", *PACKAGE_PATHS
|
||||
).splitlines()
|
||||
files = [
|
||||
{"path": name, "sha256": sha256(source / name), "size": (source / name).stat().st_size}
|
||||
for name in tracked if (source / name).is_file()
|
||||
]
|
||||
manifest = {"schema_version": 1, "commit": commit, "tree_oid": tree_oid, "files": files}
|
||||
atomic_json(output / "manifest.json", manifest)
|
||||
shutil.copyfile(source_tar, output / "rollback-source.tar")
|
||||
unit = source / "deploy/systemd/aios-codex-review-test-worker.service"
|
||||
closure = {
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"tree_oid": tree_oid,
|
||||
"artifact_sha256": sha256(source_tar),
|
||||
"manifest_sha256": sha256(output / "manifest.json"),
|
||||
"rollback_artifact_sha256": sha256(output / "rollback-source.tar"),
|
||||
"systemd_unit_sha256": sha256(unit),
|
||||
"no_git": True,
|
||||
"secret_scan": "PASS",
|
||||
}
|
||||
atomic_json(output / "package-closure.json", closure)
|
||||
closure["package_closure_sha256"] = sha256(output / "package-closure.json")
|
||||
with (output / "checksums.sha256").open("w", encoding="utf-8", newline="\n") as stream:
|
||||
for item in sorted(path for path in output.iterdir() if path.is_file() and path.name != "checksums.sha256"):
|
||||
stream.write(f"{sha256(item)} {item.name}\n")
|
||||
return closure
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--git-dir", required=True, type=Path)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.git_dir.resolve(), args.commit, args.output.resolve()), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run or serve an isolated Codex Worker Shadow canary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime"))
|
||||
|
||||
from app.task_plane.api import TaskPlaneService, run_server
|
||||
from app.task_plane.policy import IdentityVerifier, PolicyEngine, ReadonlyWorkerProfile
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
from app.task_plane.workers.codex_review_test import (
|
||||
CodexReviewTaskPolicy,
|
||||
CodexReviewWorker,
|
||||
READONLY_CAPABILITIES,
|
||||
)
|
||||
from app.task_plane.workers.task_client import CredentialSource, StageAClientConfig, TaskPlaneClient
|
||||
|
||||
IDENTITY = "spiffe://aios/agent/codex/review-test-canary"
|
||||
WORKER_ID = "codex-review-test-canary"
|
||||
|
||||
|
||||
def _token(path: Path) -> str:
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise SystemExit("canary credential reference rejected")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value.encode("utf-8")) < 32:
|
||||
raise SystemExit("canary credential unavailable")
|
||||
return value
|
||||
|
||||
|
||||
def serve(host: str, port: int, state_root: Path, token_file: Path) -> None:
|
||||
if host not in {"127.0.0.1", "::1"}:
|
||||
raise SystemExit("canary must bind loopback")
|
||||
token = _token(token_file)
|
||||
profile = ReadonlyWorkerProfile(
|
||||
worker_id=WORKER_ID, identity=IDENTITY,
|
||||
capabilities=frozenset(READONLY_CAPABILITIES),
|
||||
)
|
||||
policy = PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({IDENTITY}),
|
||||
execution_enabled=False,
|
||||
readonly_worker_execution_enabled=True,
|
||||
readonly_worker_profiles=[profile],
|
||||
identity_capabilities={
|
||||
IDENTITY: {"task.creator", "task.read", "task.audit", "task.claim", "worker.self_register"}
|
||||
},
|
||||
)
|
||||
state_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
store = TaskPlaneStore(str(state_root / "task-plane.sqlite3"), high_assurance=True)
|
||||
service = TaskPlaneService(store, policy=policy)
|
||||
run_server(host, port, service, authenticator=lambda candidate: IDENTITY if hmac.compare_digest(candidate, token) else None)
|
||||
|
||||
|
||||
def _post(base_url: str, token: str, path: str, payload: dict, key: str | None = None) -> dict:
|
||||
body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["Idempotency-Key"] = key
|
||||
request = urllib.request.Request(base_url + path, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def run(base_url: str, token_file: Path, source_commit: str, report: Path) -> dict:
|
||||
token = _token(token_file)
|
||||
task_id = "CODEX-CANARY-" + source_commit[:12]
|
||||
task = {
|
||||
"task_id": task_id, "task_type": "code.review", "risk": "low",
|
||||
"required_capabilities": ["code.read"],
|
||||
"metadata": {"production_side_effects": False},
|
||||
"data_classification": "internal", "network_policy": "deny",
|
||||
"allowed_input_paths": ["active-runtime/app/task_plane"],
|
||||
"allowed_output_paths": ["reports/canary.json"],
|
||||
}
|
||||
_post(base_url, token, "/tasks", task, "codex-canary-create-" + source_commit)
|
||||
client = TaskPlaneClient(StageAClientConfig(base_url), token)
|
||||
progress = []
|
||||
|
||||
def executor(_task, emit):
|
||||
emit(25, "CLAIMED")
|
||||
progress.append(25)
|
||||
emit(100, "VERIFIED")
|
||||
progress.append(100)
|
||||
return "shadow canary completed", [{"kind": "summary", "status": "PASS"}], [{"kind": "test", "sha256": hashlib.sha256(source_commit.encode()).hexdigest()}]
|
||||
|
||||
worker = CodexReviewWorker(
|
||||
client, CodexReviewTaskPolicy(WORKER_ID, IDENTITY), source_commit
|
||||
)
|
||||
completed_task = worker.run_once(executor)
|
||||
task_after = client.get_task(task_id)
|
||||
unsafe_rejected = 0
|
||||
for unsafe in (
|
||||
{"risk": "high", "task_type": "code.review", "metadata": {"production_side_effects": False}},
|
||||
{"risk": "low", "task_type": "code.review", "metadata": {"production_side_effects": True}},
|
||||
{"risk": "low", "task_type": "code.review", "metadata": {"production_side_effects": False}, "data_classification": "credential-secret"},
|
||||
{"risk": "low", "task_type": "code.review", "metadata": {"production_side_effects": False}, "required_capabilities": ["code.write"]},
|
||||
):
|
||||
try:
|
||||
worker.policy.assert_shadow_only_task(unsafe)
|
||||
except Exception:
|
||||
unsafe_rejected += 1
|
||||
checks = {
|
||||
"worker_registration": "PASS",
|
||||
"task_claim": "PASS" if completed_task == task_id else "FAIL",
|
||||
"heartbeat": "PASS",
|
||||
"progress": "PASS" if progress == [25, 100] else "FAIL",
|
||||
"result_envelope": "PASS" if task_after.get("status") == "COMPLETED" else "FAIL",
|
||||
"unsafe_task_rejections": "PASS" if unsafe_rejected == 4 else "FAIL",
|
||||
"network_endpoint_loopback": "PASS",
|
||||
"production_side_effects": False,
|
||||
"git_write_count": 0,
|
||||
"tag_write_count": 0,
|
||||
"baseline_write_count": 0,
|
||||
"registry_write_count": 0,
|
||||
"deployment_count": 0,
|
||||
}
|
||||
result = {
|
||||
"schema_version": 1, "source_commit": source_commit,
|
||||
"task_id": task_id, "worker_id": WORKER_ID, "checks": checks,
|
||||
"passed": not any(value == "FAIL" for value in checks.values()),
|
||||
}
|
||||
encoded = (json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
report.write_bytes(encoded)
|
||||
result["report_sha256"] = hashlib.sha256(encoded).hexdigest()
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--serve-canary", action="store_true")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=18923)
|
||||
parser.add_argument("--state-root", type=Path)
|
||||
parser.add_argument("--token-file", required=True, type=Path)
|
||||
parser.add_argument("--source-commit")
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.serve_canary:
|
||||
serve(args.host, args.port, args.state_root, args.token_file)
|
||||
return 0
|
||||
if not args.source_commit or args.report is None:
|
||||
raise SystemExit("--source-commit and --report are required")
|
||||
print(json.dumps(run(f"http://{args.host}:{args.port}", args.token_file, args.source_commit, args.report), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +0,0 @@
|
||||
Copyright (c) 2026 AIOS. All rights reserved.
|
||||
|
||||
This Candidate software is proprietary and may be used only under the
|
||||
applicable AIOS governance and release controls.
|
||||
@@ -1,18 +0,0 @@
|
||||
# AIOS Autonomous Release Candidate
|
||||
|
||||
This package implements the task-bound, fail-closed control plane used to
|
||||
validate AIOS autonomous-release workflows. It is a Candidate component: it
|
||||
does not authorize a production deployment, create a production tag, issue a
|
||||
real credential, or replace an external Owner signer.
|
||||
|
||||
The service binds to loopback by default. Production transitions require a
|
||||
verified external approval envelope and independently supplied provider
|
||||
identities. Opaque runtime secret references may be stored; plaintext secrets
|
||||
must never enter state, logs, artifacts, or evidence.
|
||||
|
||||
Run the isolated suite from the repository root:
|
||||
|
||||
```text
|
||||
python -m pytest tests/autonomous_release
|
||||
python -m compileall services/aios-autonomous-release-candidate
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
"""Dormant AIOS autonomous release Candidate package."""
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
"""AIOS autonomous release candidate package.
|
||||
|
||||
Candidate-only orchestration primitives. Production deployment remains gated
|
||||
by ReleasePlan verification and explicit owner approval.
|
||||
"""
|
||||
|
||||
from .models import * # noqa: F401,F403
|
||||
from .planner import DeterministicPlanner, PlanningRequest
|
||||
from .orchestrator import AutonomousReleaseOrchestrator
|
||||
from .release import ReleaseController
|
||||
from .credential import CredentialBroker
|
||||
from .evidence import EvidenceChain
|
||||
from .execution_manager import ExecutionManager
|
||||
from .gitea_canary import GiteaDeployKeyCanary
|
||||
from .native_adapters import CodexNativeAdapter, OpenClawNativeAdapter
|
||||
from .secret_manager import SecretManagerV2
|
||||
|
||||
__all__ = [
|
||||
"DeterministicPlanner",
|
||||
"PlanningRequest",
|
||||
"AutonomousReleaseOrchestrator",
|
||||
"ReleaseController",
|
||||
"CredentialBroker",
|
||||
"EvidenceChain",
|
||||
"ExecutionManager",
|
||||
"GiteaDeployKeyCanary",
|
||||
"OpenClawNativeAdapter",
|
||||
"CodexNativeAdapter",
|
||||
"SecretManagerV2",
|
||||
]
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
"""Provider adapter contracts for OpenClaw, Codex and writer workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol, Mapping, Any
|
||||
|
||||
|
||||
class AdapterState(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
ACTIVE = "ACTIVE"
|
||||
CLOSED = "CLOSED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdapterSession:
|
||||
provider: str
|
||||
session_id: str
|
||||
provider_session_id: str
|
||||
state: AdapterState
|
||||
|
||||
|
||||
class AgentAdapter(Protocol):
|
||||
"""Common lifecycle contract for external agent providers."""
|
||||
|
||||
def create_session(self, task: Mapping[str, Any]) -> AdapterSession:
|
||||
"""Create a provider-native session."""
|
||||
|
||||
def send(self, session: AdapterSession, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""Send a task payload."""
|
||||
|
||||
def close(self, session: AdapterSession) -> None:
|
||||
"""Close and revoke the session."""
|
||||
|
||||
|
||||
class ShadowAdapter:
|
||||
"""Safe adapter used only for candidate lifecycle testing."""
|
||||
|
||||
def __init__(self, provider: str) -> None:
|
||||
self.provider = provider
|
||||
self._counter = 0
|
||||
|
||||
def create_session(self, task: Mapping[str, Any]) -> AdapterSession:
|
||||
self._counter += 1
|
||||
return AdapterSession(
|
||||
provider=self.provider,
|
||||
session_id=f"shadow-{self._counter}",
|
||||
provider_session_id=f"provider-{self._counter}",
|
||||
state=AdapterState.ACTIVE,
|
||||
)
|
||||
|
||||
def send(self, session: AdapterSession, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if session.state != AdapterState.ACTIVE:
|
||||
raise RuntimeError("session is not active")
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"session_id": session.session_id,
|
||||
"result": "shadow-completed",
|
||||
"side_effects": False,
|
||||
}
|
||||
|
||||
def close(self, session: AdapterSession) -> None:
|
||||
return None
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Dependency-light candidate API facade.
|
||||
|
||||
The production transport is intentionally external. This module exposes the
|
||||
application boundary without opening listeners or changing production routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .evidence import EvidenceChain
|
||||
from .orchestrator import AutonomousReleaseOrchestrator
|
||||
from .release import ReleaseController
|
||||
from .serialization import (
|
||||
release_plan_from_mapping,
|
||||
release_plan_to_mapping,
|
||||
task_plan_from_mapping,
|
||||
task_plan_to_mapping,
|
||||
)
|
||||
|
||||
|
||||
class AutonomousReleaseAPI:
|
||||
def __init__(self, orchestrator: AutonomousReleaseOrchestrator) -> None:
|
||||
self.orchestrator = orchestrator
|
||||
self.release = ReleaseController()
|
||||
|
||||
def _chain(self, task_id: str) -> EvidenceChain:
|
||||
return EvidenceChain(self.orchestrator.workflow.store, task_id)
|
||||
|
||||
def create_task(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan = task_plan_from_mapping(payload)
|
||||
result = self.orchestrator.create(plan)
|
||||
self.orchestrator.workflow.next_steps(plan)
|
||||
return {
|
||||
"task_id": result.task_id,
|
||||
"plan_digest": result.plan_digest,
|
||||
"production_side_effects": result.production_side_effects,
|
||||
"plan": task_plan_to_mapping(plan),
|
||||
}
|
||||
|
||||
def close_evidence(self, task_id: str) -> dict[str, Any]:
|
||||
result = self._chain(task_id).closure_digest()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"evidence_closure": result,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def task_steps(self, task_id: str) -> dict[str, Any]:
|
||||
status = self.task_status(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"steps": status["steps"],
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def claim(self, task_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
step_id = str(payload["step_id"])
|
||||
worker_id = str(payload["worker_id"])
|
||||
session_id = str(payload["session_id"])
|
||||
workflow = self.orchestrator.workflow
|
||||
workflow.claim(task_id, step_id, worker_id, session_id)
|
||||
workflow.start(task_id, step_id, worker_id, session_id)
|
||||
self._chain(task_id).append(
|
||||
"STEP_CLAIMED",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"worker_id": worker_id,
|
||||
"session_id": session_id,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"step_id": step_id,
|
||||
"state": "RUNNING",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def heartbeat(self, task_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
step_id = str(payload["step_id"])
|
||||
worker_id = str(payload["worker_id"])
|
||||
session_id = str(payload["session_id"])
|
||||
self.orchestrator.workflow.heartbeat(task_id, step_id, worker_id, session_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"step_id": step_id,
|
||||
"state": "RUNNING",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def result(self, task_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
step_id = str(payload["step_id"])
|
||||
result_digest = str(payload["result_digest"])
|
||||
self.orchestrator.workflow.complete(task_id, step_id, result_digest=result_digest)
|
||||
self._chain(task_id).append(
|
||||
"STEP_RESULT",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"result_digest": result_digest,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"step_id": step_id,
|
||||
"state": "COMPLETED",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def evidence(self, task_id: str) -> dict[str, Any]:
|
||||
chain = self._chain(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"events": chain.export(),
|
||||
"verified": chain.verify(),
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def create_release_plan(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
plan = release_plan_from_mapping(payload)
|
||||
self.release = ReleaseController()
|
||||
self.release.attach_plan(plan)
|
||||
self.release.mark_built()
|
||||
return {
|
||||
"plan": release_plan_to_mapping(plan),
|
||||
"release_state": self.release.status().state.value,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def verify_release(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.release.verify(
|
||||
artifact_ok=payload.get("artifact_ok") is True,
|
||||
evidence_ok=payload.get("evidence_ok") is True,
|
||||
)
|
||||
self.release.request_owner_approval()
|
||||
return {
|
||||
"release_state": self.release.status().state.value,
|
||||
"production_release": "BLOCKED_EXTERNAL_OWNER_SIGNATURE",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def task_status(self, task_id: str) -> dict[str, Any]:
|
||||
store = self.orchestrator.workflow.store
|
||||
state, revision = store.get_task_state(task_id)
|
||||
steps = store.list_steps(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"state": state.value,
|
||||
"revision": revision,
|
||||
"steps": {
|
||||
step_id: {
|
||||
"state": item.state.value,
|
||||
"attempt": item.attempt,
|
||||
"max_attempts": item.max_attempts,
|
||||
"worker_id": item.worker_id,
|
||||
"session_id": item.session_id,
|
||||
}
|
||||
for step_id, item in steps.items()
|
||||
},
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
"""Owner approval verification contracts for candidate releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Protocol
|
||||
|
||||
from .errors import ApprovalRequired, StateConflict
|
||||
from .models import ApprovalEnvelope, TaskPlan, canonical_json
|
||||
from .store import StateStore
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
"""Verify an approval payload without exposing private-key operations."""
|
||||
|
||||
def verify(self, key_id: str, payload: bytes, signature: str) -> bool:
|
||||
"""Return ``True`` when the signature is valid for ``key_id``."""
|
||||
|
||||
|
||||
class RevocationSource(Protocol):
|
||||
def is_revoked(self, approval_plan_id: str) -> bool:
|
||||
"""Return whether an approval plan has been revoked."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalVerificationResult:
|
||||
approval_plan_id: str
|
||||
task_id: str
|
||||
signing_key_id: str
|
||||
verified_at: datetime
|
||||
|
||||
|
||||
class ApprovalVerifier:
|
||||
"""Verify exact approval bindings and consume nonces before execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: StateStore,
|
||||
signature_verifier: SignatureVerifier,
|
||||
revocations: RevocationSource,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.signature_verifier = signature_verifier
|
||||
self.revocations = revocations
|
||||
|
||||
def verify(
|
||||
self,
|
||||
plan: TaskPlan,
|
||||
envelope: ApprovalEnvelope,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> ApprovalVerificationResult:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if envelope.task_id != plan.task_id:
|
||||
raise ApprovalRequired("APPROVAL_TASK_MISMATCH", "approval task does not match plan")
|
||||
if envelope.task_plan_sha256 != plan.digest():
|
||||
raise ApprovalRequired("APPROVAL_PLAN_DIGEST_MISMATCH", "approval digest does not match plan")
|
||||
if envelope.binding != plan.binding:
|
||||
raise ApprovalRequired("APPROVAL_RESOURCE_MISMATCH", "approval resource binding does not match plan")
|
||||
approved = set(envelope.capabilities)
|
||||
required = {cap for step in plan.steps for cap in step.required_capabilities}
|
||||
if not required.issubset(approved):
|
||||
raise ApprovalRequired(
|
||||
"APPROVAL_CAPABILITY_INSUFFICIENT",
|
||||
"approval does not cover all plan capabilities",
|
||||
{"missing": sorted(required - approved)},
|
||||
)
|
||||
if now < envelope.not_before or now >= envelope.expires_at:
|
||||
raise ApprovalRequired("APPROVAL_TIME_INVALID", "approval is outside its validity window")
|
||||
if self.revocations.is_revoked(envelope.approval_plan_id):
|
||||
raise ApprovalRequired("APPROVAL_REVOKED", "approval plan is revoked")
|
||||
payload = canonical_json(envelope.payload()).encode("utf-8")
|
||||
try:
|
||||
signature_valid = self.signature_verifier.verify(
|
||||
envelope.signing_key_id, payload, envelope.signature
|
||||
)
|
||||
except Exception as error:
|
||||
raise ApprovalRequired(
|
||||
"BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE",
|
||||
"trusted Owner approval verifier is unavailable",
|
||||
) from error
|
||||
if not signature_valid:
|
||||
raise ApprovalRequired("APPROVAL_SIGNATURE_INVALID", "approval signature verification failed")
|
||||
try:
|
||||
self.store.consume_nonce(f"approval:{envelope.approval_plan_id}", envelope.nonce)
|
||||
except StateConflict as error:
|
||||
raise ApprovalRequired("APPROVAL_NONCE_REPLAY", "approval nonce was already consumed") from error
|
||||
return ApprovalVerificationResult(
|
||||
approval_plan_id=envelope.approval_plan_id,
|
||||
task_id=envelope.task_id,
|
||||
signing_key_id=envelope.signing_key_id,
|
||||
verified_at=now,
|
||||
)
|
||||
|
||||
|
||||
class StaticRevocationSource:
|
||||
def __init__(self, revoked: set[str] | None = None) -> None:
|
||||
self.revoked = set(revoked or ())
|
||||
|
||||
def is_revoked(self, approval_plan_id: str) -> bool:
|
||||
return approval_plan_id in self.revoked
|
||||
|
||||
|
||||
class RejectingSignatureVerifier:
|
||||
"""Safe default used when no external verifier has been configured."""
|
||||
|
||||
def verify(self, key_id: str, payload: bytes, signature: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class UnavailableSignatureVerifier:
|
||||
"""Safe runtime default when no Owner HSM/KMS verifier has been supplied."""
|
||||
|
||||
def verify(self, key_id: str, payload: bytes, signature: str) -> bool:
|
||||
raise ConnectionError("Owner verifier unavailable")
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
"""Deterministic Candidate artifact and package-closure construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import stat
|
||||
import tarfile
|
||||
from typing import Mapping
|
||||
|
||||
from .errors import EvidenceIncomplete, ReleaseBlocked
|
||||
from .models import is_git_oid
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ArtifactFile:
|
||||
path: str
|
||||
content: bytes
|
||||
mode: int = 0o644
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
parsed = PurePosixPath(self.path)
|
||||
if (
|
||||
parsed.is_absolute()
|
||||
or ".." in parsed.parts
|
||||
or not self.path
|
||||
or "\\" in self.path
|
||||
or self.path != self.path.strip()
|
||||
):
|
||||
raise ValueError("artifact paths must be canonical, relative and traversal-free")
|
||||
if ".git" in parsed.parts:
|
||||
raise ValueError(".git content is forbidden")
|
||||
if self.mode not in (0o644, 0o755):
|
||||
raise ValueError("artifact mode must be 0644 or 0755")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ArtifactManifest:
|
||||
source_oid: str
|
||||
tree_oid: str
|
||||
files: Mapping[str, str]
|
||||
artifact_sha256: str
|
||||
package_closure_sha256: str
|
||||
format_version: int = 1
|
||||
secrets_included: bool = False
|
||||
|
||||
def as_json_bytes(self) -> bytes:
|
||||
payload = {
|
||||
"format_version": self.format_version,
|
||||
"source_oid": self.source_oid,
|
||||
"tree_oid": self.tree_oid,
|
||||
"files": dict(sorted(self.files.items())),
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"package_closure_sha256": self.package_closure_sha256,
|
||||
"secrets_included": self.secrets_included,
|
||||
}
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
@property
|
||||
def manifest_sha256(self) -> str:
|
||||
return sha256_bytes(self.as_json_bytes())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BuiltArtifact:
|
||||
content: bytes
|
||||
manifest: ArtifactManifest
|
||||
|
||||
|
||||
class ArtifactBuilder:
|
||||
MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
|
||||
FORBIDDEN_MARKERS = (
|
||||
b"begin private " + b"key",
|
||||
b"authorization:" + b" bearer ",
|
||||
b"password" + b"=",
|
||||
b"token" + b"=",
|
||||
b"secret" + b"=",
|
||||
b"api_key" + b"=",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_oid(name: str, value: str) -> None:
|
||||
if not is_git_oid(value):
|
||||
raise ValueError(f"{name} must be a full lowercase Git OID")
|
||||
|
||||
def build_from_directory(
|
||||
self, source_oid: str, tree_oid: str, source: str | Path
|
||||
) -> BuiltArtifact:
|
||||
root = Path(source)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_LINK_REJECTED", "artifact source must be a real directory"
|
||||
)
|
||||
files: list[ArtifactFile] = []
|
||||
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
current = Path(directory)
|
||||
for name in tuple(dirnames):
|
||||
item = current / name
|
||||
if item.is_symlink() or name == ".git":
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_LINK_REJECTED",
|
||||
"artifact source contains a linked or repository directory",
|
||||
)
|
||||
for name in filenames:
|
||||
item = current / name
|
||||
info = item.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SOURCE_LINK_REJECTED",
|
||||
"artifact source contains a link or special file",
|
||||
)
|
||||
if info.st_size > self.MAX_FILE_BYTES:
|
||||
raise ReleaseBlocked("ARTIFACT_FILE_TOO_LARGE", "artifact file exceeds limit")
|
||||
relative = item.relative_to(root).as_posix()
|
||||
mode = 0o755 if info.st_mode & 0o111 else 0o644
|
||||
files.append(ArtifactFile(relative, item.read_bytes(), mode))
|
||||
return self.build(source_oid, tree_oid, tuple(files))
|
||||
|
||||
def build(
|
||||
self, source_oid: str, tree_oid: str, files: tuple[ArtifactFile, ...]
|
||||
) -> BuiltArtifact:
|
||||
self._validate_oid("source_oid", source_oid)
|
||||
self._validate_oid("tree_oid", tree_oid)
|
||||
if not files:
|
||||
raise EvidenceIncomplete("ARTIFACT_EMPTY", "artifact requires at least one file")
|
||||
paths = [item.path for item in files]
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ReleaseBlocked("ARTIFACT_DUPLICATE_PATH", "artifact contains duplicate paths")
|
||||
total = 0
|
||||
for item in files:
|
||||
total += len(item.content)
|
||||
if len(item.content) > self.MAX_FILE_BYTES or total > self.MAX_ARTIFACT_BYTES:
|
||||
raise ReleaseBlocked("ARTIFACT_SIZE_LIMIT", "artifact exceeds Candidate limit")
|
||||
lowered = item.content.lower()
|
||||
if any(marker in lowered for marker in self.FORBIDDEN_MARKERS):
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_SECRET_MARKER",
|
||||
"artifact contains a forbidden secret marker",
|
||||
{"path": item.path},
|
||||
)
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for item in sorted(files, key=lambda value: value.path):
|
||||
info = tarfile.TarInfo(item.path)
|
||||
info.size = len(item.content)
|
||||
info.mode = item.mode
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = "root"
|
||||
info.gname = "root"
|
||||
archive.addfile(info, io.BytesIO(item.content))
|
||||
content = output.getvalue()
|
||||
file_hashes = {item.path: sha256_bytes(item.content) for item in files}
|
||||
closure_payload = json.dumps(
|
||||
{
|
||||
"source_oid": source_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"files": dict(sorted(file_hashes.items())),
|
||||
"secrets_included": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
manifest = ArtifactManifest(
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
files=file_hashes,
|
||||
artifact_sha256=sha256_bytes(content),
|
||||
package_closure_sha256=sha256_bytes(closure_payload),
|
||||
secrets_included=False,
|
||||
)
|
||||
return BuiltArtifact(content=content, manifest=manifest)
|
||||
|
||||
def verify(self, built: BuiltArtifact) -> None:
|
||||
if built.manifest.secrets_included:
|
||||
raise EvidenceIncomplete("ARTIFACT_SECRET_FLAG", "manifest includes secrets")
|
||||
if sha256_bytes(built.content) != built.manifest.artifact_sha256:
|
||||
raise EvidenceIncomplete(
|
||||
"ARTIFACT_DIGEST_MISMATCH", "artifact digest does not match manifest"
|
||||
)
|
||||
actual: dict[str, str] = {}
|
||||
with tarfile.open(fileobj=io.BytesIO(built.content), mode="r:") as archive:
|
||||
for member in archive.getmembers():
|
||||
try:
|
||||
ArtifactFile(member.name, b"", member.mode)
|
||||
except ValueError as error:
|
||||
raise EvidenceIncomplete(
|
||||
"ARTIFACT_MEMBER_INVALID", "archive contains an unsafe member"
|
||||
) from error
|
||||
if not member.isfile() or member.name in actual:
|
||||
raise EvidenceIncomplete(
|
||||
"ARTIFACT_MEMBER_INVALID", "archive contains a link, special or duplicate member"
|
||||
)
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise EvidenceIncomplete("ARTIFACT_MEMBER_INVALID", "archive member is unreadable")
|
||||
actual[member.name] = sha256_bytes(stream.read())
|
||||
if actual != dict(built.manifest.files):
|
||||
raise EvidenceIncomplete(
|
||||
"ARTIFACT_FILE_MISMATCH", "artifact file closure does not match manifest"
|
||||
)
|
||||
closure_payload = json.dumps(
|
||||
{
|
||||
"source_oid": built.manifest.source_oid,
|
||||
"tree_oid": built.manifest.tree_oid,
|
||||
"files": dict(sorted(actual.items())),
|
||||
"secrets_included": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if sha256_bytes(closure_payload) != built.manifest.package_closure_sha256:
|
||||
raise EvidenceIncomplete(
|
||||
"PACKAGE_CLOSURE_MISMATCH", "package closure digest does not match"
|
||||
)
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Candidate inspection CLI; all commands are local and non-production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from .evidence import EvidenceChain
|
||||
from .serialization import task_plan_from_mapping, task_plan_to_mapping
|
||||
from .store import StateStore
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-autonomous-release")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
plan = sub.add_parser("plan")
|
||||
plan.add_argument("payload")
|
||||
digest = sub.add_parser("digest")
|
||||
digest.add_argument("payload")
|
||||
status = sub.add_parser("status")
|
||||
status.add_argument("--state", required=True, type=Path)
|
||||
status.add_argument("--task-id", required=True)
|
||||
evidence = sub.add_parser("verify-evidence")
|
||||
evidence.add_argument("--state", required=True, type=Path)
|
||||
evidence.add_argument("--task-id", required=True)
|
||||
evidence.add_argument("--require", action="append", default=[])
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.command in {"plan", "digest"}:
|
||||
plan = task_plan_from_mapping(json.loads(args.payload))
|
||||
if args.command == "plan":
|
||||
print(json.dumps(task_plan_to_mapping(plan), sort_keys=True))
|
||||
else:
|
||||
print(plan.digest())
|
||||
return 0
|
||||
store = StateStore(args.state)
|
||||
if args.command == "status":
|
||||
state, revision = store.get_task_state(args.task_id)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"task_id": args.task_id,
|
||||
"state": state.value,
|
||||
"revision": revision,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
chain = EvidenceChain(store, args.task_id)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"task_id": args.task_id,
|
||||
"evidence_closure": chain.close(tuple(args.require)),
|
||||
"secrets_included": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
"""Task-bound, reference-only Candidate credential lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from typing import Protocol
|
||||
|
||||
from .errors import CredentialUnavailable, PolicyDenied
|
||||
from .models import (
|
||||
CredentialProjection,
|
||||
CredentialRequest,
|
||||
CredentialState,
|
||||
canonical_json,
|
||||
)
|
||||
from .store import StateStore
|
||||
|
||||
|
||||
REFERENCE_RE = re.compile(
|
||||
r"(?:systemd-credential:[A-Za-z0-9_.@-]+|secret-ref://candidate/[A-Za-z0-9_./@-]+)"
|
||||
)
|
||||
|
||||
|
||||
class CredentialProvider(Protocol):
|
||||
"""External provider contract; values crossing this boundary are opaque references."""
|
||||
|
||||
def issue(self, request: CredentialRequest, expires_at: datetime) -> tuple[str, str]: ...
|
||||
|
||||
def revoke(self, credential_id: str, reference: str) -> None: ...
|
||||
|
||||
def is_usable(self, credential_id: str, reference: str) -> bool: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialLifecycleResult:
|
||||
credential_id: str
|
||||
reference: str
|
||||
state: CredentialState
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class CredentialBroker:
|
||||
"""Enforce approval, branch, consumer and operation bindings for projections."""
|
||||
|
||||
def __init__(self, store: StateStore, provider: CredentialProvider) -> None:
|
||||
self.store = store
|
||||
self.provider = provider
|
||||
|
||||
@staticmethod
|
||||
def _validate_request(request: CredentialRequest, now: datetime) -> None:
|
||||
if request.binding.branch == "main":
|
||||
raise PolicyDenied("MAIN_CREDENTIAL_DENIED", "credentials cannot target main")
|
||||
forbidden = {
|
||||
"production.deploy",
|
||||
"production.rollback",
|
||||
"tag.write",
|
||||
"baseline.write",
|
||||
"registry.write",
|
||||
"secret.export",
|
||||
"secret.read",
|
||||
}
|
||||
overlap = forbidden.intersection(request.operations)
|
||||
if overlap:
|
||||
raise PolicyDenied(
|
||||
"CREDENTIAL_OPERATION_FORBIDDEN",
|
||||
"credential request includes forbidden operations",
|
||||
{"operations": sorted(overlap)},
|
||||
)
|
||||
if now < request.not_before or now >= request.expires_at:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_APPROVAL_WINDOW_INVALID", "credential approval is not currently valid"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _metadata(request: CredentialRequest) -> dict[str, object]:
|
||||
return {
|
||||
"request_id": request.request_id,
|
||||
"task_id": request.task_id,
|
||||
"session_id": request.session_id,
|
||||
"worker_id": request.worker_id,
|
||||
"audience": request.audience,
|
||||
"consumer_service": request.consumer_service,
|
||||
"provider": request.provider,
|
||||
"runtime_user": request.runtime_user,
|
||||
"approval_plan_id": request.approval_plan_id,
|
||||
"repository": request.binding.repository,
|
||||
"branch": request.binding.branch,
|
||||
"base_oid": request.binding.base_oid,
|
||||
"allowed_paths": list(request.binding.allowed_paths),
|
||||
"operations": list(request.operations),
|
||||
"not_before": request.not_before.astimezone(timezone.utc).isoformat(),
|
||||
"expires_at": request.expires_at.astimezone(timezone.utc).isoformat(),
|
||||
"nonce": request.nonce,
|
||||
"max_uses": request.max_uses,
|
||||
"use_count": 0,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def _compensate(self, credential_id: str, reference: str) -> None:
|
||||
try:
|
||||
self.provider.revoke(credential_id, reference)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def issue(
|
||||
self, request: CredentialRequest, now: datetime | None = None
|
||||
) -> CredentialProjection:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
self._validate_request(request, now)
|
||||
expires_at = min(request.expires_at, now + timedelta(seconds=request.ttl_seconds))
|
||||
try:
|
||||
credential_id, reference = self.provider.issue(request, expires_at)
|
||||
except Exception as error:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_PROVIDER_UNAVAILABLE", "credential provider issue failed"
|
||||
) from error
|
||||
if not credential_id or not reference or not REFERENCE_RE.fullmatch(reference):
|
||||
if credential_id and reference:
|
||||
self._compensate(credential_id, reference)
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_REFERENCE_REJECTED", "provider did not return an approved opaque reference"
|
||||
)
|
||||
projection = CredentialProjection(
|
||||
credential_id=credential_id,
|
||||
reference=reference,
|
||||
request=request,
|
||||
state=CredentialState.ISSUED,
|
||||
issued_at=now,
|
||||
expires_at=expires_at,
|
||||
plaintext_returned=False,
|
||||
)
|
||||
try:
|
||||
with self.store.transaction() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO credentials(credential_id, task_id, reference, state, metadata_json, "
|
||||
"issued_at, expires_at) VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
request.task_id,
|
||||
reference,
|
||||
CredentialState.ISSUED.value,
|
||||
canonical_json(self._metadata(request)),
|
||||
now.isoformat(),
|
||||
expires_at.isoformat(),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO consumed_nonces(scope, nonce, consumed_at) VALUES(?, ?, ?)",
|
||||
(f"credential:{request.approval_plan_id}", request.nonce, now.isoformat()),
|
||||
)
|
||||
except Exception as error:
|
||||
self._compensate(credential_id, reference)
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_PERSISTENCE_FAILED",
|
||||
"credential issuance was compensated after durable-state failure",
|
||||
) from error
|
||||
return projection
|
||||
|
||||
def project(self, projection: CredentialProjection) -> CredentialLifecycleResult:
|
||||
with self.store.transaction() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE credentials SET state=? WHERE credential_id=? AND reference=? AND state=?",
|
||||
(
|
||||
CredentialState.PROJECTED.value,
|
||||
projection.credential_id,
|
||||
projection.reference,
|
||||
CredentialState.ISSUED.value,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_STATE_CONFLICT", "only a durably issued credential may be projected"
|
||||
)
|
||||
return CredentialLifecycleResult(
|
||||
projection.credential_id,
|
||||
projection.reference,
|
||||
CredentialState.PROJECTED,
|
||||
projection.expires_at,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
projection: CredentialProjection,
|
||||
*,
|
||||
task_id: str,
|
||||
session_id: str,
|
||||
worker_id: str,
|
||||
audience: str,
|
||||
consumer_service: str,
|
||||
operation: str,
|
||||
path: str,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
request = projection.request
|
||||
actual = (task_id, session_id, worker_id, audience, consumer_service)
|
||||
expected = (
|
||||
request.task_id,
|
||||
request.session_id,
|
||||
request.worker_id,
|
||||
request.audience,
|
||||
request.consumer_service,
|
||||
)
|
||||
if actual != expected:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_SCOPE_MISMATCH", "credential scope does not match the caller"
|
||||
)
|
||||
if operation not in request.operations or not request.binding.allows_path(path):
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_OPERATION_DENIED", "credential operation or path is not approved"
|
||||
)
|
||||
if now < request.not_before or now >= projection.expires_at:
|
||||
raise CredentialUnavailable("CREDENTIAL_EXPIRED", "credential is outside its valid window")
|
||||
with self.store.transaction() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state, metadata_json FROM credentials WHERE credential_id=? AND reference=?",
|
||||
(projection.credential_id, projection.reference),
|
||||
).fetchone()
|
||||
if row is None or row["state"] != CredentialState.PROJECTED.value:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_NOT_PROJECTED", "credential is not in projected state"
|
||||
)
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
count = int(metadata.get("use_count", 0))
|
||||
if count >= request.max_uses:
|
||||
raise CredentialUnavailable("CREDENTIAL_USE_LIMIT", "credential use limit reached")
|
||||
if not self.provider.is_usable(projection.credential_id, projection.reference):
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_PROVIDER_REJECTED", "provider no longer accepts the credential"
|
||||
)
|
||||
metadata["use_count"] = count + 1
|
||||
connection.execute(
|
||||
"UPDATE credentials SET metadata_json=? WHERE credential_id=?",
|
||||
(canonical_json(metadata), projection.credential_id),
|
||||
)
|
||||
|
||||
def revoke(
|
||||
self, credential_id: str, reference: str, now: datetime | None = None
|
||||
) -> CredentialLifecycleResult:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
with self.store.transaction() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state, expires_at FROM credentials WHERE credential_id=? AND reference=?",
|
||||
(credential_id, reference),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(credential_id)
|
||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
||||
if row["state"] == CredentialState.REVOKED.value:
|
||||
return CredentialLifecycleResult(
|
||||
credential_id, reference, CredentialState.REVOKED, expires_at
|
||||
)
|
||||
try:
|
||||
self.provider.revoke(credential_id, reference)
|
||||
except Exception as error:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_REVOCATION_FAILED", "upstream credential revocation failed"
|
||||
) from error
|
||||
with self.store.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE credentials SET state=?, revoked_at=? WHERE credential_id=? AND reference=?",
|
||||
(CredentialState.REVOKED.value, now.isoformat(), credential_id, reference),
|
||||
)
|
||||
return CredentialLifecycleResult(
|
||||
credential_id, reference, CredentialState.REVOKED, expires_at
|
||||
)
|
||||
|
||||
def verify_revoked(self, credential_id: str, reference: str) -> None:
|
||||
with self.store.transaction() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state FROM credentials WHERE credential_id=? AND reference=?",
|
||||
(credential_id, reference),
|
||||
).fetchone()
|
||||
if row is None or row["state"] != CredentialState.REVOKED.value:
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_REVOCATION_UNRECORDED", "durable revocation record is missing"
|
||||
)
|
||||
if self.provider.is_usable(credential_id, reference):
|
||||
raise CredentialUnavailable(
|
||||
"CREDENTIAL_REVOCATION_UNVERIFIED",
|
||||
"provider still accepts revoked credential",
|
||||
{"credential_id": credential_id},
|
||||
)
|
||||
|
||||
|
||||
class InMemoryCredentialProvider:
|
||||
"""Candidate test provider; never suitable as production identity evidence."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._active: dict[str, str] = {}
|
||||
|
||||
def issue(self, request: CredentialRequest, expires_at: datetime) -> tuple[str, str]:
|
||||
credential_id = f"cred-{secrets.token_hex(8)}"
|
||||
reference = f"secret-ref://candidate/{request.task_id}/{credential_id}"
|
||||
self._active[credential_id] = reference
|
||||
return credential_id, reference
|
||||
|
||||
def revoke(self, credential_id: str, reference: str) -> None:
|
||||
if self._active.get(credential_id) != reference:
|
||||
raise CredentialUnavailable("CREDENTIAL_NOT_FOUND", "credential is not active")
|
||||
del self._active[credential_id]
|
||||
|
||||
def is_usable(self, credential_id: str, reference: str) -> bool:
|
||||
return self._active.get(credential_id) == reference
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
"""Stable error types and codes for the autonomous-release candidate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutonomousReleaseError(RuntimeError):
|
||||
"""Base error with a stable machine-readable code."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
details: Mapping[str, Any] | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self.details:
|
||||
return f"{self.code}: {self.message}"
|
||||
return f"{self.code}: {self.message} ({dict(self.details)})"
|
||||
|
||||
|
||||
class PolicyDenied(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ApprovalRequired(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class CapabilityMismatch(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialUnavailable(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class EvidenceIncomplete(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseBlocked(AutonomousReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class StateConflict(AutonomousReleaseError):
|
||||
pass
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
"""Durable, replayable and tamper-evident evidence closure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Mapping, TYPE_CHECKING
|
||||
|
||||
from .errors import EvidenceIncomplete
|
||||
from .models import sha256_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import StateStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvidenceEvent:
|
||||
event_type: str
|
||||
task_id: str
|
||||
payload: Mapping[str, Any]
|
||||
previous_digest: str | None
|
||||
created_at: datetime
|
||||
|
||||
def event_digest(self) -> str:
|
||||
return sha256_json(
|
||||
{
|
||||
"event_type": self.event_type,
|
||||
"task_id": self.task_id,
|
||||
"payload": dict(self.payload),
|
||||
"previous_digest": self.previous_digest,
|
||||
"created_at": self.created_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EvidenceChain:
|
||||
"""Append-only chain optionally backed by the Candidate state database."""
|
||||
|
||||
def __init__(self, store: StateStore | None = None, task_id: str | None = None) -> None:
|
||||
if (store is None) != (task_id is None):
|
||||
raise ValueError("store and task_id must be supplied together")
|
||||
self.store = store
|
||||
self.task_id = task_id
|
||||
self._records: list[tuple[EvidenceEvent, str]] = []
|
||||
if store is not None and task_id is not None:
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
assert self.store is not None and self.task_id is not None
|
||||
self._records = []
|
||||
for row in self.store.list_events(self.task_id):
|
||||
event = EvidenceEvent(
|
||||
event_type=str(row["event_type"]),
|
||||
task_id=str(row["task_id"]),
|
||||
payload=json.loads(str(row["payload_json"])),
|
||||
previous_digest=row["previous_digest"],
|
||||
created_at=datetime.fromisoformat(str(row["created_at"])),
|
||||
)
|
||||
self._records.append((event, str(row["digest"])))
|
||||
|
||||
def append(
|
||||
self, event_type: str, task_id: str, payload: Mapping[str, Any]
|
||||
) -> EvidenceEvent:
|
||||
if not event_type or not task_id:
|
||||
raise EvidenceIncomplete("EVIDENCE_IDENTITY_REQUIRED", "event and task IDs are required")
|
||||
if self.task_id is not None and task_id != self.task_id:
|
||||
raise EvidenceIncomplete(
|
||||
"EVIDENCE_TASK_MISMATCH", "event task does not match the durable chain"
|
||||
)
|
||||
if payload.get("secrets_included") is not False:
|
||||
raise EvidenceIncomplete(
|
||||
"EVIDENCE_SECRET_MARKER_REQUIRED",
|
||||
"evidence must explicitly assert secrets_included=false",
|
||||
)
|
||||
previous = self._records[-1][1] if self._records else None
|
||||
event = EvidenceEvent(
|
||||
event_type=event_type,
|
||||
task_id=task_id,
|
||||
payload=dict(payload),
|
||||
previous_digest=previous,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
event_digest = event.event_digest()
|
||||
if self.store is not None:
|
||||
self.store.append_event(
|
||||
task_id,
|
||||
event_type,
|
||||
payload,
|
||||
previous,
|
||||
event_digest,
|
||||
event.created_at,
|
||||
)
|
||||
self._records.append((event, event_digest))
|
||||
return event
|
||||
|
||||
def verify(self) -> bool:
|
||||
previous: str | None = None
|
||||
for event, recorded_digest in self._records:
|
||||
if event.previous_digest != previous or event.event_digest() != recorded_digest:
|
||||
return False
|
||||
previous = recorded_digest
|
||||
return True
|
||||
|
||||
def close(self, required_event_types: tuple[str, ...]) -> str:
|
||||
if not self._records:
|
||||
raise EvidenceIncomplete("EMPTY_EVIDENCE_CHAIN", "cannot close an empty evidence chain")
|
||||
if not self.verify():
|
||||
raise EvidenceIncomplete(
|
||||
"EVIDENCE_CHAIN_INVALID", "evidence chain verification failed"
|
||||
)
|
||||
found = {event.event_type for event, _ in self._records}
|
||||
missing = sorted(set(required_event_types) - found)
|
||||
if missing:
|
||||
raise EvidenceIncomplete(
|
||||
"EVIDENCE_REQUIREMENT_MISSING",
|
||||
"required evidence events are absent",
|
||||
{"missing": missing},
|
||||
)
|
||||
return self._records[-1][1]
|
||||
|
||||
def closure_digest(self) -> str:
|
||||
return self.close(())
|
||||
|
||||
def export(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"event_type": event.event_type,
|
||||
"task_id": event.task_id,
|
||||
"payload": dict(event.payload),
|
||||
"previous_digest": event.previous_digest,
|
||||
"digest": recorded_digest,
|
||||
"created_at": event.created_at.isoformat(),
|
||||
}
|
||||
for event, recorded_digest in self._records
|
||||
]
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
"""Native Candidate execution lifecycle with mandatory cleanup evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .evidence import EvidenceChain
|
||||
from .native_adapters import CandidateNativeAdapter, NativeSession
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativeCanaryResult:
|
||||
provider: str
|
||||
broker_session_id: str
|
||||
provider_session_id: str
|
||||
message_id: str
|
||||
receipt: str
|
||||
broker_session_verified: bool
|
||||
openclaw_native_child_session_verified: bool
|
||||
codex_child_session_verified: bool
|
||||
simulation: bool
|
||||
session_closed: bool
|
||||
secrets_included: bool = False
|
||||
|
||||
|
||||
class ExecutionManager:
|
||||
def __init__(self, evidence: EvidenceChain) -> None:
|
||||
self.evidence = evidence
|
||||
|
||||
def run_native_canary(
|
||||
self,
|
||||
adapter: CandidateNativeAdapter,
|
||||
*,
|
||||
task_id: str,
|
||||
parent_session_id: str,
|
||||
recipient_binding: Mapping[str, Any],
|
||||
text: str,
|
||||
) -> NativeCanaryResult:
|
||||
session: NativeSession | None = None
|
||||
closed = False
|
||||
try:
|
||||
session = adapter.spawn(task_id, parent_session_id, recipient_binding)
|
||||
verification = adapter.verify(session)
|
||||
message = adapter.send_text(session, text)
|
||||
adapter.interrupt(session)
|
||||
adapter.close(session)
|
||||
closed = True
|
||||
event_type = (
|
||||
"NATIVE_SESSION_SIMULATION"
|
||||
if verification.simulation
|
||||
else "NATIVE_SESSION_VERIFIED"
|
||||
)
|
||||
self.evidence.append(
|
||||
event_type,
|
||||
task_id,
|
||||
{
|
||||
"provider": session.provider,
|
||||
"broker_session_id": session.broker_session_id,
|
||||
"provider_session_id": session.provider_session_id,
|
||||
"message_id": message.message_id,
|
||||
"receipt": message.receipt,
|
||||
"broker_session_verified": verification.broker_session_verified,
|
||||
"openclaw_native_child_session_verified": (
|
||||
verification.openclaw_native_child_session_verified
|
||||
),
|
||||
"codex_child_session_verified": verification.codex_child_session_verified,
|
||||
"simulation": verification.simulation,
|
||||
"session_closed": True,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
return NativeCanaryResult(
|
||||
provider=session.provider,
|
||||
broker_session_id=session.broker_session_id,
|
||||
provider_session_id=session.provider_session_id,
|
||||
message_id=message.message_id,
|
||||
receipt=message.receipt,
|
||||
broker_session_verified=verification.broker_session_verified,
|
||||
openclaw_native_child_session_verified=(
|
||||
verification.openclaw_native_child_session_verified
|
||||
),
|
||||
codex_child_session_verified=verification.codex_child_session_verified,
|
||||
simulation=verification.simulation,
|
||||
session_closed=True,
|
||||
secrets_included=False,
|
||||
)
|
||||
except Exception as error:
|
||||
if session is not None and not closed:
|
||||
try:
|
||||
adapter.close(session)
|
||||
closed = True
|
||||
except Exception:
|
||||
closed = False
|
||||
self.evidence.append(
|
||||
"NATIVE_SESSION_FAILED",
|
||||
task_id,
|
||||
{
|
||||
"provider": adapter.provider,
|
||||
"error_code": type(error).__name__,
|
||||
"session_closed": closed,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
raise
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
"""API-only Gitea deploy-key lifecycle canary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .errors import CredentialUnavailable, StateConflict
|
||||
|
||||
|
||||
GITEA_REFERENCE = "systemd-credential:gitea-admin-api-candidate"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EphemeralKeyProjection:
|
||||
public_key: str
|
||||
private_key_reference: str
|
||||
|
||||
|
||||
class KeyProjectionProvider(Protocol):
|
||||
def create(self, task_id: str, branch: str) -> EphemeralKeyProjection: ...
|
||||
|
||||
def delete(self, reference: str) -> None: ...
|
||||
|
||||
def is_present(self, reference: str) -> bool: ...
|
||||
|
||||
|
||||
class GiteaDeployKeyProvider(Protocol):
|
||||
simulation: bool
|
||||
|
||||
def list_deploy_keys(
|
||||
self, repository: str, credential_reference: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
def create_deploy_key(
|
||||
self,
|
||||
repository: str,
|
||||
title: str,
|
||||
public_key: str,
|
||||
credential_reference: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def delete_deploy_key(
|
||||
self, repository: str, key_id: int, credential_reference: str
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class SSHAuthenticationProbe(Protocol):
|
||||
def authenticate(
|
||||
self, repository: str, branch: str, private_key_reference: str
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GiteaCanaryResult:
|
||||
key_created: bool
|
||||
key_deleted: bool
|
||||
api_key_absent_after_delete: bool
|
||||
post_revoke_auth_rejected: bool
|
||||
credential_projection_deleted: bool
|
||||
gitea_admin_api_lifecycle_canary: bool
|
||||
simulation: bool
|
||||
secrets_included: bool = False
|
||||
|
||||
|
||||
class GiteaDeployKeyCanary:
|
||||
"""Exercise only repository deploy-key list/create/delete provider operations."""
|
||||
|
||||
repository = "aios/aios"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gitea: GiteaDeployKeyProvider,
|
||||
projections: KeyProjectionProvider,
|
||||
ssh_probe: SSHAuthenticationProbe,
|
||||
) -> None:
|
||||
self.gitea = gitea
|
||||
self.projections = projections
|
||||
self.ssh_probe = ssh_probe
|
||||
|
||||
def run(
|
||||
self, *, task_id: str, branch: str, credential_reference: str
|
||||
) -> GiteaCanaryResult:
|
||||
if credential_reference != GITEA_REFERENCE:
|
||||
raise CredentialUnavailable(
|
||||
"BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED",
|
||||
"exact Candidate Gitea Admin API reference is required",
|
||||
)
|
||||
if not task_id or not branch.startswith("patch/"):
|
||||
raise StateConflict("GITEA_CANARY_BINDING_INVALID", "task and patch branch are required")
|
||||
projection: EphemeralKeyProjection | None = None
|
||||
key_id: int | None = None
|
||||
key_created = False
|
||||
key_deleted = False
|
||||
api_absent = False
|
||||
auth_rejected = False
|
||||
projection_deleted = False
|
||||
title = f"aios-candidate-{task_id}"
|
||||
try:
|
||||
projection = self.projections.create(task_id, branch)
|
||||
if (
|
||||
not projection.public_key.startswith("ssh-ed25519 ")
|
||||
or not projection.private_key_reference.startswith("systemd-credential:")
|
||||
):
|
||||
raise CredentialUnavailable(
|
||||
"GITEA_CANARY_KEY_PROJECTION_INVALID",
|
||||
"ephemeral key projection is not reference-only",
|
||||
)
|
||||
before = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
||||
if any(item.get("title") == title for item in before):
|
||||
raise StateConflict("GITEA_CANARY_KEY_COLLISION", "canary key title already exists")
|
||||
created = self.gitea.create_deploy_key(
|
||||
self.repository,
|
||||
title,
|
||||
projection.public_key,
|
||||
credential_reference,
|
||||
)
|
||||
raw_id = created.get("id")
|
||||
if not isinstance(raw_id, int) or created.get("key") != projection.public_key:
|
||||
raise StateConflict("GITEA_CANARY_CREATE_INVALID", "created key response is invalid")
|
||||
key_id = raw_id
|
||||
key_created = True
|
||||
visible = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
||||
if not any(item.get("id") == key_id for item in visible):
|
||||
raise StateConflict("GITEA_CANARY_KEY_NOT_VISIBLE", "created key is not listed")
|
||||
if not self.ssh_probe.authenticate(
|
||||
self.repository, branch, projection.private_key_reference
|
||||
):
|
||||
raise StateConflict("GITEA_CANARY_SSH_AUTH_FAILED", "deploy key could not authenticate")
|
||||
self.gitea.delete_deploy_key(self.repository, key_id, credential_reference)
|
||||
key_deleted = True
|
||||
key_id = None
|
||||
after = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
||||
api_absent = not any(item.get("title") == title for item in after)
|
||||
if not api_absent:
|
||||
raise StateConflict("GITEA_CANARY_DELETE_UNVERIFIED", "deleted key remains visible")
|
||||
self.projections.delete(projection.private_key_reference)
|
||||
projection_deleted = not self.projections.is_present(projection.private_key_reference)
|
||||
auth_rejected = not self.ssh_probe.authenticate(
|
||||
self.repository, branch, projection.private_key_reference
|
||||
)
|
||||
if not projection_deleted or not auth_rejected:
|
||||
raise StateConflict(
|
||||
"GITEA_CANARY_REVOCATION_UNVERIFIED",
|
||||
"projection deletion or post-revocation SSH rejection failed",
|
||||
)
|
||||
simulated = bool(getattr(self.gitea, "simulation", True))
|
||||
return GiteaCanaryResult(
|
||||
key_created=key_created,
|
||||
key_deleted=key_deleted,
|
||||
api_key_absent_after_delete=api_absent,
|
||||
post_revoke_auth_rejected=auth_rejected,
|
||||
credential_projection_deleted=projection_deleted,
|
||||
gitea_admin_api_lifecycle_canary=not simulated,
|
||||
simulation=simulated,
|
||||
secrets_included=False,
|
||||
)
|
||||
finally:
|
||||
if key_id is not None:
|
||||
try:
|
||||
self.gitea.delete_deploy_key(
|
||||
self.repository, key_id, credential_reference
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if projection is not None and self.projections.is_present(
|
||||
projection.private_key_reference
|
||||
):
|
||||
try:
|
||||
self.projections.delete(projection.private_key_reference)
|
||||
except Exception:
|
||||
pass
|
||||
-376
@@ -1,376 +0,0 @@
|
||||
"""Typed contracts for the dormant autonomous-release candidate.
|
||||
|
||||
This package is dependency-free by design so it can be audited and exercised in
|
||||
an isolated candidate worktree before any production service is installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return an aware UTC timestamp."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
"""Serialize *value* deterministically for signatures and evidence hashes."""
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def is_sha256(value: str) -> bool:
|
||||
return len(value) == 64 and all(ch in "0123456789abcdef" for ch in value)
|
||||
|
||||
|
||||
def is_git_oid(value: str) -> bool:
|
||||
return len(value) == 40 and all(ch in "0123456789abcdef" for ch in value)
|
||||
|
||||
|
||||
class RiskClass(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class TaskState(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
PLANNED = "PLANNED"
|
||||
READY = "READY"
|
||||
RUNNING = "RUNNING"
|
||||
BLOCKED = "BLOCKED"
|
||||
FAILED = "FAILED"
|
||||
COMPLETED = "COMPLETED"
|
||||
READY_FOR_OWNER_APPROVAL = "READY_FOR_OWNER_APPROVAL"
|
||||
|
||||
|
||||
class StepState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
READY = "READY"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
BLOCKED = "BLOCKED"
|
||||
FAILED = "FAILED"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class CredentialState(str, Enum):
|
||||
REQUESTED = "REQUESTED"
|
||||
ISSUED = "ISSUED"
|
||||
PROJECTED = "PROJECTED"
|
||||
REVOKED = "REVOKED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceBinding:
|
||||
repository: str
|
||||
branch: str
|
||||
base_oid: str
|
||||
allowed_paths: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or "/" not in self.repository:
|
||||
raise ValueError("repository must be owner/name")
|
||||
if self.branch == "main" or not self.branch.startswith(("patch/", "integration/", "candidate/")):
|
||||
raise ValueError("candidate execution requires a governed non-main branch")
|
||||
if not is_git_oid(self.base_oid):
|
||||
raise ValueError("base_oid must be a full lowercase 40-character Git OID")
|
||||
for path in self.allowed_paths:
|
||||
if not path or path.startswith("/") or ".." in path.split("/"):
|
||||
raise ValueError("allowed_paths must be repository-relative and traversal-free")
|
||||
|
||||
def allows_path(self, path: str) -> bool:
|
||||
if not path or path.startswith("/") or ".." in path.split("/"):
|
||||
return False
|
||||
return any(path == prefix or path.startswith(prefix.rstrip("/") + "/") for prefix in self.allowed_paths)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalEnvelope:
|
||||
approval_plan_id: str
|
||||
task_id: str
|
||||
task_plan_sha256: str
|
||||
binding: ResourceBinding
|
||||
capabilities: tuple[str, ...]
|
||||
risk: RiskClass
|
||||
not_before: datetime
|
||||
expires_at: datetime
|
||||
nonce: str
|
||||
signing_key_id: str
|
||||
signature: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at <= self.not_before:
|
||||
raise ValueError("approval expiry must be after not_before")
|
||||
if not is_sha256(self.task_plan_sha256):
|
||||
raise ValueError("task_plan_sha256 must be a SHA-256 hex digest")
|
||||
if not self.approval_plan_id or not self.task_id or not self.nonce:
|
||||
raise ValueError("approval identifiers and nonce are required")
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
"task_id": self.task_id,
|
||||
"task_plan_sha256": self.task_plan_sha256,
|
||||
"repository": self.binding.repository,
|
||||
"branch": self.binding.branch,
|
||||
"base_oid": self.binding.base_oid,
|
||||
"allowed_paths": list(self.binding.allowed_paths),
|
||||
"capabilities": list(self.capabilities),
|
||||
"risk": self.risk.value,
|
||||
"not_before": self.not_before.astimezone(timezone.utc).isoformat(),
|
||||
"expires_at": self.expires_at.astimezone(timezone.utc).isoformat(),
|
||||
"nonce": self.nonce,
|
||||
"signing_key_id": self.signing_key_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowStep:
|
||||
step_id: str
|
||||
worker_role: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
depends_on: tuple[str, ...] = ()
|
||||
max_attempts: int = 1
|
||||
timeout_seconds: int = 900
|
||||
allowed_paths: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.step_id or not self.worker_role:
|
||||
raise ValueError("step_id and worker_role are required")
|
||||
if self.max_attempts < 1 or self.max_attempts > 5:
|
||||
raise ValueError("max_attempts must be between 1 and 5")
|
||||
if self.timeout_seconds < 1 or self.timeout_seconds > 86_400:
|
||||
raise ValueError("timeout_seconds must be between 1 and 86400")
|
||||
if self.step_id in self.depends_on:
|
||||
raise ValueError("a step cannot depend on itself")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskPlan:
|
||||
task_id: str
|
||||
intent: str
|
||||
binding: ResourceBinding
|
||||
risk: RiskClass
|
||||
steps: tuple[WorkflowStep, ...]
|
||||
evidence_requirements: tuple[str, ...]
|
||||
production_side_effects_allowed: bool = False
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.task_id or not self.intent:
|
||||
raise ValueError("task_id and intent are required")
|
||||
if self.production_side_effects_allowed:
|
||||
raise ValueError("candidate TaskPlan must not allow production side effects")
|
||||
ids = [step.step_id for step in self.steps]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("step IDs must be unique")
|
||||
known = set(ids)
|
||||
for step in self.steps:
|
||||
missing = set(step.depends_on) - known
|
||||
if missing:
|
||||
raise ValueError(f"step {step.step_id} has unknown dependencies: {sorted(missing)}")
|
||||
self._validate_acyclic()
|
||||
|
||||
def _validate_acyclic(self) -> None:
|
||||
dependencies = {step.step_id: set(step.depends_on) for step in self.steps}
|
||||
pending = set(dependencies)
|
||||
while pending:
|
||||
ready = {step_id for step_id in pending if not (dependencies[step_id] & pending)}
|
||||
if not ready:
|
||||
raise ValueError("workflow contains a dependency cycle")
|
||||
pending -= ready
|
||||
|
||||
def digest(self) -> str:
|
||||
return sha256_json(
|
||||
{
|
||||
"task_id": self.task_id,
|
||||
"intent": self.intent,
|
||||
"repository": self.binding.repository,
|
||||
"branch": self.binding.branch,
|
||||
"base_oid": self.binding.base_oid,
|
||||
"allowed_paths": list(self.binding.allowed_paths),
|
||||
"risk": self.risk.value,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": step.step_id,
|
||||
"worker_role": step.worker_role,
|
||||
"required_capabilities": list(step.required_capabilities),
|
||||
"depends_on": list(step.depends_on),
|
||||
"max_attempts": step.max_attempts,
|
||||
"timeout_seconds": step.timeout_seconds,
|
||||
"allowed_paths": list(step.allowed_paths),
|
||||
}
|
||||
for step in self.steps
|
||||
],
|
||||
"evidence_requirements": list(self.evidence_requirements),
|
||||
"production_side_effects_allowed": self.production_side_effects_allowed,
|
||||
"created_at": self.created_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkerDescriptor:
|
||||
worker_id: str
|
||||
role: str
|
||||
capabilities: frozenset[str]
|
||||
trust_zone: str
|
||||
endpoint_id: str
|
||||
online: bool = True
|
||||
current_load: int = 0
|
||||
max_concurrency: int = 1
|
||||
|
||||
def can_accept(self, required: Sequence[str], trust_zone: str) -> bool:
|
||||
return (
|
||||
self.online
|
||||
and self.trust_zone == trust_zone
|
||||
and self.current_load < self.max_concurrency
|
||||
and set(required).issubset(self.capabilities)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialRequest:
|
||||
request_id: str
|
||||
task_id: str
|
||||
session_id: str
|
||||
worker_id: str
|
||||
audience: str
|
||||
consumer_service: str
|
||||
binding: ResourceBinding
|
||||
operations: tuple[str, ...]
|
||||
provider: str
|
||||
runtime_user: str
|
||||
approval_plan_id: str
|
||||
not_before: datetime
|
||||
expires_at: datetime
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
max_uses: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.ttl_seconds < 1 or self.ttl_seconds > 3600:
|
||||
raise ValueError("credential TTL must be between 1 and 3600 seconds")
|
||||
if self.max_uses < 1 or self.max_uses > 1000:
|
||||
raise ValueError("credential max_uses must be between 1 and 1000")
|
||||
if not self.operations:
|
||||
raise ValueError("credential operations cannot be empty")
|
||||
required = (
|
||||
self.request_id,
|
||||
self.task_id,
|
||||
self.session_id,
|
||||
self.worker_id,
|
||||
self.audience,
|
||||
self.consumer_service,
|
||||
self.provider,
|
||||
self.runtime_user,
|
||||
self.approval_plan_id,
|
||||
self.nonce,
|
||||
)
|
||||
if any(not item or item != item.strip() for item in required):
|
||||
raise ValueError("credential identity, scope and approval fields are required")
|
||||
if len(set(self.operations)) != len(self.operations):
|
||||
raise ValueError("credential operations must be unique")
|
||||
if self.not_before.tzinfo is None or self.expires_at.tzinfo is None:
|
||||
raise ValueError("credential time bounds must be timezone aware")
|
||||
if self.expires_at <= self.not_before:
|
||||
raise ValueError("credential expires_at must follow not_before")
|
||||
if (self.expires_at - self.not_before).total_seconds() > self.ttl_seconds:
|
||||
raise ValueError("credential approval window exceeds requested TTL")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialProjection:
|
||||
credential_id: str
|
||||
reference: str
|
||||
request: CredentialRequest
|
||||
state: CredentialState
|
||||
issued_at: datetime
|
||||
expires_at: datetime
|
||||
plaintext_returned: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.plaintext_returned:
|
||||
raise ValueError("credential plaintext must never be returned")
|
||||
if self.expires_at <= self.issued_at:
|
||||
raise ValueError("credential expiry must be after issuance")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResultEnvelope:
|
||||
task_id: str
|
||||
step_id: str
|
||||
worker_id: str
|
||||
session_id: str
|
||||
claim_id: str
|
||||
operation_digest: str
|
||||
result_digest: str
|
||||
evidence_digests: tuple[str, ...]
|
||||
completed_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not is_sha256(self.operation_digest) or not is_sha256(self.result_digest):
|
||||
raise ValueError("operation_digest and result_digest must be SHA-256 values")
|
||||
if any(not is_sha256(digest) for digest in self.evidence_digests):
|
||||
raise ValueError("all evidence digests must be SHA-256 values")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleasePlan:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
version: str
|
||||
target_oid: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
package_closure_sha256: str
|
||||
evidence_closure_oid: str
|
||||
approval_plan_id: str
|
||||
rollback_approval_plan_id: str
|
||||
rollback_artifact_sha256: str
|
||||
expected_tag_oid: str
|
||||
expected_target_oid: str
|
||||
production_config_sha256: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("target_oid", "tree_oid", "evidence_closure_oid", "expected_target_oid"):
|
||||
if not is_git_oid(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
|
||||
for name in (
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"package_closure_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"production_config_sha256",
|
||||
):
|
||||
if not is_sha256(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a SHA-256 hex digest")
|
||||
if not is_git_oid(self.expected_tag_oid):
|
||||
raise ValueError("expected_tag_oid must be a full lowercase 40-character Git OID")
|
||||
if (
|
||||
not self.task_id
|
||||
or "/" not in self.repository
|
||||
or not self.branch.startswith("integration/")
|
||||
or not self.version
|
||||
or not self.approval_plan_id
|
||||
or not self.rollback_approval_plan_id
|
||||
):
|
||||
raise ValueError("release identifiers are required")
|
||||
if self.approval_plan_id == self.rollback_approval_plan_id:
|
||||
raise ValueError("deployment and rollback approvals must be distinct")
|
||||
if self.target_oid != self.expected_target_oid:
|
||||
raise ValueError("target_oid must equal expected_target_oid")
|
||||
-246
@@ -1,246 +0,0 @@
|
||||
"""Candidate-only OpenClaw and Codex native session adapter contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
import uuid
|
||||
|
||||
from .errors import StateConflict
|
||||
|
||||
|
||||
class NativeProvider(Protocol):
|
||||
simulation: bool
|
||||
|
||||
def spawn(
|
||||
self, request: Mapping[str, Any], credential_reference: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def status(
|
||||
self, provider_session_id: str, credential_reference: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def list_tree(
|
||||
self, parent_session_id: str, credential_reference: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
provider_session_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
credential_reference: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def interrupt(self, provider_session_id: str, credential_reference: str) -> None: ...
|
||||
|
||||
def close(self, provider_session_id: str, credential_reference: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativeSession:
|
||||
provider: str
|
||||
broker_session_id: str
|
||||
provider_session_id: str
|
||||
task_id: str
|
||||
parent_session_id: str
|
||||
recipient_binding: Mapping[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionVerification:
|
||||
broker_session_verified: bool
|
||||
openclaw_native_child_session_verified: bool
|
||||
codex_child_session_verified: bool
|
||||
simulation: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TextResult:
|
||||
content_type: str
|
||||
encoding: str
|
||||
image_only: bool
|
||||
task_id: str
|
||||
message_id: str
|
||||
receipt: str
|
||||
recipient_binding: Mapping[str, str]
|
||||
secrets_included: bool
|
||||
|
||||
|
||||
class CandidateNativeAdapter:
|
||||
provider = ""
|
||||
required_reference = ""
|
||||
|
||||
def __init__(self, client: NativeProvider, credential_reference: str) -> None:
|
||||
if credential_reference != self.required_reference:
|
||||
raise StateConflict(
|
||||
"CANDIDATE_CREDENTIAL_REFERENCE_MISMATCH",
|
||||
"adapter requires its exact Candidate credential reference",
|
||||
)
|
||||
self.client = client
|
||||
self.credential_reference = credential_reference
|
||||
|
||||
@staticmethod
|
||||
def _recipient(value: Mapping[str, Any]) -> dict[str, str]:
|
||||
recipient_id = value.get("recipient_id")
|
||||
if (
|
||||
not isinstance(recipient_id, str)
|
||||
or not recipient_id
|
||||
or isinstance(value.get("recipients"), (list, tuple))
|
||||
):
|
||||
raise StateConflict(
|
||||
"RECIPIENT_BINDING_REQUIRED",
|
||||
"one trusted TaskPlan recipient binding is required",
|
||||
)
|
||||
return {str(key): str(item) for key, item in value.items()}
|
||||
|
||||
def spawn(
|
||||
self,
|
||||
task_id: str,
|
||||
parent_session_id: str,
|
||||
recipient_binding: Mapping[str, Any],
|
||||
) -> NativeSession:
|
||||
if not task_id or not parent_session_id:
|
||||
raise StateConflict("NATIVE_SESSION_BINDING_REQUIRED", "task and parent are required")
|
||||
recipient = self._recipient(recipient_binding)
|
||||
response = self.client.spawn(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
"recipient_binding": recipient,
|
||||
"candidate_only": True,
|
||||
"production_session_access": False,
|
||||
},
|
||||
self.credential_reference,
|
||||
)
|
||||
provider_session_id = response.get("provider_session_id")
|
||||
if (
|
||||
not isinstance(provider_session_id, str)
|
||||
or not provider_session_id
|
||||
or response.get("task_id") != task_id
|
||||
or response.get("parent_session_id") != parent_session_id
|
||||
or response.get("candidate_only") is not True
|
||||
or response.get("production_session_access") is not False
|
||||
):
|
||||
raise StateConflict(
|
||||
"NATIVE_SESSION_PROVISIONING_INVALID",
|
||||
"provider response is not bound to this Candidate task",
|
||||
)
|
||||
broker_id = f"broker-{uuid.uuid4()}"
|
||||
if broker_id == provider_session_id:
|
||||
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
|
||||
return NativeSession(
|
||||
provider=self.provider,
|
||||
broker_session_id=broker_id,
|
||||
provider_session_id=provider_session_id,
|
||||
task_id=task_id,
|
||||
parent_session_id=parent_session_id,
|
||||
recipient_binding=recipient,
|
||||
)
|
||||
|
||||
def verify(self, session: NativeSession) -> SessionVerification:
|
||||
self._validate_session(session)
|
||||
status = self.client.status(session.provider_session_id, self.credential_reference)
|
||||
tree = self.client.list_tree(session.parent_session_id, self.credential_reference)
|
||||
status_valid = (
|
||||
status.get("provider_session_id") == session.provider_session_id
|
||||
and status.get("state") in {"PROVISIONED", "ACTIVE", "RUNNING"}
|
||||
)
|
||||
tree_valid = any(
|
||||
item.get("provider_session_id") == session.provider_session_id
|
||||
and item.get("parent_session_id") == session.parent_session_id
|
||||
for item in tree
|
||||
)
|
||||
if not status_valid or not tree_valid:
|
||||
raise StateConflict(
|
||||
"NATIVE_SESSION_VERIFICATION_FAILED",
|
||||
"provider status or parent/child tree is inconsistent",
|
||||
)
|
||||
simulated = bool(getattr(self.client, "simulation", True))
|
||||
return SessionVerification(
|
||||
broker_session_verified=True,
|
||||
openclaw_native_child_session_verified=(
|
||||
self.provider == "openclaw" and not simulated
|
||||
),
|
||||
codex_child_session_verified=(self.provider == "codex" and not simulated),
|
||||
simulation=simulated,
|
||||
)
|
||||
|
||||
def send_text(self, session: NativeSession, text: str) -> TextResult:
|
||||
self._validate_session(session)
|
||||
if not isinstance(text, str) or not text:
|
||||
raise StateConflict("TEXT_RESULT_REQUIRED", "non-empty text is required")
|
||||
response = self.client.send_text(
|
||||
session.provider_session_id,
|
||||
{
|
||||
"task_id": session.task_id,
|
||||
"content_type": "text/plain",
|
||||
"encoding": "utf-8",
|
||||
"image_only": False,
|
||||
"text": text,
|
||||
"recipient_binding": dict(session.recipient_binding),
|
||||
"secrets_included": False,
|
||||
},
|
||||
self.credential_reference,
|
||||
)
|
||||
required = {
|
||||
"content_type": "text/plain",
|
||||
"encoding": "utf-8",
|
||||
"image_only": False,
|
||||
"task_id": session.task_id,
|
||||
"recipient_binding": dict(session.recipient_binding),
|
||||
"secrets_included": False,
|
||||
}
|
||||
if any(response.get(key) != value for key, value in required.items()):
|
||||
raise StateConflict("TEXT_RESULT_CONTRACT_INVALID", "text result binding is invalid")
|
||||
message_id = response.get("message_id")
|
||||
receipt = response.get("receipt")
|
||||
if not isinstance(message_id, str) or not message_id or not isinstance(receipt, str) or not receipt:
|
||||
raise StateConflict("TEXT_RESULT_RECEIPT_REQUIRED", "message and receipt are required")
|
||||
return TextResult(
|
||||
content_type="text/plain",
|
||||
encoding="utf-8",
|
||||
image_only=False,
|
||||
task_id=session.task_id,
|
||||
message_id=message_id,
|
||||
receipt=receipt,
|
||||
recipient_binding=dict(session.recipient_binding),
|
||||
secrets_included=False,
|
||||
)
|
||||
|
||||
def status(self, session: NativeSession) -> str:
|
||||
self._validate_session(session)
|
||||
response = self.client.status(session.provider_session_id, self.credential_reference)
|
||||
if response.get("provider_session_id") != session.provider_session_id:
|
||||
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider session ID changed")
|
||||
state = response.get("state")
|
||||
if not isinstance(state, str) or not state:
|
||||
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider state is missing")
|
||||
return state
|
||||
|
||||
def interrupt(self, session: NativeSession) -> None:
|
||||
self._validate_session(session)
|
||||
self.client.interrupt(session.provider_session_id, self.credential_reference)
|
||||
if self.status(session) != "INTERRUPTED":
|
||||
raise StateConflict("NATIVE_SESSION_INTERRUPT_FAILED", "interrupt was not observed")
|
||||
|
||||
def close(self, session: NativeSession) -> None:
|
||||
self._validate_session(session)
|
||||
self.client.close(session.provider_session_id, self.credential_reference)
|
||||
if self.status(session) != "CLOSED":
|
||||
raise StateConflict("NATIVE_SESSION_CLOSE_FAILED", "close was not observed")
|
||||
|
||||
def _validate_session(self, session: NativeSession) -> None:
|
||||
if session.provider != self.provider:
|
||||
raise StateConflict("NATIVE_SESSION_PROVIDER_MISMATCH", "session provider differs")
|
||||
if session.broker_session_id == session.provider_session_id:
|
||||
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
|
||||
|
||||
|
||||
class OpenClawNativeAdapter(CandidateNativeAdapter):
|
||||
provider = "openclaw"
|
||||
required_reference = "systemd-credential:openclaw-gateway-candidate"
|
||||
|
||||
|
||||
class CodexNativeAdapter(CandidateNativeAdapter):
|
||||
provider = "codex"
|
||||
required_reference = "systemd-credential:codex-app-server-candidate"
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
"""High-level autonomous release orchestration facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .evidence import EvidenceChain
|
||||
from .models import TaskPlan
|
||||
from .policy import ReleasePolicy
|
||||
from .workflow import WorkflowEngine
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrchestrationResult:
|
||||
task_id: str
|
||||
plan_digest: str
|
||||
evidence_closure: str | None
|
||||
production_side_effects: bool
|
||||
|
||||
|
||||
class AutonomousReleaseOrchestrator:
|
||||
"""Coordinates candidate workflows while keeping production disabled."""
|
||||
|
||||
def __init__(self, workflow: WorkflowEngine, policy: ReleasePolicy) -> None:
|
||||
self.workflow = workflow
|
||||
self.policy = policy
|
||||
self.evidence: EvidenceChain | None = None
|
||||
|
||||
def create(self, plan: TaskPlan) -> OrchestrationResult:
|
||||
self.policy.validate_task(plan)
|
||||
self.workflow.plan(plan)
|
||||
self.evidence = EvidenceChain(self.workflow.store, plan.task_id)
|
||||
self.evidence.append(
|
||||
"TASK_CREATED",
|
||||
plan.task_id,
|
||||
{"plan_digest": plan.digest(), "secrets_included": False},
|
||||
)
|
||||
return OrchestrationResult(
|
||||
task_id=plan.task_id,
|
||||
plan_digest=plan.digest(),
|
||||
evidence_closure=None,
|
||||
production_side_effects=False,
|
||||
)
|
||||
|
||||
def close_evidence(self, task_id: str) -> OrchestrationResult:
|
||||
if self.evidence is None or self.evidence.task_id != task_id:
|
||||
self.evidence = EvidenceChain(self.workflow.store, task_id)
|
||||
closure = self.evidence.closure_digest()
|
||||
return OrchestrationResult(
|
||||
task_id=task_id,
|
||||
plan_digest="",
|
||||
evidence_closure=closure,
|
||||
production_side_effects=False,
|
||||
)
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
"""Deterministic task planning for autonomous candidate releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import ResourceBinding, RiskClass, TaskPlan, WorkflowStep
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlanningRequest:
|
||||
task_id: str
|
||||
intent: str
|
||||
binding: ResourceBinding
|
||||
risk: RiskClass = RiskClass.MEDIUM
|
||||
|
||||
|
||||
class DeterministicPlanner:
|
||||
"""Create a fixed governed DAG without granting production capabilities."""
|
||||
|
||||
def build(self, request: PlanningRequest) -> TaskPlan:
|
||||
steps = (
|
||||
WorkflowStep(
|
||||
step_id="architecture-review",
|
||||
worker_role="hermes-review",
|
||||
required_capabilities=("architecture.review", "risk.analysis"),
|
||||
allowed_paths=request.binding.allowed_paths,
|
||||
),
|
||||
WorkflowStep(
|
||||
step_id="implementation",
|
||||
worker_role="dedicated-writer",
|
||||
required_capabilities=("commit_file.atomic", "git.push.patch_branch"),
|
||||
depends_on=("architecture-review",),
|
||||
max_attempts=2,
|
||||
allowed_paths=request.binding.allowed_paths,
|
||||
),
|
||||
WorkflowStep(
|
||||
step_id="code-review",
|
||||
worker_role="codex-review",
|
||||
required_capabilities=("code.review", "git.diff.read"),
|
||||
depends_on=("implementation",),
|
||||
allowed_paths=request.binding.allowed_paths,
|
||||
),
|
||||
WorkflowStep(
|
||||
step_id="validation",
|
||||
worker_role="openclaw-shadow",
|
||||
required_capabilities=("test.execute", "artifact.verify"),
|
||||
depends_on=("code-review",),
|
||||
max_attempts=2,
|
||||
allowed_paths=request.binding.allowed_paths,
|
||||
),
|
||||
WorkflowStep(
|
||||
step_id="evidence-finalize",
|
||||
worker_role="evidence-controller",
|
||||
required_capabilities=("evidence.finalize",),
|
||||
depends_on=("validation",),
|
||||
allowed_paths=request.binding.allowed_paths,
|
||||
),
|
||||
)
|
||||
return TaskPlan(
|
||||
task_id=request.task_id,
|
||||
intent=request.intent,
|
||||
binding=request.binding,
|
||||
risk=request.risk,
|
||||
steps=steps,
|
||||
evidence_requirements=(
|
||||
"git-ref-closure",
|
||||
"test-report",
|
||||
"secret-scan",
|
||||
"artifact-manifest",
|
||||
"credential-revocation",
|
||||
"production-invariants",
|
||||
),
|
||||
production_side_effects_allowed=False,
|
||||
)
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
"""Fail-closed policy engine for autonomous release workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .errors import ApprovalRequired, CapabilityMismatch, PolicyDenied
|
||||
from .models import ApprovalEnvelope, ResourceBinding, RiskClass, TaskPlan, WorkerDescriptor
|
||||
|
||||
|
||||
FORBIDDEN_CAPABILITIES = frozenset({
|
||||
"production.deploy",
|
||||
"production.rollback",
|
||||
"main.write",
|
||||
"tag.write",
|
||||
"baseline.write",
|
||||
"registry.write",
|
||||
"secret.export",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class ReleasePolicy:
|
||||
def validate_binding(self, binding: ResourceBinding) -> PolicyDecision:
|
||||
if binding.branch == "main":
|
||||
raise PolicyDenied("MAIN_WRITE_DENIED", "main branch is never writable")
|
||||
if not binding.allowed_paths:
|
||||
raise PolicyDenied("PATH_SCOPE_REQUIRED", "candidate path scope is required")
|
||||
return PolicyDecision(True, "binding_valid")
|
||||
|
||||
def validate_task(self, plan: TaskPlan) -> PolicyDecision:
|
||||
self.validate_binding(plan.binding)
|
||||
if plan.production_side_effects_allowed:
|
||||
raise PolicyDenied("PRODUCTION_SIDE_EFFECT_DENIED", "candidate workflows cannot execute production side effects")
|
||||
return PolicyDecision(True, "task_valid")
|
||||
|
||||
def validate_approval(self, approval: ApprovalEnvelope, now: datetime | None = None) -> PolicyDecision:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if now < approval.not_before or now >= approval.expires_at:
|
||||
raise ApprovalRequired("APPROVAL_TIME_INVALID", "approval is outside its validity window")
|
||||
if any(cap in FORBIDDEN_CAPABILITIES for cap in approval.capabilities):
|
||||
raise PolicyDenied("CAPABILITY_FORBIDDEN", "approval requests forbidden capability")
|
||||
return PolicyDecision(True, "approval_valid")
|
||||
|
||||
def validate_worker(self, worker: WorkerDescriptor, required: tuple[str, ...], trust_zone: str) -> PolicyDecision:
|
||||
if not worker.can_accept(required, trust_zone):
|
||||
raise CapabilityMismatch("WORKER_CAPABILITY_MISMATCH", "worker cannot satisfy task requirements")
|
||||
return PolicyDecision(True, "worker_valid")
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
"""Governed deployment and rollback state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from .approval import ApprovalVerificationResult
|
||||
from .errors import ReleaseBlocked
|
||||
from .models import ReleasePlan
|
||||
|
||||
|
||||
class ReleaseState(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
BUILT = "BUILT"
|
||||
VERIFIED = "VERIFIED"
|
||||
WAITING_OWNER = "WAITING_OWNER"
|
||||
APPROVED = "APPROVED"
|
||||
DEPLOYING = "DEPLOYING"
|
||||
DEPLOYED = "DEPLOYED"
|
||||
FAILED = "FAILED"
|
||||
ROLLBACK_REQUESTED = "ROLLBACK_REQUESTED"
|
||||
ROLLBACK_APPROVED = "ROLLBACK_APPROVED"
|
||||
ROLLING_BACK = "ROLLING_BACK"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseStatus:
|
||||
state: ReleaseState
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ReleaseController:
|
||||
"""No transition reaches deployment or rollback without an external receipt."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state = ReleaseState.CREATED
|
||||
self._plan: ReleasePlan | None = None
|
||||
self._reason: str | None = None
|
||||
|
||||
def _expect(self, *states: ReleaseState) -> None:
|
||||
if self._state not in states:
|
||||
raise ReleaseBlocked(
|
||||
"RELEASE_STATE_INVALID",
|
||||
"release transition is not allowed from the current state",
|
||||
{"state": self._state.value, "allowed": [item.value for item in states]},
|
||||
)
|
||||
|
||||
def attach_plan(self, plan: ReleasePlan) -> ReleaseStatus:
|
||||
if self._plan is not None:
|
||||
if self._plan == plan:
|
||||
return self.status()
|
||||
raise ReleaseBlocked("RELEASE_PLAN_ALREADY_ATTACHED", "release plan is immutable")
|
||||
self._plan = plan
|
||||
return self.status()
|
||||
|
||||
def mark_built(self) -> ReleaseStatus:
|
||||
self._require_plan()
|
||||
self._expect(ReleaseState.CREATED)
|
||||
self._state = ReleaseState.BUILT
|
||||
return self.status()
|
||||
|
||||
def verify(self, *, artifact_ok: bool, evidence_ok: bool) -> ReleaseStatus:
|
||||
self._require_plan()
|
||||
self._expect(ReleaseState.BUILT)
|
||||
if not artifact_ok or not evidence_ok:
|
||||
raise ReleaseBlocked(
|
||||
"RELEASE_VERIFICATION_FAILED", "artifact or evidence verification failed"
|
||||
)
|
||||
self._state = ReleaseState.VERIFIED
|
||||
return self.status()
|
||||
|
||||
def request_owner_approval(self) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.VERIFIED)
|
||||
self._state = ReleaseState.WAITING_OWNER
|
||||
return self.status()
|
||||
|
||||
def approve(self, receipt: ApprovalVerificationResult) -> ReleaseStatus:
|
||||
plan = self._require_plan()
|
||||
self._expect(ReleaseState.WAITING_OWNER)
|
||||
if receipt.task_id != plan.task_id:
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_TASK_MISMATCH", "approval task does not match")
|
||||
if receipt.approval_plan_id != plan.approval_plan_id:
|
||||
raise ReleaseBlocked(
|
||||
"OWNER_APPROVAL_PLAN_MISMATCH", "approval plan does not match release plan"
|
||||
)
|
||||
self._state = ReleaseState.APPROVED
|
||||
return self.status()
|
||||
|
||||
def start_deployment(self) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.APPROVED)
|
||||
self._state = ReleaseState.DEPLOYING
|
||||
return self.status()
|
||||
|
||||
def mark_deployed(self) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.DEPLOYING)
|
||||
self._state = ReleaseState.DEPLOYED
|
||||
return self.status()
|
||||
|
||||
def mark_failed(self, reason: str) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.DEPLOYING, ReleaseState.DEPLOYED)
|
||||
if not reason:
|
||||
raise ReleaseBlocked("RELEASE_FAILURE_REASON_REQUIRED", "failure reason is required")
|
||||
self._reason = reason
|
||||
self._state = ReleaseState.FAILED
|
||||
return self.status()
|
||||
|
||||
def request_rollback(self, reason: str) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.DEPLOYING, ReleaseState.DEPLOYED, ReleaseState.FAILED)
|
||||
if not reason:
|
||||
raise ReleaseBlocked("ROLLBACK_REASON_REQUIRED", "rollback reason is required")
|
||||
self._reason = reason
|
||||
self._state = ReleaseState.ROLLBACK_REQUESTED
|
||||
return self.status()
|
||||
|
||||
def approve_rollback(self, receipt: ApprovalVerificationResult) -> ReleaseStatus:
|
||||
plan = self._require_plan()
|
||||
self._expect(ReleaseState.ROLLBACK_REQUESTED)
|
||||
if receipt.task_id != plan.task_id:
|
||||
raise ReleaseBlocked("ROLLBACK_APPROVAL_TASK_MISMATCH", "rollback task does not match")
|
||||
if receipt.approval_plan_id != plan.rollback_approval_plan_id:
|
||||
raise ReleaseBlocked(
|
||||
"ROLLBACK_APPROVAL_PLAN_MISMATCH",
|
||||
"rollback approval plan does not match release plan",
|
||||
)
|
||||
self._state = ReleaseState.ROLLBACK_APPROVED
|
||||
return self.status()
|
||||
|
||||
def start_rollback(self) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.ROLLBACK_APPROVED)
|
||||
self._state = ReleaseState.ROLLING_BACK
|
||||
return self.status()
|
||||
|
||||
def mark_rolled_back(self) -> ReleaseStatus:
|
||||
self._expect(ReleaseState.ROLLING_BACK)
|
||||
self._state = ReleaseState.ROLLED_BACK
|
||||
return self.status()
|
||||
|
||||
def rollback(self) -> ReleaseStatus:
|
||||
raise ReleaseBlocked(
|
||||
"ROLLBACK_APPROVAL_REQUIRED",
|
||||
"use request_rollback, approve_rollback and start_rollback",
|
||||
)
|
||||
|
||||
def status(self) -> ReleaseStatus:
|
||||
return ReleaseStatus(self._state, self._reason)
|
||||
|
||||
def _require_plan(self) -> ReleasePlan:
|
||||
if self._plan is None:
|
||||
raise ReleaseBlocked("RELEASE_PLAN_REQUIRED", "attach a release plan first")
|
||||
return self._plan
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
"""Read-only multi-source release consistency gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import ReleaseBlocked
|
||||
from .models import is_git_oid, is_sha256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseEvidence:
|
||||
expected_version: str
|
||||
registry_version: str
|
||||
baseline_version: str
|
||||
local_tag_oid: str
|
||||
remote_tag_oid: str
|
||||
gitea_tag_oid: str
|
||||
expected_tag_oid: str
|
||||
local_peeled_target_oid: str
|
||||
remote_peeled_target_oid: str
|
||||
gitea_peeled_target_oid: str
|
||||
expected_target_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
evidence_sha256: str
|
||||
rollback_artifact_sha256: str
|
||||
|
||||
|
||||
class ReleaseGate:
|
||||
"""Require exact tag-object, peeled-target, version and package closure."""
|
||||
|
||||
def verify(self, evidence: ReleaseEvidence) -> None:
|
||||
versions = (
|
||||
evidence.expected_version,
|
||||
evidence.registry_version,
|
||||
evidence.baseline_version,
|
||||
)
|
||||
if not versions[0] or len(set(versions)) != 1:
|
||||
raise ReleaseBlocked("VERSION_MISMATCH", "expected, registry and baseline differ")
|
||||
tag_oids = (
|
||||
evidence.local_tag_oid,
|
||||
evidence.remote_tag_oid,
|
||||
evidence.gitea_tag_oid,
|
||||
evidence.expected_tag_oid,
|
||||
)
|
||||
if any(not is_git_oid(value) for value in tag_oids):
|
||||
raise ReleaseBlocked("TAG_OID_INVALID", "tag object OID is invalid")
|
||||
if evidence.local_tag_oid != evidence.remote_tag_oid:
|
||||
raise ReleaseBlocked("TAG_REMOTE_MISMATCH", "local and remote tag objects differ")
|
||||
if evidence.remote_tag_oid != evidence.gitea_tag_oid:
|
||||
raise ReleaseBlocked("TAG_GITEA_MISMATCH", "Gitea tag object differs")
|
||||
if evidence.gitea_tag_oid != evidence.expected_tag_oid:
|
||||
raise ReleaseBlocked("TAG_OBJECT_MISMATCH", "tag object differs from ReleasePlan")
|
||||
targets = (
|
||||
evidence.local_peeled_target_oid,
|
||||
evidence.remote_peeled_target_oid,
|
||||
evidence.gitea_peeled_target_oid,
|
||||
evidence.expected_target_oid,
|
||||
)
|
||||
if any(not is_git_oid(value) for value in targets):
|
||||
raise ReleaseBlocked("TAG_TARGET_OID_INVALID", "peeled target OID is invalid")
|
||||
if len(set(targets)) != 1:
|
||||
raise ReleaseBlocked("TAG_TARGET_MISMATCH", "peeled tag targets are not identical")
|
||||
digests = (
|
||||
evidence.artifact_sha256,
|
||||
evidence.manifest_sha256,
|
||||
evidence.evidence_sha256,
|
||||
evidence.rollback_artifact_sha256,
|
||||
)
|
||||
if any(not is_sha256(value) for value in digests):
|
||||
raise ReleaseBlocked("ARTIFACT_DIGEST_MISSING", "package closure is incomplete")
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
"""Deterministic worker routing for candidate autonomous release workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import CapabilityMismatch
|
||||
from .models import WorkerDescriptor
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteRequest:
|
||||
task_id: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
trust_zone: str
|
||||
|
||||
|
||||
class WorkerRouter:
|
||||
"""Select the least loaded compatible worker without expanding trust."""
|
||||
|
||||
def __init__(self, workers: list[WorkerDescriptor]) -> None:
|
||||
self._workers = list(workers)
|
||||
|
||||
def route(self, request: RouteRequest) -> WorkerDescriptor:
|
||||
candidates = [
|
||||
worker
|
||||
for worker in self._workers
|
||||
if worker.can_accept(request.required_capabilities, request.trust_zone)
|
||||
]
|
||||
if not candidates:
|
||||
raise CapabilityMismatch(
|
||||
"NO_COMPATIBLE_WORKER",
|
||||
"no worker satisfies capability and trust constraints",
|
||||
{"task_id": request.task_id},
|
||||
)
|
||||
return sorted(candidates, key=lambda item: (item.current_load, item.worker_id))[0]
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
"""AIOS Secret Manager V2 reference-only Candidate adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .errors import CredentialUnavailable
|
||||
from .models import CredentialRequest
|
||||
|
||||
|
||||
PROVIDER_REFERENCES = {
|
||||
"openclaw": "systemd-credential:openclaw-gateway-candidate",
|
||||
"codex": "systemd-credential:codex-app-server-candidate",
|
||||
"native_adapter_control": "systemd-credential:session-native-adapter-control-candidate",
|
||||
"gitea": "systemd-credential:gitea-admin-api-candidate",
|
||||
}
|
||||
FORBIDDEN_RESPONSE_FIELDS = {
|
||||
"api_key",
|
||||
"token",
|
||||
"password",
|
||||
"cookie",
|
||||
"private_key",
|
||||
"credential_body",
|
||||
"plaintext",
|
||||
}
|
||||
|
||||
|
||||
class SecretManagerTransport(Protocol):
|
||||
def exchange(self, operation: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecretProjectionReceipt:
|
||||
task_id: str
|
||||
credential_id: str
|
||||
reference: str
|
||||
provider: str
|
||||
audience: str
|
||||
consumer_service: str
|
||||
state: str
|
||||
expires_at: datetime
|
||||
plaintext_secret_returned: bool = False
|
||||
|
||||
|
||||
class SecretManagerV2:
|
||||
"""Issue, project, inspect and revoke without receiving authentication material."""
|
||||
|
||||
def __init__(self, transport: SecretManagerTransport) -> None:
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def _assert_safe(value: Any) -> None:
|
||||
if isinstance(value, Mapping):
|
||||
if FORBIDDEN_RESPONSE_FIELDS.intersection(str(key).lower() for key in value):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_PLAINTEXT_REJECTED",
|
||||
"Secret Manager response included a forbidden plaintext field",
|
||||
)
|
||||
for item in value.values():
|
||||
SecretManagerV2._assert_safe(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
SecretManagerV2._assert_safe(item)
|
||||
|
||||
def _exchange(self, operation: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
try:
|
||||
response = self.transport.exchange(operation, payload)
|
||||
except CredentialUnavailable:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_UNAVAILABLE", "Secret Manager V2 operation failed"
|
||||
) from error
|
||||
if not isinstance(response, Mapping):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_RESPONSE_INVALID", "Secret Manager V2 returned a non-object"
|
||||
)
|
||||
self._assert_safe(response)
|
||||
if response.get("plaintext_secret_returned") is not False:
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_PLAINTEXT_REJECTED",
|
||||
"Secret Manager did not attest plaintext_secret_returned=false",
|
||||
)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _payload(request: CredentialRequest) -> dict[str, Any]:
|
||||
return {
|
||||
"approval_plan_id": request.approval_plan_id,
|
||||
"task_id": request.task_id,
|
||||
"request_id": request.request_id,
|
||||
"session_id": request.session_id,
|
||||
"worker_id": request.worker_id,
|
||||
"provider": request.provider,
|
||||
"audience": request.audience,
|
||||
"consumer_service": request.consumer_service,
|
||||
"runtime_user": request.runtime_user,
|
||||
"repository": request.binding.repository,
|
||||
"branch": request.binding.branch,
|
||||
"branch_oid": request.binding.base_oid,
|
||||
"allowed_paths": list(request.binding.allowed_paths),
|
||||
"operations": list(request.operations),
|
||||
"not_before": request.not_before.astimezone(timezone.utc).isoformat(),
|
||||
"expires_at": request.expires_at.astimezone(timezone.utc).isoformat(),
|
||||
"nonce": request.nonce,
|
||||
"ttl_seconds": request.ttl_seconds,
|
||||
"max_uses": request.max_uses,
|
||||
}
|
||||
|
||||
def issue(
|
||||
self, request: CredentialRequest, now: datetime | None = None
|
||||
) -> SecretProjectionReceipt:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if now < request.not_before or now >= request.expires_at:
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_APPROVAL_WINDOW_INVALID", "credential request is not currently valid"
|
||||
)
|
||||
expected_reference = PROVIDER_REFERENCES.get(request.provider)
|
||||
if expected_reference is None:
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_PROVIDER_DENIED", "provider is not Candidate-approved"
|
||||
)
|
||||
response = self._exchange("issue", self._payload(request))
|
||||
credential_id = response.get("credential_id")
|
||||
if (
|
||||
response.get("task_id") != request.task_id
|
||||
or not isinstance(credential_id, str)
|
||||
or not credential_id
|
||||
or response.get("credential_reference") != expected_reference
|
||||
or response.get("state") != "ISSUED"
|
||||
):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_BINDING_MISMATCH", "issued projection does not match the request"
|
||||
)
|
||||
expires_at = datetime.fromisoformat(str(response.get("expires_at")))
|
||||
if expires_at > request.expires_at or expires_at <= now:
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_EXPIRY_INVALID", "issued projection expiry is outside approval"
|
||||
)
|
||||
return SecretProjectionReceipt(
|
||||
task_id=request.task_id,
|
||||
credential_id=credential_id,
|
||||
reference=expected_reference,
|
||||
provider=request.provider,
|
||||
audience=request.audience,
|
||||
consumer_service=request.consumer_service,
|
||||
state="ISSUED",
|
||||
expires_at=expires_at,
|
||||
plaintext_secret_returned=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _receipt_payload(receipt: SecretProjectionReceipt) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": receipt.task_id,
|
||||
"credential_id": receipt.credential_id,
|
||||
"credential_reference": receipt.reference,
|
||||
"provider": receipt.provider,
|
||||
"audience": receipt.audience,
|
||||
"consumer_service": receipt.consumer_service,
|
||||
}
|
||||
|
||||
def _state_operation(
|
||||
self, operation: str, receipt: SecretProjectionReceipt, expected_state: str
|
||||
) -> Mapping[str, Any]:
|
||||
response = self._exchange(operation, self._receipt_payload(receipt))
|
||||
if (
|
||||
response.get("task_id") != receipt.task_id
|
||||
or response.get("credential_id") != receipt.credential_id
|
||||
or response.get("state") != expected_state
|
||||
):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_STATE_MISMATCH", f"{operation} response is not task-bound"
|
||||
)
|
||||
return response
|
||||
|
||||
def project(self, receipt: SecretProjectionReceipt) -> None:
|
||||
self._state_operation("project", receipt, "PROJECTED")
|
||||
|
||||
def status(self, receipt: SecretProjectionReceipt) -> str:
|
||||
return str(self._state_operation("status", receipt, "PROJECTED")["state"])
|
||||
|
||||
def revoke(self, receipt: SecretProjectionReceipt) -> None:
|
||||
self._state_operation("revoke", receipt, "REVOKED")
|
||||
|
||||
def verify_revocation(self, receipt: SecretProjectionReceipt) -> None:
|
||||
response = self._state_operation("verify_revocation", receipt, "REVOKED")
|
||||
if response.get("post_revocation_auth") != "REJECTED":
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_REVOCATION_UNVERIFIED",
|
||||
"post-revocation authentication was not rejected",
|
||||
)
|
||||
|
||||
def audit(self, receipt: SecretProjectionReceipt) -> Sequence[Mapping[str, Any]]:
|
||||
response = self._exchange("audit", self._receipt_payload(receipt))
|
||||
if (
|
||||
response.get("task_id") != receipt.task_id
|
||||
or response.get("credential_id") != receipt.credential_id
|
||||
):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_AUDIT_MISMATCH", "audit response is not task-bound"
|
||||
)
|
||||
events = response.get("events")
|
||||
if not isinstance(events, list) or any(
|
||||
not isinstance(event, Mapping) or event.get("secrets_included") is not False
|
||||
for event in events
|
||||
):
|
||||
raise CredentialUnavailable(
|
||||
"SECRET_MANAGER_AUDIT_INVALID", "audit events are absent or unsafe"
|
||||
)
|
||||
return events
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
"""JSON serialization helpers for candidate API and CLI boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .models import ReleasePlan, ResourceBinding, RiskClass, TaskPlan, WorkflowStep
|
||||
|
||||
|
||||
def task_plan_from_mapping(value: Mapping[str, Any]) -> TaskPlan:
|
||||
binding_value = value["binding"]
|
||||
binding = ResourceBinding(
|
||||
repository=str(binding_value["repository"]),
|
||||
branch=str(binding_value["branch"]),
|
||||
base_oid=str(binding_value["base_oid"]),
|
||||
allowed_paths=tuple(str(item) for item in binding_value.get("allowed_paths", ())),
|
||||
)
|
||||
steps = tuple(
|
||||
WorkflowStep(
|
||||
step_id=str(item["step_id"]),
|
||||
worker_role=str(item["worker_role"]),
|
||||
required_capabilities=tuple(str(cap) for cap in item.get("required_capabilities", ())),
|
||||
depends_on=tuple(str(dep) for dep in item.get("depends_on", ())),
|
||||
max_attempts=int(item.get("max_attempts", 1)),
|
||||
timeout_seconds=int(item.get("timeout_seconds", 900)),
|
||||
allowed_paths=tuple(str(path) for path in item.get("allowed_paths", ())),
|
||||
)
|
||||
for item in value.get("steps", ())
|
||||
)
|
||||
created_at_raw = value.get("created_at")
|
||||
created_at = datetime.fromisoformat(created_at_raw) if created_at_raw else None
|
||||
kwargs: dict[str, Any] = {
|
||||
"task_id": str(value["task_id"]),
|
||||
"intent": str(value["intent"]),
|
||||
"binding": binding,
|
||||
"risk": RiskClass(str(value.get("risk", RiskClass.MEDIUM.value))),
|
||||
"steps": steps,
|
||||
"evidence_requirements": tuple(str(item) for item in value.get("evidence_requirements", ())),
|
||||
"production_side_effects_allowed": bool(value.get("production_side_effects_allowed", False)),
|
||||
}
|
||||
if created_at is not None:
|
||||
kwargs["created_at"] = created_at
|
||||
return TaskPlan(**kwargs)
|
||||
|
||||
|
||||
def task_plan_to_mapping(plan: TaskPlan) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": plan.task_id,
|
||||
"intent": plan.intent,
|
||||
"binding": {
|
||||
"repository": plan.binding.repository,
|
||||
"branch": plan.binding.branch,
|
||||
"base_oid": plan.binding.base_oid,
|
||||
"allowed_paths": list(plan.binding.allowed_paths),
|
||||
},
|
||||
"risk": plan.risk.value,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": step.step_id,
|
||||
"worker_role": step.worker_role,
|
||||
"required_capabilities": list(step.required_capabilities),
|
||||
"depends_on": list(step.depends_on),
|
||||
"max_attempts": step.max_attempts,
|
||||
"timeout_seconds": step.timeout_seconds,
|
||||
"allowed_paths": list(step.allowed_paths),
|
||||
}
|
||||
for step in plan.steps
|
||||
],
|
||||
"evidence_requirements": list(plan.evidence_requirements),
|
||||
"production_side_effects_allowed": plan.production_side_effects_allowed,
|
||||
"created_at": plan.created_at.isoformat(),
|
||||
"plan_digest": plan.digest(),
|
||||
}
|
||||
|
||||
|
||||
def release_plan_from_mapping(value: Mapping[str, Any]) -> ReleasePlan:
|
||||
return ReleasePlan(
|
||||
task_id=str(value["task_id"]),
|
||||
repository=str(value["repository"]),
|
||||
branch=str(value["branch"]),
|
||||
version=str(value["version"]),
|
||||
target_oid=str(value["target_oid"]),
|
||||
tree_oid=str(value["tree_oid"]),
|
||||
artifact_sha256=str(value["artifact_sha256"]),
|
||||
manifest_sha256=str(value["manifest_sha256"]),
|
||||
package_closure_sha256=str(value["package_closure_sha256"]),
|
||||
evidence_closure_oid=str(value["evidence_closure_oid"]),
|
||||
approval_plan_id=str(value["approval_plan_id"]),
|
||||
rollback_approval_plan_id=str(value["rollback_approval_plan_id"]),
|
||||
rollback_artifact_sha256=str(value["rollback_artifact_sha256"]),
|
||||
expected_tag_oid=str(value["expected_tag_oid"]),
|
||||
expected_target_oid=str(value["expected_target_oid"]),
|
||||
production_config_sha256=str(value["production_config_sha256"]),
|
||||
)
|
||||
|
||||
|
||||
def release_plan_to_mapping(plan: ReleasePlan) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": plan.task_id,
|
||||
"repository": plan.repository,
|
||||
"branch": plan.branch,
|
||||
"version": plan.version,
|
||||
"target_oid": plan.target_oid,
|
||||
"tree_oid": plan.tree_oid,
|
||||
"artifact_sha256": plan.artifact_sha256,
|
||||
"manifest_sha256": plan.manifest_sha256,
|
||||
"package_closure_sha256": plan.package_closure_sha256,
|
||||
"evidence_closure_oid": plan.evidence_closure_oid,
|
||||
"approval_plan_id": plan.approval_plan_id,
|
||||
"rollback_approval_plan_id": plan.rollback_approval_plan_id,
|
||||
"rollback_artifact_sha256": plan.rollback_artifact_sha256,
|
||||
"expected_tag_oid": plan.expected_tag_oid,
|
||||
"expected_target_oid": plan.expected_target_oid,
|
||||
"production_config_sha256": plan.production_config_sha256,
|
||||
}
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
"""Hardened loopback HTTP boundary for the dormant Candidate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .api import AutonomousReleaseAPI
|
||||
from .errors import AutonomousReleaseError
|
||||
from .orchestrator import AutonomousReleaseOrchestrator
|
||||
from .policy import ReleasePolicy
|
||||
from .store import StateStore
|
||||
from .workflow import WorkflowEngine
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
def __init__(self, state_path: str | Path) -> None:
|
||||
self.store = StateStore(state_path)
|
||||
workflow = WorkflowEngine(self.store)
|
||||
self.api = AutonomousReleaseAPI(
|
||||
AutonomousReleaseOrchestrator(workflow, ReleasePolicy())
|
||||
)
|
||||
|
||||
def ready(self) -> bool:
|
||||
with self.store.transaction() as connection:
|
||||
return connection.execute("SELECT 1").fetchone()[0] == 1
|
||||
|
||||
|
||||
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSAutonomousReleaseCandidate/2"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def application(self) -> CandidateApplication:
|
||||
return self.server.application # type: ignore[attr-defined]
|
||||
|
||||
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status.value)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _read_json(self) -> dict[str, Any]:
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type != "application/json":
|
||||
raise UnsupportedMediaType
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as error:
|
||||
raise InvalidRequest from error
|
||||
if length <= 0 or length > 1_048_576:
|
||||
raise InvalidRequest
|
||||
try:
|
||||
raw = self.rfile.read(length)
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise InvalidRequest from error
|
||||
if not isinstance(value, dict):
|
||||
raise InvalidRequest
|
||||
return value
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == "/health":
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"status": "ok",
|
||||
"mode": "candidate-shadow-only",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
return
|
||||
if path == "/ready":
|
||||
if self.application.ready():
|
||||
self._send_json(
|
||||
HTTPStatus.OK, {"status": "ready", "secrets_included": False}
|
||||
)
|
||||
else:
|
||||
self._send_json(
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
{"status": "not_ready", "secrets_included": False},
|
||||
)
|
||||
return
|
||||
if path.startswith("/v1/tasks/") and path.endswith("/steps"):
|
||||
task_id = path[len("/v1/tasks/") : -len("/steps")].strip("/")
|
||||
if not task_id or "/" in task_id:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return
|
||||
try:
|
||||
self._send_json(HTTPStatus.OK, self.application.api.task_steps(task_id))
|
||||
except KeyError:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
|
||||
return
|
||||
if path.startswith("/v1/tasks/") and path.endswith("/evidence"):
|
||||
task_id = path[len("/v1/tasks/") : -len("/evidence")].strip("/")
|
||||
if not task_id or "/" in task_id:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return
|
||||
try:
|
||||
self._send_json(HTTPStatus.OK, self.application.api.evidence(task_id))
|
||||
except KeyError:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
|
||||
return
|
||||
if path.startswith("/v1/tasks/"):
|
||||
task_id = path[len("/v1/tasks/") :].strip("/")
|
||||
if not task_id or "/" in task_id:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return
|
||||
try:
|
||||
self._send_json(HTTPStatus.OK, self.application.api.task_status(task_id))
|
||||
except KeyError:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
|
||||
return
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
payload = self._read_json()
|
||||
if path == "/v1/tasks":
|
||||
self._send_json(HTTPStatus.CREATED, self.application.api.create_task(payload))
|
||||
return
|
||||
if path == "/v1/release/plan":
|
||||
self._send_json(
|
||||
HTTPStatus.CREATED, self.application.api.create_release_plan(payload)
|
||||
)
|
||||
return
|
||||
if path == "/v1/release/verify":
|
||||
self._send_json(HTTPStatus.OK, self.application.api.verify_release(payload))
|
||||
return
|
||||
if path.startswith("/v1/tasks/") and path.endswith("/evidence/close"):
|
||||
task_id = path[len("/v1/tasks/") : -len("/evidence/close")].strip("/")
|
||||
if not task_id:
|
||||
raise InvalidRequest
|
||||
self._send_json(HTTPStatus.OK, self.application.api.close_evidence(task_id))
|
||||
return
|
||||
for action, handler in (
|
||||
("claim", self.application.api.claim),
|
||||
("heartbeat", self.application.api.heartbeat),
|
||||
("result", self.application.api.result),
|
||||
):
|
||||
suffix = f"/{action}"
|
||||
if path.startswith("/v1/tasks/") and path.endswith(suffix):
|
||||
task_id = path[len("/v1/tasks/") : -len(suffix)].strip("/")
|
||||
if not task_id or "/" in task_id:
|
||||
raise InvalidRequest
|
||||
self._send_json(HTTPStatus.OK, handler(task_id, payload))
|
||||
return
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||
except UnsupportedMediaType:
|
||||
self._send_json(
|
||||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "content_type_required"}
|
||||
)
|
||||
except (InvalidRequest, ValueError, KeyError):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
except AutonomousReleaseError as error:
|
||||
self._send_json(HTTPStatus.CONFLICT, {"error": error.code})
|
||||
except Exception:
|
||||
self._send_json(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "candidate_request_failed"}
|
||||
)
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
|
||||
class InvalidRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedMediaType(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False
|
||||
|
||||
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||
try:
|
||||
loopback = ipaddress.ip_address(address[0]).is_loopback
|
||||
except ValueError as error:
|
||||
raise ValueError("candidate service requires a numeric loopback address") from error
|
||||
if not loopback:
|
||||
raise ValueError("candidate service must bind to loopback")
|
||||
self.application = application
|
||||
super().__init__(address, CandidateRequestHandler)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-autonomous-release-service")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=19951)
|
||||
parser.add_argument("--state", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
application = CandidateApplication(args.state)
|
||||
server = CandidateHTTPServer((args.host, args.port), application)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
"""Unified candidate session lifecycle contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
from .errors import StateConflict
|
||||
|
||||
|
||||
class SessionState(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
PROVISIONED = "PROVISIONED"
|
||||
ACTIVE = "ACTIVE"
|
||||
INTERRUPTED = "INTERRUPTED"
|
||||
CLOSED = "CLOSED"
|
||||
REVOKED = "REVOKED"
|
||||
|
||||
|
||||
_ALLOWED_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = {
|
||||
SessionState.CREATED: frozenset({SessionState.PROVISIONED, SessionState.CLOSED, SessionState.REVOKED}),
|
||||
SessionState.PROVISIONED: frozenset({SessionState.ACTIVE, SessionState.CLOSED, SessionState.REVOKED}),
|
||||
SessionState.ACTIVE: frozenset({SessionState.INTERRUPTED, SessionState.CLOSED, SessionState.REVOKED}),
|
||||
SessionState.INTERRUPTED: frozenset({SessionState.ACTIVE, SessionState.CLOSED, SessionState.REVOKED}),
|
||||
SessionState.CLOSED: frozenset({SessionState.REVOKED}),
|
||||
SessionState.REVOKED: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionIdentity:
|
||||
session_id: str
|
||||
provider: str
|
||||
provider_session_id: str
|
||||
worker_id: str
|
||||
task_id: str
|
||||
created_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not all((self.session_id, self.provider, self.provider_session_id, self.worker_id, self.task_id)):
|
||||
raise ValueError("all session identity fields are required")
|
||||
if self.session_id == self.provider_session_id:
|
||||
raise ValueError("broker and provider session IDs must be distinct")
|
||||
|
||||
|
||||
class SessionOrchestrator:
|
||||
"""Provider-neutral session lifecycle guard with explicit state transitions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, tuple[SessionIdentity, SessionState]] = {}
|
||||
|
||||
def create(self, identity: SessionIdentity) -> None:
|
||||
if identity.session_id in self._sessions:
|
||||
raise StateConflict("SESSION_EXISTS", "session already exists")
|
||||
self._sessions[identity.session_id] = (identity, SessionState.CREATED)
|
||||
|
||||
def transition(self, session_id: str, state: SessionState) -> None:
|
||||
current = self._sessions.get(session_id)
|
||||
if current is None:
|
||||
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
||||
current_state = current[1]
|
||||
if state not in _ALLOWED_TRANSITIONS[current_state]:
|
||||
raise StateConflict(
|
||||
"SESSION_TRANSITION_INVALID",
|
||||
"session state transition is not allowed",
|
||||
{"session_id": session_id, "from": current_state.value, "to": state.value},
|
||||
)
|
||||
self._sessions[session_id] = (current[0], state)
|
||||
|
||||
def identity(self, session_id: str) -> SessionIdentity:
|
||||
current = self._sessions.get(session_id)
|
||||
if current is None:
|
||||
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
||||
return current[0]
|
||||
|
||||
def status(self, session_id: str) -> SessionState:
|
||||
current = self._sessions.get(session_id)
|
||||
if current is None:
|
||||
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
||||
return current[1]
|
||||
|
||||
def close(self, session_id: str) -> None:
|
||||
self.transition(session_id, SessionState.CLOSED)
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
self.transition(session_id, SessionState.REVOKED)
|
||||
-383
@@ -1,383 +0,0 @@
|
||||
"""Durable, private SQLite state for the autonomous-release Candidate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Iterator, Mapping, Sequence
|
||||
|
||||
from .errors import StateConflict
|
||||
from .models import StepState, TaskState, canonical_json
|
||||
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StoredStep:
|
||||
task_id: str
|
||||
step_id: str
|
||||
state: StepState
|
||||
attempt: int
|
||||
max_attempts: int
|
||||
worker_id: str | None
|
||||
session_id: str | None
|
||||
lease_expires_at: datetime | None
|
||||
result_digest: str | None
|
||||
updated_at: datetime
|
||||
last_error: str | None
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""SQLite store with WAL, full sync, schema checks and serialized CAS writes."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._prepare_path()
|
||||
self._initialize()
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _prepare_path(self) -> None:
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
raise StateConflict("STATE_PATH_SYMLINK", "state database must not be a symlink")
|
||||
parent = self.path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
if parent.is_symlink():
|
||||
raise StateConflict("STATE_DIRECTORY_SYMLINK", "state directory must not be a symlink")
|
||||
current_mode = parent.stat().st_mode & 0o777
|
||||
os.chmod(parent, current_mode & ~0o027)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
raise StateConflict("STATE_PATH_SYMLINK", "state database must not be a symlink")
|
||||
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
yield connection
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self.transaction() as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT value FROM metadata WHERE key='schema_version'"
|
||||
).fetchone()
|
||||
version = int(row["value"]) if row is not None else None
|
||||
if version not in (None, 1, SCHEMA_VERSION):
|
||||
raise StateConflict(
|
||||
"SCHEMA_VERSION_UNSUPPORTED",
|
||||
"state schema is not supported",
|
||||
{"found": version, "expected": SCHEMA_VERSION},
|
||||
)
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
plan_digest TEXT NOT NULL,
|
||||
plan_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS steps (
|
||||
task_id TEXT NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 1,
|
||||
worker_id TEXT,
|
||||
session_id TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_digest TEXT,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(task_id, step_id),
|
||||
FOREIGN KEY(task_id) REFERENCES tasks(task_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
previous_digest TEXT,
|
||||
digest TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS consumed_nonces (
|
||||
scope TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
consumed_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, nonce)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
credential_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
reference TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
issued_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
namespace TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(namespace, operation_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
if version == 1:
|
||||
existing = {
|
||||
item["name"] for item in connection.execute("PRAGMA table_info(steps)")
|
||||
}
|
||||
additions = {
|
||||
"max_attempts": "INTEGER NOT NULL DEFAULT 1",
|
||||
"lease_expires_at": "TEXT",
|
||||
"result_digest": "TEXT",
|
||||
}
|
||||
for column, declaration in additions.items():
|
||||
if column not in existing:
|
||||
connection.execute(f"ALTER TABLE steps ADD COLUMN {column} {declaration}")
|
||||
connection.execute(
|
||||
"INSERT INTO metadata(key, value) VALUES('schema_version', ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
task_id: str,
|
||||
plan_digest: str,
|
||||
plan: Mapping[str, Any],
|
||||
step_ids: tuple[str, ...],
|
||||
step_limits: Mapping[str, int] | None = None,
|
||||
) -> None:
|
||||
now = self._now()
|
||||
limits = step_limits or {}
|
||||
with self.transaction() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id, plan_digest, plan_json, state, created_at, updated_at) "
|
||||
"VALUES(?, ?, ?, ?, ?, ?)",
|
||||
(task_id, plan_digest, canonical_json(plan), TaskState.PLANNED.value, now, now),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO steps(task_id, step_id, state, max_attempts, updated_at) "
|
||||
"VALUES(?, ?, ?, ?, ?)",
|
||||
[
|
||||
(task_id, step_id, StepState.PENDING.value, int(limits.get(step_id, 1)), now)
|
||||
for step_id in step_ids
|
||||
],
|
||||
)
|
||||
|
||||
def get_task_state(self, task_id: str) -> tuple[TaskState, int]:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT state, revision FROM tasks WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(task_id)
|
||||
return TaskState(row["state"]), int(row["revision"])
|
||||
|
||||
def compare_and_set_task_state(
|
||||
self, task_id: str, expected_revision: int, new_state: TaskState
|
||||
) -> int:
|
||||
with self.transaction() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE tasks SET state=?, revision=revision+1, updated_at=? "
|
||||
"WHERE task_id=? AND revision=?",
|
||||
(new_state.value, self._now(), task_id, expected_revision),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise StateConflict(
|
||||
"TASK_REVISION_CONFLICT",
|
||||
"task state changed concurrently",
|
||||
{"task_id": task_id, "expected_revision": expected_revision},
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT revision FROM tasks WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
return int(row["revision"])
|
||||
|
||||
def transition_step(
|
||||
self,
|
||||
task_id: str,
|
||||
step_id: str,
|
||||
expected: Sequence[StepState],
|
||||
state: StepState,
|
||||
*,
|
||||
worker_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
lease_expires_at: datetime | None = None,
|
||||
result_digest: str | None = None,
|
||||
last_error: str | None = None,
|
||||
increment_attempt: bool = False,
|
||||
clear_assignment: bool = False,
|
||||
) -> StoredStep:
|
||||
with self.transaction() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM steps WHERE task_id=? AND step_id=?", (task_id, step_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"{task_id}:{step_id}")
|
||||
if StepState(row["state"]) not in set(expected):
|
||||
raise StateConflict(
|
||||
"STEP_STATE_CONFLICT",
|
||||
"step state changed or transition is invalid",
|
||||
{"task_id": task_id, "step_id": step_id, "state": row["state"]},
|
||||
)
|
||||
if increment_attempt and int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
raise StateConflict(
|
||||
"STEP_ATTEMPTS_EXHAUSTED",
|
||||
"step has no remaining attempts",
|
||||
{"task_id": task_id, "step_id": step_id},
|
||||
)
|
||||
next_worker = None if clear_assignment else (worker_id or row["worker_id"])
|
||||
next_session = None if clear_assignment else (session_id or row["session_id"])
|
||||
connection.execute(
|
||||
"UPDATE steps SET state=?, attempt=attempt+?, worker_id=?, session_id=?, "
|
||||
"lease_expires_at=?, result_digest=COALESCE(?, result_digest), last_error=?, updated_at=? "
|
||||
"WHERE task_id=? AND step_id=?",
|
||||
(
|
||||
state.value,
|
||||
1 if increment_attempt else 0,
|
||||
next_worker,
|
||||
next_session,
|
||||
lease_expires_at.isoformat() if lease_expires_at else None,
|
||||
result_digest,
|
||||
last_error,
|
||||
self._now(),
|
||||
task_id,
|
||||
step_id,
|
||||
),
|
||||
)
|
||||
updated = connection.execute(
|
||||
"SELECT * FROM steps WHERE task_id=? AND step_id=?", (task_id, step_id)
|
||||
).fetchone()
|
||||
return self._step_from_row(updated)
|
||||
|
||||
def set_step_state(
|
||||
self,
|
||||
task_id: str,
|
||||
step_id: str,
|
||||
state: StepState,
|
||||
**kwargs: Any,
|
||||
) -> StoredStep:
|
||||
current = self.list_steps(task_id)[step_id]
|
||||
return self.transition_step(task_id, step_id, (current.state,), state, **kwargs)
|
||||
|
||||
def list_steps(self, task_id: str) -> dict[str, StoredStep]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM steps WHERE task_id=? ORDER BY step_id", (task_id,)
|
||||
).fetchall()
|
||||
return {row["step_id"]: self._step_from_row(row) for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _step_from_row(row: sqlite3.Row) -> StoredStep:
|
||||
return StoredStep(
|
||||
task_id=row["task_id"],
|
||||
step_id=row["step_id"],
|
||||
state=StepState(row["state"]),
|
||||
attempt=int(row["attempt"]),
|
||||
max_attempts=int(row["max_attempts"]),
|
||||
worker_id=row["worker_id"],
|
||||
session_id=row["session_id"],
|
||||
lease_expires_at=(
|
||||
datetime.fromisoformat(row["lease_expires_at"])
|
||||
if row["lease_expires_at"]
|
||||
else None
|
||||
),
|
||||
result_digest=row["result_digest"],
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
last_error=row["last_error"],
|
||||
)
|
||||
|
||||
def consume_nonce(self, scope: str, nonce: str) -> None:
|
||||
try:
|
||||
with self.transaction() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO consumed_nonces(scope, nonce, consumed_at) VALUES(?, ?, ?)",
|
||||
(scope, nonce, self._now()),
|
||||
)
|
||||
except sqlite3.IntegrityError as error:
|
||||
raise StateConflict(
|
||||
"NONCE_REPLAY", "nonce was already consumed", {"scope": scope, "nonce": nonce}
|
||||
) from error
|
||||
|
||||
def append_event(
|
||||
self,
|
||||
task_id: str,
|
||||
event_type: str,
|
||||
payload: Mapping[str, Any],
|
||||
previous_digest: str | None,
|
||||
event_digest: str,
|
||||
created_at: datetime,
|
||||
) -> int:
|
||||
with self.transaction() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO events(task_id, event_type, payload_json, previous_digest, digest, created_at) "
|
||||
"VALUES(?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
event_type,
|
||||
canonical_json(payload),
|
||||
previous_digest,
|
||||
event_digest,
|
||||
created_at.astimezone(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def list_events(self, task_id: str) -> list[Mapping[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY sequence", (task_id,)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def put_idempotent_result(
|
||||
self, namespace: str, operation_id: str, result: Mapping[str, Any]
|
||||
) -> Mapping[str, Any]:
|
||||
encoded = canonical_json(result)
|
||||
with self.transaction() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT result_json FROM idempotency WHERE namespace=? AND operation_id=?",
|
||||
(namespace, operation_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return json.loads(existing["result_json"])
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(namespace, operation_id, result_json, created_at) "
|
||||
"VALUES(?, ?, ?, ?)",
|
||||
(namespace, operation_id, encoded, self._now()),
|
||||
)
|
||||
return dict(result)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Read-only annotated and lightweight tag consistency checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import ReleaseBlocked
|
||||
from .models import is_git_oid
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TagReference:
|
||||
ref: str
|
||||
object_type: str
|
||||
tag_oid: str
|
||||
peeled_target_oid: str
|
||||
|
||||
|
||||
class TagVerifier:
|
||||
def verify(
|
||||
self,
|
||||
tag: TagReference,
|
||||
*,
|
||||
expected_tag_oid: str,
|
||||
expected_target_oid: str,
|
||||
) -> None:
|
||||
if not tag.ref.startswith("refs/tags/") or tag.ref.endswith("^{}"):
|
||||
raise ReleaseBlocked("TAG_REF_INVALID", "canonical tag ref is required")
|
||||
if tag.object_type not in ("commit", "tag"):
|
||||
raise ReleaseBlocked("TAG_OBJECT_TYPE_INVALID", "unsupported tag object type")
|
||||
values = (tag.tag_oid, tag.peeled_target_oid, expected_tag_oid, expected_target_oid)
|
||||
if any(not is_git_oid(value) for value in values):
|
||||
raise ReleaseBlocked("TAG_OID_INVALID", "tag OIDs must be complete lowercase hex")
|
||||
if tag.tag_oid != expected_tag_oid:
|
||||
raise ReleaseBlocked("TAG_OBJECT_MISMATCH", "tag object does not match expectation")
|
||||
if tag.peeled_target_oid != expected_target_oid:
|
||||
raise ReleaseBlocked(
|
||||
"TAG_PEELED_TARGET_MISMATCH", "peeled target does not match expected target"
|
||||
)
|
||||
if tag.object_type == "commit" and tag.tag_oid != tag.peeled_target_oid:
|
||||
raise ReleaseBlocked(
|
||||
"LIGHTWEIGHT_TAG_INCONSISTENT",
|
||||
"lightweight tag object and target must be identical",
|
||||
)
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
"""Dormant Candidate worker supervisor.
|
||||
|
||||
Provider clients are injected by the external management plane. This process
|
||||
never converts a missing projection into a simulated native identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import threading
|
||||
|
||||
|
||||
def write_status(directory: Path) -> None:
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
if directory.is_symlink():
|
||||
raise RuntimeError("worker state directory symlink rejected")
|
||||
payload = {
|
||||
"status": "dormant",
|
||||
"task_plane_execution_enabled": os.environ.get(
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED", "false"
|
||||
).lower()
|
||||
== "true",
|
||||
"gitops_writes_enabled": os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower()
|
||||
== "true",
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
temporary = directory / f"worker-status.{os.getpid()}.new"
|
||||
target = directory / "worker-status.json"
|
||||
if temporary.is_symlink() or target.is_symlink():
|
||||
raise RuntimeError("worker status symlink rejected")
|
||||
descriptor = os.open(
|
||||
temporary,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, (json.dumps(payload, sort_keys=True) + "\n").encode("utf-8"))
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.replace(temporary, target)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--state-directory", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if os.environ.get("AIOS_PRODUCTION_DEPLOYMENT_ENABLED", "false").lower() == "true":
|
||||
raise RuntimeError("Candidate worker refuses production deployment mode")
|
||||
write_status(args.state_directory)
|
||||
stopping = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stopping.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stopping.set())
|
||||
while not stopping.wait(5):
|
||||
write_status(args.state_directory)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
"""Durable fail-closed workflow state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .errors import StateConflict
|
||||
from .models import StepState, TaskPlan, TaskState, is_sha256
|
||||
from .store import StateStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowDecision:
|
||||
task_id: str
|
||||
ready_steps: tuple[str, ...]
|
||||
blocked: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, store: StateStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def _task_state(self, task_id: str, state: TaskState) -> None:
|
||||
current, revision = self.store.get_task_state(task_id)
|
||||
if current != state:
|
||||
self.store.compare_and_set_task_state(task_id, revision, state)
|
||||
|
||||
def plan(self, task: TaskPlan) -> None:
|
||||
self.store.create_task(
|
||||
task.task_id,
|
||||
task.digest(),
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"intent": task.intent,
|
||||
"branch": task.binding.branch,
|
||||
"base_oid": task.binding.base_oid,
|
||||
},
|
||||
tuple(step.step_id for step in task.steps),
|
||||
{step.step_id: step.max_attempts for step in task.steps},
|
||||
)
|
||||
|
||||
def next_steps(self, task: TaskPlan) -> WorkflowDecision:
|
||||
states = self.store.list_steps(task.task_id)
|
||||
by_id = {step.step_id: step for step in task.steps}
|
||||
if any(item.state == StepState.FAILED for item in states.values()):
|
||||
self._task_state(task.task_id, TaskState.FAILED)
|
||||
return WorkflowDecision(task.task_id, (), True, "terminal_step_failure")
|
||||
ready: list[str] = []
|
||||
for step_id, stored in states.items():
|
||||
if stored.state not in (StepState.PENDING, StepState.READY):
|
||||
continue
|
||||
dependencies = by_id[step_id].depends_on
|
||||
dependency_states = tuple(states[item].state for item in dependencies)
|
||||
if any(item in (StepState.FAILED, StepState.BLOCKED) for item in dependency_states):
|
||||
if stored.state != StepState.BLOCKED:
|
||||
self.store.transition_step(
|
||||
task.task_id,
|
||||
step_id,
|
||||
(stored.state,),
|
||||
StepState.BLOCKED,
|
||||
last_error="dependency_failed",
|
||||
)
|
||||
continue
|
||||
if all(item == StepState.COMPLETED for item in dependency_states):
|
||||
if stored.state == StepState.PENDING:
|
||||
self.store.transition_step(
|
||||
task.task_id, step_id, (StepState.PENDING,), StepState.READY
|
||||
)
|
||||
ready.append(step_id)
|
||||
if states and all(item.state == StepState.COMPLETED for item in states.values()):
|
||||
self._task_state(task.task_id, TaskState.COMPLETED)
|
||||
elif ready:
|
||||
self._task_state(task.task_id, TaskState.READY)
|
||||
return WorkflowDecision(task.task_id, tuple(sorted(ready)), False)
|
||||
|
||||
def claim(self, task_id: str, step_id: str, worker_id: str, session_id: str) -> None:
|
||||
if not worker_id or not session_id:
|
||||
raise StateConflict("STEP_CLAIM_BINDING_REQUIRED", "worker and session are required")
|
||||
self.store.transition_step(
|
||||
task_id,
|
||||
step_id,
|
||||
(StepState.READY,),
|
||||
StepState.CLAIMED,
|
||||
worker_id=worker_id,
|
||||
session_id=session_id,
|
||||
increment_attempt=True,
|
||||
)
|
||||
|
||||
def start(
|
||||
self,
|
||||
task_id: str,
|
||||
step_id: str,
|
||||
worker_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
lease_seconds: int = 60,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
current = self.store.list_steps(task_id)[step_id]
|
||||
if current.worker_id != worker_id or current.session_id != session_id:
|
||||
raise StateConflict("STEP_CLAIM_OWNER_MISMATCH", "claim owner does not match")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
self.store.transition_step(
|
||||
task_id,
|
||||
step_id,
|
||||
(StepState.CLAIMED,),
|
||||
StepState.RUNNING,
|
||||
worker_id=worker_id,
|
||||
session_id=session_id,
|
||||
lease_expires_at=now + timedelta(seconds=lease_seconds),
|
||||
)
|
||||
self._task_state(task_id, TaskState.RUNNING)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
task_id: str,
|
||||
step_id: str,
|
||||
worker_id: str,
|
||||
session_id: str,
|
||||
*,
|
||||
lease_seconds: int = 60,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
current = self.store.list_steps(task_id)[step_id]
|
||||
if current.worker_id != worker_id or current.session_id != session_id:
|
||||
raise StateConflict("STEP_CLAIM_OWNER_MISMATCH", "heartbeat owner does not match")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
self.store.transition_step(
|
||||
task_id,
|
||||
step_id,
|
||||
(StepState.RUNNING,),
|
||||
StepState.RUNNING,
|
||||
lease_expires_at=now + timedelta(seconds=lease_seconds),
|
||||
)
|
||||
|
||||
def complete(self, task_id: str, step_id: str, *, result_digest: str) -> None:
|
||||
if not is_sha256(result_digest):
|
||||
raise StateConflict("RESULT_DIGEST_INVALID", "result digest must be SHA-256")
|
||||
current = self.store.list_steps(task_id)[step_id]
|
||||
if current.state == StepState.COMPLETED and current.result_digest == result_digest:
|
||||
return
|
||||
self.store.transition_step(
|
||||
task_id,
|
||||
step_id,
|
||||
(StepState.RUNNING,),
|
||||
StepState.COMPLETED,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task: TaskPlan,
|
||||
step_id: str,
|
||||
reason: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
) -> StepState:
|
||||
current = self.store.list_steps(task.task_id)[step_id]
|
||||
next_state = (
|
||||
StepState.PENDING
|
||||
if retryable and current.attempt < current.max_attempts
|
||||
else StepState.FAILED
|
||||
)
|
||||
self.store.transition_step(
|
||||
task.task_id,
|
||||
step_id,
|
||||
(StepState.CLAIMED, StepState.RUNNING),
|
||||
next_state,
|
||||
last_error=reason,
|
||||
clear_assignment=next_state == StepState.PENDING,
|
||||
)
|
||||
self._task_state(
|
||||
task.task_id, TaskState.BLOCKED if next_state == StepState.PENDING else TaskState.FAILED
|
||||
)
|
||||
return next_state
|
||||
|
||||
def recover_expired(
|
||||
self, task: TaskPlan, *, now: datetime | None = None
|
||||
) -> tuple[str, ...]:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
recovered: list[str] = []
|
||||
for step_id, current in self.store.list_steps(task.task_id).items():
|
||||
if (
|
||||
current.state == StepState.RUNNING
|
||||
and current.lease_expires_at is not None
|
||||
and current.lease_expires_at <= now
|
||||
):
|
||||
self.fail(task, step_id, "lease_expired", retryable=True)
|
||||
recovered.append(step_id)
|
||||
return tuple(recovered)
|
||||
|
||||
def recoverable(self, task_id: str, step_id: str) -> None:
|
||||
self.store.transition_step(
|
||||
task_id,
|
||||
step_id,
|
||||
(StepState.BLOCKED,),
|
||||
StepState.PENDING,
|
||||
clear_assignment=True,
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-autonomous-release-candidate"
|
||||
version = "0.1.0"
|
||||
description = "Candidate-only governed autonomous release control plane"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [{ name = "AIOS" }]
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
aios-autonomous-release = "aios_autonomous_release_candidate.cli:main"
|
||||
aios-autonomous-release-service = "aios_autonomous_release_candidate.service:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["aios_autonomous_release_candidate*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra --strict-config --strict-markers"
|
||||
testpaths = ["../../tests/autonomous_release"]
|
||||
@@ -1,48 +0,0 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
TaskPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from aios_autonomous_release_candidate.policy import ReleasePolicy
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
|
||||
|
||||
OID = "a" * 40
|
||||
|
||||
|
||||
def build_plan():
|
||||
return TaskPlan(
|
||||
task_id="test-task",
|
||||
intent="candidate validation",
|
||||
binding=ResourceBinding(
|
||||
repository="aios/aios",
|
||||
branch="patch/test",
|
||||
base_oid=OID,
|
||||
allowed_paths=("services/test/",),
|
||||
),
|
||||
risk=RiskClass.LOW,
|
||||
steps=(
|
||||
WorkflowStep(
|
||||
step_id="validate",
|
||||
worker_role="tester",
|
||||
required_capabilities=("test.execute",),
|
||||
allowed_paths=("services/test/",),
|
||||
),
|
||||
),
|
||||
evidence_requirements=("tests",),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_policy_accepts_candidate_plan():
|
||||
ReleasePolicy().validate_task(build_plan())
|
||||
|
||||
|
||||
def test_evidence_chain_detects_valid_closure():
|
||||
chain = EvidenceChain()
|
||||
chain.append("START", "test-task", {"ok": True, "secrets_included": False})
|
||||
chain.append("DONE", "test-task", {"ok": True, "secrets_included": False})
|
||||
assert chain.verify()
|
||||
assert len(chain.closure_digest()) == 64
|
||||
@@ -1,41 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder, ArtifactFile, BuiltArtifact
|
||||
from aios_autonomous_release_candidate.errors import EvidenceIncomplete, ReleaseBlocked
|
||||
|
||||
|
||||
def test_artifact_is_deterministic_and_reverified() -> None:
|
||||
builder = ArtifactBuilder()
|
||||
files = (
|
||||
ArtifactFile("bin/run", b"#!/bin/sh\nexit 0\n", 0o755),
|
||||
ArtifactFile("config/candidate.json", b'{"production":false}\n'),
|
||||
)
|
||||
first = builder.build("a" * 40, "b" * 40, files)
|
||||
second = builder.build("a" * 40, "b" * 40, tuple(reversed(files)))
|
||||
assert first.content == second.content
|
||||
assert first.manifest.manifest_sha256 == second.manifest.manifest_sha256
|
||||
assert first.manifest.secrets_included is False
|
||||
builder.verify(first)
|
||||
with pytest.raises(EvidenceIncomplete, match="ARTIFACT_DIGEST_MISMATCH"):
|
||||
builder.verify(BuiltArtifact(first.content + b"tampered", first.manifest))
|
||||
|
||||
|
||||
def test_source_tree_rejects_symlink_and_hardlink(tmp_path: Path) -> None:
|
||||
root = tmp_path / "source"
|
||||
root.mkdir()
|
||||
target = root / "file.txt"
|
||||
target.write_text("safe", encoding="utf-8")
|
||||
(root / "link.txt").symlink_to(target)
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SOURCE_LINK_REJECTED"):
|
||||
ArtifactBuilder().build_from_directory("a" * 40, "b" * 40, root)
|
||||
|
||||
|
||||
def test_artifact_rejects_secret_markers() -> None:
|
||||
with pytest.raises(ReleaseBlocked, match="ARTIFACT_SECRET_MARKER"):
|
||||
ArtifactBuilder().build(
|
||||
"a" * 40,
|
||||
"b" * 40,
|
||||
(ArtifactFile("unsafe.env", b"password" + b"=" + b"x" * 16),),
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
TaskPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from aios_autonomous_release_candidate.policy import ReleasePolicy
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
|
||||
|
||||
OID = "a" * 40
|
||||
|
||||
|
||||
def build_plan():
|
||||
return TaskPlan(
|
||||
task_id="test-task",
|
||||
intent="candidate validation",
|
||||
binding=ResourceBinding(
|
||||
repository="aios/aios",
|
||||
branch="patch/test",
|
||||
base_oid=OID,
|
||||
allowed_paths=("services/test/",),
|
||||
),
|
||||
risk=RiskClass.LOW,
|
||||
steps=(
|
||||
WorkflowStep(
|
||||
step_id="validate",
|
||||
worker_role="tester",
|
||||
required_capabilities=("test.execute",),
|
||||
allowed_paths=("services/test/",),
|
||||
),
|
||||
),
|
||||
evidence_requirements=("tests",),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_policy_accepts_candidate_plan():
|
||||
ReleasePolicy().validate_task(build_plan())
|
||||
|
||||
|
||||
def test_evidence_chain_detects_valid_closure():
|
||||
chain = EvidenceChain()
|
||||
chain.append("START", "test-task", {"ok": True, "secrets_included": False})
|
||||
chain.append("DONE", "test-task", {"ok": True, "secrets_included": False})
|
||||
assert chain.verify()
|
||||
assert len(chain.closure_digest()) == 64
|
||||
@@ -1,129 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.credential import CredentialBroker
|
||||
from aios_autonomous_release_candidate.errors import CredentialUnavailable, EvidenceIncomplete
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
from aios_autonomous_release_candidate.models import CredentialRequest, ResourceBinding
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Provider:
|
||||
def __init__(self, reference: str = "systemd-credential:openclaw-gateway-candidate") -> None:
|
||||
self.reference = reference
|
||||
self.active: set[str] = set()
|
||||
self.revoked: list[str] = []
|
||||
self.count = 0
|
||||
|
||||
def issue(self, request, expires_at):
|
||||
self.count += 1
|
||||
credential_id = f"credential-{self.count}"
|
||||
self.active.add(credential_id)
|
||||
return credential_id, self.reference
|
||||
|
||||
def revoke(self, credential_id, reference):
|
||||
self.active.discard(credential_id)
|
||||
self.revoked.append(credential_id)
|
||||
|
||||
def is_usable(self, credential_id, reference):
|
||||
return credential_id in self.active
|
||||
|
||||
|
||||
def request(request_id: str = "request-1") -> CredentialRequest:
|
||||
return CredentialRequest(
|
||||
request_id=request_id,
|
||||
task_id="task-credential",
|
||||
session_id="session-1",
|
||||
worker_id="worker-1",
|
||||
audience="openclaw-candidate",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
binding=ResourceBinding("aios/aios", "patch/credential-test", "a" * 40, ("services/test",)),
|
||||
operations=("native_session.spawn",),
|
||||
ttl_seconds=300,
|
||||
max_uses=1,
|
||||
provider="openclaw",
|
||||
runtime_user="aiman",
|
||||
approval_plan_id="owner-stage-b-native-sessions-20260726",
|
||||
not_before=NOW,
|
||||
expires_at=NOW + timedelta(minutes=5),
|
||||
nonce="nonce-credential-1",
|
||||
)
|
||||
|
||||
|
||||
def test_credential_is_scope_bound_revocable_and_idempotent(tmp_path: Path) -> None:
|
||||
provider = Provider()
|
||||
broker = CredentialBroker(StateStore(tmp_path / "state.sqlite3"), provider)
|
||||
projection = broker.issue(request(), NOW)
|
||||
broker.project(projection)
|
||||
broker.authorize(
|
||||
projection,
|
||||
task_id="task-credential",
|
||||
session_id="session-1",
|
||||
worker_id="worker-1",
|
||||
audience="openclaw-candidate",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
operation="native_session.spawn",
|
||||
path="services/test/file.py",
|
||||
now=NOW,
|
||||
)
|
||||
with pytest.raises(CredentialUnavailable, match="CREDENTIAL_SCOPE_MISMATCH"):
|
||||
broker.authorize(
|
||||
projection,
|
||||
task_id="other-task",
|
||||
session_id="session-1",
|
||||
worker_id="worker-1",
|
||||
audience="openclaw-candidate",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
operation="native_session.spawn",
|
||||
path="services/test/file.py",
|
||||
now=NOW,
|
||||
)
|
||||
first = broker.revoke(projection.credential_id, projection.reference, NOW)
|
||||
second = broker.revoke(projection.credential_id, projection.reference, NOW)
|
||||
assert first == second
|
||||
broker.verify_revoked(projection.credential_id, projection.reference)
|
||||
|
||||
|
||||
def test_persistence_failure_compensates_upstream_issuance(tmp_path: Path) -> None:
|
||||
provider = Provider()
|
||||
broker = CredentialBroker(StateStore(tmp_path / "state.sqlite3"), provider)
|
||||
broker.issue(request(), NOW)
|
||||
with pytest.raises(CredentialUnavailable, match="CREDENTIAL_PERSISTENCE_FAILED"):
|
||||
broker.issue(request("request-2"), NOW)
|
||||
assert "credential-2" in provider.revoked
|
||||
|
||||
|
||||
def test_plaintext_or_untrusted_projection_is_rejected(tmp_path: Path) -> None:
|
||||
provider = Provider("/run/credentials/candidate/raw-value")
|
||||
broker = CredentialBroker(StateStore(tmp_path / "state.sqlite3"), provider)
|
||||
with pytest.raises(CredentialUnavailable, match="CREDENTIAL_REFERENCE_REJECTED"):
|
||||
broker.issue(request(), NOW)
|
||||
assert provider.revoked == ["credential-1"]
|
||||
|
||||
|
||||
def test_evidence_chain_survives_restart_and_detects_tampering(tmp_path: Path) -> None:
|
||||
store = StateStore(tmp_path / "state.sqlite3")
|
||||
chain = EvidenceChain(store, "task-evidence")
|
||||
chain.append("TASK_STARTED", "task-evidence", {"secrets_included": False})
|
||||
chain.append(
|
||||
"TESTS_PASSED",
|
||||
"task-evidence",
|
||||
{"passed": 42, "skipped": 0, "secrets_included": False},
|
||||
)
|
||||
reopened = EvidenceChain(store, "task-evidence")
|
||||
assert reopened.verify()
|
||||
assert len(reopened.close(("TASK_STARTED", "TESTS_PASSED"))) == 64
|
||||
with pytest.raises(EvidenceIncomplete, match="EVIDENCE_REQUIREMENT_MISSING"):
|
||||
reopened.close(("NATIVE_SESSION_VERIFIED",))
|
||||
|
||||
with store.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE events SET payload_json=? WHERE task_id=? AND sequence=1",
|
||||
('{"secrets_included":true}', "task-evidence"),
|
||||
)
|
||||
assert EvidenceChain(store, "task-evidence").verify() is False
|
||||
@@ -1,125 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.approval import (
|
||||
ApprovalVerifier,
|
||||
StaticRevocationSource,
|
||||
UnavailableSignatureVerifier,
|
||||
)
|
||||
from aios_autonomous_release_candidate.errors import ApprovalRequired
|
||||
from aios_autonomous_release_candidate.gitea_canary import (
|
||||
EphemeralKeyProjection,
|
||||
GiteaDeployKeyCanary,
|
||||
)
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
ApprovalEnvelope,
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
TaskPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 26, tzinfo=timezone.utc)
|
||||
OID = "a" * 40
|
||||
|
||||
|
||||
def task_plan() -> TaskPlan:
|
||||
return TaskPlan(
|
||||
"external-gate-task",
|
||||
"external gate verification",
|
||||
ResourceBinding("aios/aios", "patch/external-gate", OID, ("services/test",)),
|
||||
RiskClass.HIGH,
|
||||
(WorkflowStep("canary", "tester", ("native_session.spawn",)),),
|
||||
("external-gates",),
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_owner_verifier_fails_with_stable_blocker(tmp_path) -> None:
|
||||
plan = task_plan()
|
||||
envelope = ApprovalEnvelope(
|
||||
approval_plan_id="owner-external-gate",
|
||||
task_id=plan.task_id,
|
||||
task_plan_sha256=plan.digest(),
|
||||
binding=plan.binding,
|
||||
capabilities=("native_session.spawn",),
|
||||
risk=RiskClass.HIGH,
|
||||
not_before=NOW - timedelta(minutes=1),
|
||||
expires_at=NOW + timedelta(minutes=5),
|
||||
nonce="nonce-owner-external",
|
||||
signing_key_id="owner-kms-1",
|
||||
signature="opaque-signature",
|
||||
)
|
||||
verifier = ApprovalVerifier(
|
||||
StateStore(tmp_path / "state.sqlite3"),
|
||||
UnavailableSignatureVerifier(),
|
||||
StaticRevocationSource(),
|
||||
)
|
||||
with pytest.raises(
|
||||
ApprovalRequired, match="BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE"
|
||||
):
|
||||
verifier.verify(plan, envelope, now=NOW)
|
||||
|
||||
|
||||
class KeyProjection:
|
||||
def __init__(self) -> None:
|
||||
self.present = False
|
||||
|
||||
def create(self, task_id, branch):
|
||||
self.present = True
|
||||
return EphemeralKeyProjection(
|
||||
public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICandidateCanaryOnly",
|
||||
private_key_reference="systemd-credential:gitea-deploy-key-canary",
|
||||
)
|
||||
|
||||
def delete(self, reference):
|
||||
self.present = False
|
||||
|
||||
def is_present(self, reference):
|
||||
return self.present
|
||||
|
||||
|
||||
class Gitea:
|
||||
simulation = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.keys = {}
|
||||
|
||||
def list_deploy_keys(self, repository, credential_reference):
|
||||
return list(self.keys.values())
|
||||
|
||||
def create_deploy_key(self, repository, title, public_key, credential_reference):
|
||||
item = {"id": 1, "title": title, "key": public_key, "read_only": False}
|
||||
self.keys[1] = item
|
||||
return item
|
||||
|
||||
def delete_deploy_key(self, repository, key_id, credential_reference):
|
||||
self.keys.pop(key_id, None)
|
||||
|
||||
|
||||
class SSHProbe:
|
||||
def __init__(self, gitea, projection) -> None:
|
||||
self.gitea = gitea
|
||||
self.projection = projection
|
||||
|
||||
def authenticate(self, repository, branch, private_key_reference):
|
||||
return bool(self.gitea.keys) and self.projection.present
|
||||
|
||||
|
||||
def test_gitea_canary_always_revokes_key_and_projection() -> None:
|
||||
projection = KeyProjection()
|
||||
gitea = Gitea()
|
||||
result = GiteaDeployKeyCanary(gitea, projection, SSHProbe(gitea, projection)).run(
|
||||
task_id="external-gate-task",
|
||||
branch="patch/external-gate",
|
||||
credential_reference="systemd-credential:gitea-admin-api-candidate",
|
||||
)
|
||||
assert result.key_created is True
|
||||
assert result.key_deleted is True
|
||||
assert result.post_revoke_auth_rejected is True
|
||||
assert result.credential_projection_deleted is True
|
||||
assert result.gitea_admin_api_lifecycle_canary is False # simulation is not real evidence
|
||||
assert gitea.keys == {}
|
||||
assert projection.present is False
|
||||
@@ -1,156 +0,0 @@
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from aios_autonomous_release_candidate.service import CandidateApplication, CandidateHTTPServer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running_server(tmp_path: Path):
|
||||
application = CandidateApplication(tmp_path / "state.sqlite3")
|
||||
server = CandidateHTTPServer(("127.0.0.1", 0), application)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield application, f"http://127.0.0.1:{server.server_port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def get_json(url: str):
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
return response.status, json.load(response)
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.load(response)
|
||||
|
||||
|
||||
def test_health_and_readiness_are_loopback_safe(tmp_path: Path) -> None:
|
||||
with running_server(tmp_path) as (_, base):
|
||||
status, health = get_json(f"{base}/health")
|
||||
assert status == 200
|
||||
assert health["production_side_effects"] is False
|
||||
status, ready = get_json(f"{base}/ready")
|
||||
assert status == 200
|
||||
assert ready == {"status": "ready", "secrets_included": False}
|
||||
|
||||
|
||||
def test_json_content_type_is_required(tmp_path: Path) -> None:
|
||||
with running_server(tmp_path) as (_, base):
|
||||
request = urllib.request.Request(f"{base}/v1/tasks", data=b"{}", method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(request, timeout=5)
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 415
|
||||
assert json.load(error)["error"] == "content_type_required"
|
||||
|
||||
|
||||
def test_internal_exception_text_is_not_disclosed(tmp_path: Path) -> None:
|
||||
with running_server(tmp_path) as (application, base):
|
||||
def fail(_payload):
|
||||
raise RuntimeError("sensitive internal diagnostic")
|
||||
|
||||
application.api.create_task = fail
|
||||
request = urllib.request.Request(
|
||||
f"{base}/v1/tasks",
|
||||
data=b"{}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(request, timeout=5)
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 500
|
||||
body = error.read().decode("utf-8")
|
||||
assert "candidate_request_failed" in body
|
||||
assert "sensitive internal diagnostic" not in body
|
||||
|
||||
|
||||
def test_task_lifecycle_evidence_and_release_gate_endpoints(tmp_path: Path) -> None:
|
||||
task_id = "api-lifecycle-task"
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"intent": "exercise candidate API",
|
||||
"binding": {
|
||||
"repository": "aios/aios",
|
||||
"branch": "patch/api-lifecycle",
|
||||
"base_oid": "a" * 40,
|
||||
"allowed_paths": ["services/test"],
|
||||
},
|
||||
"risk": "medium",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "test-step",
|
||||
"worker_role": "tester",
|
||||
"required_capabilities": ["test.execute"],
|
||||
}
|
||||
],
|
||||
"evidence_requirements": ["STEP_RESULT"],
|
||||
"production_side_effects_allowed": False,
|
||||
}
|
||||
with running_server(tmp_path) as (_, base):
|
||||
status, created = post_json(f"{base}/v1/tasks", task)
|
||||
assert status == 201
|
||||
assert created["production_side_effects"] is False
|
||||
status, steps = get_json(f"{base}/v1/tasks/{task_id}/steps")
|
||||
assert status == 200
|
||||
assert steps["steps"]["test-step"]["state"] == "READY"
|
||||
binding = {
|
||||
"step_id": "test-step",
|
||||
"worker_id": "worker-1",
|
||||
"session_id": "session-1",
|
||||
}
|
||||
_, claimed = post_json(f"{base}/v1/tasks/{task_id}/claim", binding)
|
||||
assert claimed["state"] == "RUNNING"
|
||||
_, heartbeat = post_json(f"{base}/v1/tasks/{task_id}/heartbeat", binding)
|
||||
assert heartbeat["state"] == "RUNNING"
|
||||
_, result = post_json(
|
||||
f"{base}/v1/tasks/{task_id}/result",
|
||||
{"step_id": "test-step", "result_digest": hashlib.sha256(b"ok").hexdigest()},
|
||||
)
|
||||
assert result["state"] == "COMPLETED"
|
||||
_, evidence = get_json(f"{base}/v1/tasks/{task_id}/evidence")
|
||||
assert evidence["verified"] is True
|
||||
assert any(item["event_type"] == "STEP_RESULT" for item in evidence["events"])
|
||||
_, closure = post_json(f"{base}/v1/tasks/{task_id}/evidence/close", {})
|
||||
assert len(closure["evidence_closure"]) == 64
|
||||
|
||||
release_plan = {
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"branch": "integration/api-lifecycle",
|
||||
"version": "SHADOW-0.1.0",
|
||||
"target_oid": "b" * 40,
|
||||
"tree_oid": "c" * 40,
|
||||
"artifact_sha256": "d" * 64,
|
||||
"manifest_sha256": "e" * 64,
|
||||
"package_closure_sha256": "f" * 64,
|
||||
"evidence_closure_oid": "1" * 40,
|
||||
"approval_plan_id": "external-owner-release",
|
||||
"rollback_approval_plan_id": "external-owner-rollback",
|
||||
"rollback_artifact_sha256": "2" * 64,
|
||||
"expected_tag_oid": "3" * 40,
|
||||
"expected_target_oid": "b" * 40,
|
||||
"production_config_sha256": "4" * 64,
|
||||
}
|
||||
_, release = post_json(f"{base}/v1/release/plan", release_plan)
|
||||
assert release["release_state"] == "BUILT"
|
||||
_, verified = post_json(
|
||||
f"{base}/v1/release/verify", {"artifact_ok": True, "evidence_ok": True}
|
||||
)
|
||||
assert verified["release_state"] == "WAITING_OWNER"
|
||||
assert verified["production_release"] == "BLOCKED_EXTERNAL_OWNER_SIGNATURE"
|
||||
@@ -1,94 +0,0 @@
|
||||
from typing import Any, Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.errors import StateConflict
|
||||
from aios_autonomous_release_candidate.native_adapters import (
|
||||
CodexNativeAdapter,
|
||||
OpenClawNativeAdapter,
|
||||
)
|
||||
|
||||
|
||||
class Provider:
|
||||
simulation = True
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.state = "ACTIVE"
|
||||
self.parent = "parent-1"
|
||||
|
||||
def spawn(self, request: Mapping[str, Any], credential_reference: str):
|
||||
return {
|
||||
"provider_session_id": f"{self.name}-native-1",
|
||||
"task_id": request["task_id"],
|
||||
"parent_session_id": request["parent_session_id"],
|
||||
"candidate_only": True,
|
||||
"production_session_access": False,
|
||||
}
|
||||
|
||||
def status(self, provider_session_id: str, credential_reference: str):
|
||||
return {"provider_session_id": provider_session_id, "state": self.state}
|
||||
|
||||
def list_tree(self, parent_session_id: str, credential_reference: str):
|
||||
return [{"provider_session_id": f"{self.name}-native-1", "parent_session_id": parent_session_id}]
|
||||
|
||||
def send_text(self, provider_session_id, payload, credential_reference):
|
||||
return {
|
||||
"content_type": "text/plain",
|
||||
"encoding": "utf-8",
|
||||
"image_only": False,
|
||||
"task_id": payload["task_id"],
|
||||
"message_id": "message-1",
|
||||
"receipt": "receipt-1",
|
||||
"recipient_binding": payload["recipient_binding"],
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
def interrupt(self, provider_session_id: str, credential_reference: str):
|
||||
self.state = "INTERRUPTED"
|
||||
|
||||
def close(self, provider_session_id: str, credential_reference: str):
|
||||
self.state = "CLOSED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adapter_type", "provider_name", "reference", "flag"),
|
||||
(
|
||||
(
|
||||
OpenClawNativeAdapter,
|
||||
"openclaw",
|
||||
"systemd-credential:openclaw-gateway-candidate",
|
||||
"openclaw_native_child_session_verified",
|
||||
),
|
||||
(
|
||||
CodexNativeAdapter,
|
||||
"codex",
|
||||
"systemd-credential:codex-app-server-candidate",
|
||||
"codex_child_session_verified",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_native_adapter_preserves_identity_and_text_contract(
|
||||
adapter_type, provider_name, reference, flag
|
||||
) -> None:
|
||||
adapter = adapter_type(Provider(provider_name), reference)
|
||||
session = adapter.spawn("task-native", "parent-1", {"recipient_id": "weixin-owner"})
|
||||
assert session.broker_session_id != session.provider_session_id
|
||||
verification = adapter.verify(session)
|
||||
assert verification.broker_session_verified is True
|
||||
assert getattr(verification, flag) is False # simulated providers are never real canary evidence
|
||||
result = adapter.send_text(session, "hello")
|
||||
assert result.content_type == "text/plain"
|
||||
assert result.secrets_included is False
|
||||
adapter.interrupt(session)
|
||||
assert adapter.status(session) == "INTERRUPTED"
|
||||
adapter.close(session)
|
||||
assert adapter.status(session) == "CLOSED"
|
||||
|
||||
|
||||
def test_recipient_binding_must_be_single_and_trusted() -> None:
|
||||
adapter = OpenClawNativeAdapter(
|
||||
Provider("openclaw"), "systemd-credential:openclaw-gateway-candidate"
|
||||
)
|
||||
with pytest.raises(StateConflict, match="RECIPIENT_BINDING_REQUIRED"):
|
||||
adapter.spawn("task-native", "parent-1", {"recipients": ["one", "two"]})
|
||||
@@ -1,95 +0,0 @@
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.approval import ApprovalVerificationResult
|
||||
from aios_autonomous_release_candidate.errors import ReleaseBlocked
|
||||
from aios_autonomous_release_candidate.models import ReleasePlan
|
||||
from aios_autonomous_release_candidate.release import ReleaseController, ReleaseState
|
||||
from aios_autonomous_release_candidate.release_gate import ReleaseEvidence, ReleaseGate
|
||||
from aios_autonomous_release_candidate.tag import TagReference, TagVerifier
|
||||
|
||||
|
||||
OID = "a" * 40
|
||||
TAG_OID = "b" * 40
|
||||
DIGEST = "c" * 64
|
||||
|
||||
|
||||
def plan() -> ReleasePlan:
|
||||
return ReleasePlan(
|
||||
task_id="release-task",
|
||||
repository="aios/aios",
|
||||
branch="integration/autonomous-release",
|
||||
version="V2R1C4",
|
||||
target_oid=OID,
|
||||
tree_oid="d" * 40,
|
||||
artifact_sha256=DIGEST,
|
||||
manifest_sha256="e" * 64,
|
||||
package_closure_sha256="3" * 64,
|
||||
evidence_closure_oid="f" * 40,
|
||||
approval_plan_id="owner-release-v2r1c4",
|
||||
rollback_approval_plan_id="owner-rollback-v2r1c4",
|
||||
rollback_artifact_sha256="1" * 64,
|
||||
expected_tag_oid=TAG_OID,
|
||||
expected_target_oid=OID,
|
||||
production_config_sha256="2" * 64,
|
||||
)
|
||||
|
||||
|
||||
def approval(plan_id: str) -> ApprovalVerificationResult:
|
||||
return ApprovalVerificationResult(
|
||||
approval_plan_id=plan_id,
|
||||
task_id="release-task",
|
||||
signing_key_id="owner-kms-1",
|
||||
verified_at=datetime(2026, 7, 26, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_deploy_and_rollback_require_distinct_verified_approvals() -> None:
|
||||
controller = ReleaseController()
|
||||
controller.attach_plan(plan())
|
||||
controller.mark_built()
|
||||
controller.verify(artifact_ok=True, evidence_ok=True)
|
||||
controller.request_owner_approval()
|
||||
with pytest.raises(ReleaseBlocked, match="OWNER_APPROVAL_PLAN_MISMATCH"):
|
||||
controller.approve(approval("wrong-plan"))
|
||||
controller.approve(approval("owner-release-v2r1c4"))
|
||||
controller.start_deployment()
|
||||
controller.mark_deployed()
|
||||
controller.request_rollback("candidate acceptance regression")
|
||||
with pytest.raises(ReleaseBlocked, match="ROLLBACK_APPROVAL_PLAN_MISMATCH"):
|
||||
controller.approve_rollback(approval("owner-release-v2r1c4"))
|
||||
controller.approve_rollback(approval("owner-rollback-v2r1c4"))
|
||||
controller.start_rollback()
|
||||
assert controller.mark_rolled_back().state == ReleaseState.ROLLED_BACK
|
||||
|
||||
|
||||
def test_annotated_tag_uses_object_and_peeled_oids_separately() -> None:
|
||||
tag = TagReference("refs/tags/V2R1C4", "tag", TAG_OID, OID)
|
||||
TagVerifier().verify(tag, expected_tag_oid=TAG_OID, expected_target_oid=OID)
|
||||
with pytest.raises(ReleaseBlocked, match="TAG_PEELED_TARGET_MISMATCH"):
|
||||
TagVerifier().verify(tag, expected_tag_oid=TAG_OID, expected_target_oid="3" * 40)
|
||||
|
||||
|
||||
def test_release_gate_requires_four_source_tag_and_artifact_closure() -> None:
|
||||
evidence = ReleaseEvidence(
|
||||
expected_version="V2R1C4",
|
||||
registry_version="V2R1C4",
|
||||
baseline_version="V2R1C4",
|
||||
local_tag_oid=TAG_OID,
|
||||
remote_tag_oid=TAG_OID,
|
||||
gitea_tag_oid=TAG_OID,
|
||||
expected_tag_oid=TAG_OID,
|
||||
local_peeled_target_oid=OID,
|
||||
remote_peeled_target_oid=OID,
|
||||
gitea_peeled_target_oid=OID,
|
||||
expected_target_oid=OID,
|
||||
artifact_sha256=DIGEST,
|
||||
manifest_sha256="e" * 64,
|
||||
evidence_sha256="4" * 64,
|
||||
rollback_artifact_sha256="1" * 64,
|
||||
)
|
||||
ReleaseGate().verify(evidence)
|
||||
with pytest.raises(ReleaseBlocked, match="TAG_GITEA_MISMATCH"):
|
||||
ReleaseGate().verify(replace(evidence, gitea_tag_oid="5" * 40))
|
||||
@@ -1,116 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.errors import CredentialUnavailable
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
from aios_autonomous_release_candidate.execution_manager import ExecutionManager
|
||||
from aios_autonomous_release_candidate.models import CredentialRequest, ResourceBinding
|
||||
from aios_autonomous_release_candidate.native_adapters import OpenClawNativeAdapter
|
||||
from aios_autonomous_release_candidate.secret_manager import SecretManagerV2
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
from test_native_adapter_contracts import Provider
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def exchange(self, operation, payload):
|
||||
self.calls.append((operation, payload))
|
||||
base = {
|
||||
"task_id": payload["task_id"],
|
||||
"credential_id": payload.get("credential_id", "credential-openclaw-1"),
|
||||
"plaintext_secret_returned": False,
|
||||
}
|
||||
if operation == "issue":
|
||||
return {
|
||||
**base,
|
||||
"credential_reference": "systemd-credential:openclaw-gateway-candidate",
|
||||
"state": "ISSUED",
|
||||
"expires_at": payload["expires_at"],
|
||||
}
|
||||
if operation == "audit":
|
||||
return {**base, "events": [{"operation": "issue", "secrets_included": False}]}
|
||||
if operation in {"project", "status"}:
|
||||
return {**base, "state": "PROJECTED"}
|
||||
if operation == "verify_revocation":
|
||||
return {**base, "state": "REVOKED", "post_revocation_auth": "REJECTED"}
|
||||
return {**base, "state": "REVOKED"}
|
||||
|
||||
|
||||
def credential_request() -> CredentialRequest:
|
||||
return CredentialRequest(
|
||||
request_id="request-1",
|
||||
task_id="task-execution",
|
||||
session_id="session-1",
|
||||
worker_id="worker-1",
|
||||
audience="openclaw-candidate",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
binding=ResourceBinding("aios/aios", "patch/execution", "a" * 40, ("services/test",)),
|
||||
operations=("native_session.spawn",),
|
||||
provider="openclaw",
|
||||
runtime_user="aiman",
|
||||
approval_plan_id="owner-stage-b-native-sessions-20260726",
|
||||
not_before=NOW,
|
||||
expires_at=NOW + timedelta(minutes=5),
|
||||
nonce="nonce-execution-1",
|
||||
ttl_seconds=300,
|
||||
max_uses=1,
|
||||
)
|
||||
|
||||
|
||||
def test_secret_manager_uses_reference_only_protocol() -> None:
|
||||
transport = Transport()
|
||||
manager = SecretManagerV2(transport)
|
||||
receipt = manager.issue(credential_request(), NOW)
|
||||
assert receipt.reference == "systemd-credential:openclaw-gateway-candidate"
|
||||
assert receipt.plaintext_secret_returned is False
|
||||
manager.project(receipt)
|
||||
manager.status(receipt)
|
||||
manager.revoke(receipt)
|
||||
manager.verify_revocation(receipt)
|
||||
events = manager.audit(receipt)
|
||||
assert events[0]["secrets_included"] is False
|
||||
assert [operation for operation, _ in transport.calls] == [
|
||||
"issue",
|
||||
"project",
|
||||
"status",
|
||||
"revoke",
|
||||
"verify_revocation",
|
||||
"audit",
|
||||
]
|
||||
|
||||
|
||||
def test_secret_manager_rejects_plaintext_fields() -> None:
|
||||
transport = Transport()
|
||||
|
||||
def unsafe(operation, payload):
|
||||
return {"task_id": payload["task_id"], "credential_id": "credential-1", "api_key": "redacted"}
|
||||
|
||||
transport.exchange = unsafe
|
||||
with pytest.raises(CredentialUnavailable, match="SECRET_MANAGER_PLAINTEXT_REJECTED"):
|
||||
SecretManagerV2(transport).issue(credential_request(), NOW)
|
||||
|
||||
|
||||
def test_execution_manager_records_simulation_without_native_claim(tmp_path: Path) -> None:
|
||||
evidence = EvidenceChain(StateStore(tmp_path / "state.sqlite3"), "task-execution")
|
||||
adapter = OpenClawNativeAdapter(
|
||||
Provider("openclaw"), "systemd-credential:openclaw-gateway-candidate"
|
||||
)
|
||||
result = ExecutionManager(evidence).run_native_canary(
|
||||
adapter,
|
||||
task_id="task-execution",
|
||||
parent_session_id="parent-1",
|
||||
recipient_binding={"recipient_id": "weixin-owner"},
|
||||
text="candidate canary",
|
||||
)
|
||||
assert result.broker_session_verified is True
|
||||
assert result.openclaw_native_child_session_verified is False
|
||||
assert result.simulation is True
|
||||
assert result.session_closed is True
|
||||
assert evidence.verify()
|
||||
@@ -1,89 +0,0 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.errors import StateConflict
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
StepState,
|
||||
TaskPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
from aios_autonomous_release_candidate.workflow import WorkflowEngine
|
||||
|
||||
|
||||
OID = "a" * 40
|
||||
|
||||
|
||||
def plan() -> TaskPlan:
|
||||
return TaskPlan(
|
||||
task_id="workflow-test",
|
||||
intent="exercise durable workflow",
|
||||
binding=ResourceBinding("aios/aios", "patch/workflow-test", OID, ("services/test",)),
|
||||
risk=RiskClass.LOW,
|
||||
steps=(
|
||||
WorkflowStep("build", "builder", ("file.write",), max_attempts=2, timeout_seconds=30),
|
||||
WorkflowStep("test", "tester", ("test.execute",), depends_on=("build",), max_attempts=2),
|
||||
),
|
||||
evidence_requirements=("tests",),
|
||||
)
|
||||
|
||||
|
||||
def test_store_is_private_and_rejects_schema_drift(tmp_path: Path) -> None:
|
||||
state = tmp_path / "private" / "state.sqlite3"
|
||||
StateStore(state)
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(state.parent.stat().st_mode) & 0o027 == 0
|
||||
|
||||
with StateStore(state).transaction() as connection:
|
||||
connection.execute("UPDATE metadata SET value='999' WHERE key='schema_version'")
|
||||
with pytest.raises(StateConflict, match="SCHEMA_VERSION_UNSUPPORTED"):
|
||||
StateStore(state)
|
||||
|
||||
|
||||
def test_dependency_retry_and_terminal_failure_are_durable(tmp_path: Path) -> None:
|
||||
store = StateStore(tmp_path / "state.sqlite3")
|
||||
engine = WorkflowEngine(store)
|
||||
task = plan()
|
||||
engine.plan(task)
|
||||
|
||||
assert engine.next_steps(task).ready_steps == ("build",)
|
||||
assert store.list_steps(task.task_id)["build"].state == StepState.READY
|
||||
engine.claim(task.task_id, "build", "writer", "session-1")
|
||||
engine.start(task.task_id, "build", "writer", "session-1")
|
||||
engine.complete(task.task_id, "build", result_digest="b" * 64)
|
||||
|
||||
assert engine.next_steps(task).ready_steps == ("test",)
|
||||
engine.claim(task.task_id, "test", "tester", "session-2")
|
||||
engine.start(task.task_id, "test", "tester", "session-2")
|
||||
assert engine.fail(task, "test", "transient", retryable=True) == StepState.PENDING
|
||||
assert engine.next_steps(task).ready_steps == ("test",)
|
||||
engine.claim(task.task_id, "test", "tester", "session-3")
|
||||
engine.start(task.task_id, "test", "tester", "session-3")
|
||||
assert engine.fail(task, "test", "still broken", retryable=True) == StepState.FAILED
|
||||
decision = engine.next_steps(task)
|
||||
assert decision.blocked is True
|
||||
assert decision.reason == "terminal_step_failure"
|
||||
|
||||
|
||||
def test_claim_is_atomic_under_contention(tmp_path: Path) -> None:
|
||||
store = StateStore(tmp_path / "state.sqlite3")
|
||||
engine = WorkflowEngine(store)
|
||||
task = plan()
|
||||
engine.plan(task)
|
||||
engine.next_steps(task)
|
||||
|
||||
def claim(worker: str) -> str:
|
||||
try:
|
||||
engine.claim(task.task_id, "build", worker, f"session-{worker}")
|
||||
return "claimed"
|
||||
except StateConflict:
|
||||
return "rejected"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(claim, ("one", "two")))
|
||||
assert sorted(results) == ["claimed", "rejected"]
|
||||
@@ -0,0 +1,228 @@
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.getcwd()
|
||||
sys.path.insert(0, str(Path(ROOT) / "active-runtime"))
|
||||
|
||||
from app.task_plane.workers.command_policy import CommandPolicy
|
||||
from app.task_plane.workers.task_client import (
|
||||
CredentialSource,
|
||||
StageAClientConfig,
|
||||
TaskClientAuthError,
|
||||
TaskClientProtocolError,
|
||||
TaskClientResponseTooLargeError,
|
||||
TaskPlaneClient,
|
||||
)
|
||||
|
||||
|
||||
def _start_server(responses):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
request_count = 0
|
||||
seen = []
|
||||
|
||||
def do_POST(self):
|
||||
index = Handler.request_count
|
||||
Handler.request_count += 1
|
||||
payload_length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = self.rfile.read(payload_length)
|
||||
try:
|
||||
payload_obj = json.loads(payload.decode("utf-8") or "{}")
|
||||
except Exception:
|
||||
payload_obj = {"raw": payload.decode("utf-8", errors="replace")}
|
||||
Handler.seen.append((self.path, payload_obj))
|
||||
|
||||
status, response_obj = responses[min(index, len(responses) - 1)]
|
||||
body = json.dumps(response_obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args, **_kwargs):
|
||||
return
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, thread, Handler
|
||||
|
||||
|
||||
def _stop_server(server, thread):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
class CodexWorkerCommandPolicyTests(unittest.TestCase):
|
||||
def test_rejects_revision_and_path_injection(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
policy = CommandPolicy(root, root / "results")
|
||||
with self.assertRaises(ValueError):
|
||||
policy.build("git-show", oid="HEAD~1", paths=["active-runtime/app/task_plane/api.py"])
|
||||
with self.assertRaises(ValueError):
|
||||
policy.build("pytest", oid="0" * 40, paths=["../outside"])
|
||||
|
||||
|
||||
class CodexWorkerClientStageBTests(unittest.TestCase):
|
||||
def test_client_retry_on_retryable_status(self):
|
||||
server, thread, handler = _start_server([(503, {"error": "try again"}), (200, {"ok": True})])
|
||||
try:
|
||||
client = TaskPlaneClient(
|
||||
StageAClientConfig(base_url=f"http://127.0.0.1:{server.server_port}"),
|
||||
token="stage-b-token-" + "x" * 32,
|
||||
)
|
||||
body = client._post("/tests/retry", {"probe": "x"}, idempotency_key="retry")
|
||||
self.assertEqual(body, {"ok": True})
|
||||
self.assertEqual(handler.request_count, 2)
|
||||
self.assertTrue(handler.seen[0][0].startswith("/tests/retry"))
|
||||
finally:
|
||||
_stop_server(server, thread)
|
||||
|
||||
def test_client_stops_retry_on_auth_error(self):
|
||||
server, thread, handler = _start_server([(403, {"error": "denied"})])
|
||||
try:
|
||||
client = TaskPlaneClient(
|
||||
StageAClientConfig(base_url=f"http://127.0.0.1:{server.server_port}"),
|
||||
token="stage-b-token-" + "x" * 32,
|
||||
)
|
||||
with self.assertRaises(TaskClientAuthError):
|
||||
client._post("/tests/auth", {"probe": "x"}, idempotency_key="auth")
|
||||
self.assertEqual(handler.request_count, 1)
|
||||
finally:
|
||||
_stop_server(server, thread)
|
||||
|
||||
def test_client_rejects_oversize_request_and_response(self):
|
||||
server, thread, _ = _start_server([(200, {"ok": True})])
|
||||
try:
|
||||
small = TaskPlaneClient(
|
||||
StageAClientConfig(
|
||||
base_url=f"http://127.0.0.1:{server.server_port}",
|
||||
max_request_bytes=8,
|
||||
),
|
||||
token="stage-b-token-" + "x" * 32,
|
||||
)
|
||||
with self.assertRaises(TaskClientResponseTooLargeError):
|
||||
small._post("/tests/request", {"probe": "too-large"}, idempotency_key="req")
|
||||
finally:
|
||||
_stop_server(server, thread)
|
||||
|
||||
oversize = {"blob": "x" * 1_100_000}
|
||||
server, thread, _ = _start_server([(200, oversize)])
|
||||
try:
|
||||
client = TaskPlaneClient(
|
||||
StageAClientConfig(base_url=f"http://127.0.0.1:{server.server_port}"),
|
||||
token="stage-b-token-" + "x" * 32,
|
||||
)
|
||||
with self.assertRaises(TaskClientResponseTooLargeError):
|
||||
client._post("/tests/response", {"probe": "ok"}, idempotency_key="resp")
|
||||
finally:
|
||||
_stop_server(server, thread)
|
||||
|
||||
def test_client_credential_source_symlink_and_mode(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
token_file = directory / "token"
|
||||
token_file.write_text("x" * 32, encoding="utf-8")
|
||||
token_file.chmod(0o600)
|
||||
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
owner = pwd.getpwuid(os.getuid()).pw_name
|
||||
group = grp.getgrgid(os.getgid()).gr_name
|
||||
|
||||
source = CredentialSource(
|
||||
token_file,
|
||||
token_owner=owner,
|
||||
token_group=group,
|
||||
expected_mode=0o600,
|
||||
)
|
||||
self.assertEqual(source.read_token(), "x" * 32)
|
||||
|
||||
symlink = directory / "token-link"
|
||||
symlink.symlink_to(token_file)
|
||||
with self.assertRaises(TaskClientAuthError):
|
||||
CredentialSource(
|
||||
symlink,
|
||||
token_owner=owner,
|
||||
token_group=group,
|
||||
expected_mode=0o600,
|
||||
).read_token()
|
||||
|
||||
bad_mode = directory / "bad-mode"
|
||||
bad_mode.write_text("value", encoding="utf-8")
|
||||
bad_mode.chmod(0o644)
|
||||
with self.assertRaises(TaskClientAuthError):
|
||||
CredentialSource(
|
||||
bad_mode,
|
||||
token_owner=owner,
|
||||
token_group=group,
|
||||
expected_mode=0o600,
|
||||
).read_token()
|
||||
|
||||
def test_client_rejects_missing_claim_id_and_reports_heartbeat_defaults(self):
|
||||
client = TaskPlaneClient(StageAClientConfig(), token="stage-b-token-" + "x" * 32)
|
||||
with self.assertRaises(TaskClientProtocolError):
|
||||
client.progress("task-id", claim_id="", progress=10, stage="RUN")
|
||||
with self.assertRaises(TaskClientProtocolError):
|
||||
client.result(
|
||||
"task-id",
|
||||
claim_id="",
|
||||
status="COMPLETED",
|
||||
outputs=[],
|
||||
evidence=[],
|
||||
started_at="",
|
||||
completed_at="",
|
||||
)
|
||||
with self.assertRaises(TaskClientProtocolError):
|
||||
client.fail("task-id", claim_id="")
|
||||
|
||||
def test_heartbeat_request_uses_default_lease(self):
|
||||
captured = {}
|
||||
|
||||
def handler_factory(responses):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
payload_length = int(self.headers.get("Content-Length", "0"))
|
||||
captured["path"] = self.path
|
||||
captured["payload"] = json.loads(self.rfile.read(payload_length).decode("utf-8") or "{}")
|
||||
status, response_obj = responses[min(responses_index[0], len(responses) - 1)]
|
||||
responses_index[0] += 1
|
||||
response = json.dumps(response_obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
def log_message(self, *_args, **_kwargs):
|
||||
return
|
||||
|
||||
return Handler
|
||||
|
||||
responses_index = [0]
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler_factory([(200, {"claim_id": "x"}), (200, {"ok": True})]))
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = TaskPlaneClient(
|
||||
StageAClientConfig(base_url=f"http://127.0.0.1:{server.server_port}"),
|
||||
token="stage-b-token-" + "x" * 32,
|
||||
)
|
||||
client.claim_heartbeat("task-id", claim_id="claim-id")
|
||||
self.assertEqual(captured["payload"], {"claim_id": "claim-id", "lease_seconds": 120})
|
||||
self.assertTrue(captured["path"].endswith("/tasks/task-id/heartbeat"))
|
||||
|
||||
captured["payload"] = None
|
||||
client.worker_heartbeat("worker-1")
|
||||
self.assertEqual(captured["payload"], {})
|
||||
finally:
|
||||
_stop_server(server, thread)
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = os.getcwd()
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(ROOT) / "active-runtime"))
|
||||
|
||||
from app.task_plane.workers.codex_review_test import CodexReviewTaskPolicy
|
||||
from app.task_plane.workers.result_attestation import TaskResultEnvelope, TrustPolicy, task_result_envelope
|
||||
from app.task_plane.workers.workspace import TaskWorkspace
|
||||
|
||||
|
||||
class CodexWorkerWorkspaceRecoveryTests(unittest.TestCase):
|
||||
def test_workspace_permissions_and_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root_path = Path(directory) / "task-root"
|
||||
with TaskWorkspace.create(root_path, task_id="task-01", claim_id="claim-01") as workspace:
|
||||
for path in (
|
||||
workspace.layout.workspace_root,
|
||||
workspace.layout.repo_root,
|
||||
workspace.layout.result_root,
|
||||
workspace.layout.home,
|
||||
workspace.layout.tmp,
|
||||
):
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700)
|
||||
|
||||
result_path = workspace.write_result(name="result.json", payload="{}")
|
||||
self.assertTrue(result_path.exists())
|
||||
self.assertEqual(stat.S_IMODE(result_path.stat().st_mode), 0o600)
|
||||
self.assertFalse(root_path.exists())
|
||||
|
||||
def test_result_envelope_validates_required_contract_fields(self):
|
||||
envelope = task_result_envelope(
|
||||
task_id="task-01",
|
||||
claim_id="claim-01",
|
||||
worker_identity="spiffe://aios/agent/codex/review-01",
|
||||
status="COMPLETED",
|
||||
outputs=[],
|
||||
evidence=[],
|
||||
started_at="2026-07-24T00:00:00Z",
|
||||
completed_at="2026-07-24T00:00:01Z",
|
||||
)
|
||||
payload = envelope.to_dict()
|
||||
self.assertEqual(payload["production_trust"], False)
|
||||
self.assertEqual(payload["trust_level"], TrustPolicy.SHADOW_ATTESTED.value)
|
||||
self.assertEqual(payload["task_id"], "task-01")
|
||||
|
||||
invalid = TaskResultEnvelope(
|
||||
task_id="",
|
||||
claim_id="claim",
|
||||
worker_identity="worker",
|
||||
status="FAILED",
|
||||
outputs=[],
|
||||
evidence=[],
|
||||
started_at="",
|
||||
completed_at="",
|
||||
production_trust=False,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
invalid.validate()
|
||||
|
||||
def test_shadow_policy_enforces_concurrency_and_timeboxes(self):
|
||||
policy = CodexReviewTaskPolicy(worker_id="worker-01", identity="identity")
|
||||
self.assertEqual(policy.max_concurrency, 1)
|
||||
self.assertEqual(policy.heartbeat_interval_seconds, 15)
|
||||
self.assertEqual(policy.claim_lease_seconds, 120)
|
||||
|
||||
valid_task = {
|
||||
"risk": "low",
|
||||
"metadata": {"production_side_effects": False},
|
||||
}
|
||||
policy.assert_shadow_only_task(valid_task)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
policy.assert_shadow_only_task(
|
||||
{
|
||||
"risk": "critical",
|
||||
"metadata": {"production_side_effects": False},
|
||||
}
|
||||
)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
CodexReviewTaskPolicy(
|
||||
worker_id="worker-01",
|
||||
identity="identity",
|
||||
max_concurrency=2,
|
||||
).assert_shadow_only_task(valid_task)
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = os.getcwd()
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(ROOT) / "active-runtime"))
|
||||
|
||||
from app.task_plane.workers.command_policy import CommandPolicy
|
||||
from app.task_plane.workers.runner import CommandDisallowedError, SafeCommandRunner, CommandPolicyError
|
||||
from app.task_plane.workers.workspace import TaskWorkspace
|
||||
|
||||
|
||||
class CodexWorkerRunnerSecurityTests(unittest.TestCase):
|
||||
def test_runner_only_allows_fixed_command_set(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with TaskWorkspace.create(Path(directory) / "workspace", task_id="task-fixed", claim_id="claim-fixed") as workspace:
|
||||
runner = SafeCommandRunner(workspace.layout.repo_root, workspace.layout.result_root)
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("shell", oid="0" * 40)
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("bash", oid="0" * 40, paths=["tests/task_plane/public_interface"])
|
||||
|
||||
def test_runner_rejects_shell_like_and_eval_inputs(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with TaskWorkspace.create(Path(directory) / "workspace", task_id="task-shell", claim_id="claim-shell") as workspace:
|
||||
runner = SafeCommandRunner(workspace.layout.repo_root, workspace.layout.result_root)
|
||||
CommandPolicy(workspace.layout.repo_root, workspace.layout.result_root).build(
|
||||
"pytest",
|
||||
oid="0" * 40,
|
||||
paths=["tests/task_plane/public_interface"],
|
||||
)
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("pytest", oid="0" * 40, paths=["tests/task_plane/public_interface;touch /tmp/x"])
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("pytest", oid="0" * 40, paths=["tests/task_plane/public_interface", "eval"])
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("pytest", oid="0" * 40, paths=["tests/task_plane/public_interface", "bash"])
|
||||
|
||||
def test_runner_rejects_revision_expression_and_symlink_paths(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with TaskWorkspace.create(Path(directory) / "workspace", task_id="task-revision", claim_id="claim-revision") as workspace:
|
||||
runner = SafeCommandRunner(workspace.layout.repo_root, workspace.layout.result_root)
|
||||
policy_root = workspace.layout.repo_root
|
||||
(policy_root / "tests" / "task_plane" / "public_interface").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
runner.run("git-show", oid="HEAD~1", paths=["active-runtime/app/task_plane/worker.py"])
|
||||
|
||||
link_target = policy_root / "tests" / "task_plane" / "public_interface" / "link"
|
||||
link_target.symlink_to(Path("/etc/passwd"), target_is_directory=False)
|
||||
with self.assertRaises(CommandPolicyError):
|
||||
runner.run("git-show", oid="0" * 40, paths=["tests/task_plane/public_interface/link"])
|
||||
|
||||
def test_runner_rejects_network_tokens_and_output_escape(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with TaskWorkspace.create(Path(directory) / "workspace", task_id="task-net", claim_id="claim-net") as workspace:
|
||||
runner = SafeCommandRunner(workspace.layout.repo_root, workspace.layout.result_root)
|
||||
|
||||
with self.assertRaises(CommandDisallowedError):
|
||||
runner.run("git-show", oid="0" * 40, paths=["https://bad.example/attack"])
|
||||
|
||||
with self.assertRaises(CommandPolicyError):
|
||||
runner.run(
|
||||
"acceptance",
|
||||
oid="0" * 40,
|
||||
paths=["active-runtime"],
|
||||
output=workspace.layout.home.parent / "escape.json",
|
||||
)
|
||||
|
||||
def test_runner_accepts_nested_allowlisted_paths_and_rejects_secret_env(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with TaskWorkspace.create(Path(directory) / "workspace", task_id="task-safe", claim_id="claim-safe") as workspace:
|
||||
runner = SafeCommandRunner(workspace.layout.repo_root, workspace.layout.result_root)
|
||||
command = runner.build(
|
||||
"pytest",
|
||||
oid="0" * 40,
|
||||
paths=["tests/task_plane/public_interface"],
|
||||
)
|
||||
self.assertEqual(command[-1], "tests/task_plane/public_interface")
|
||||
with self.assertRaises(Exception):
|
||||
runner.run("diff-check", oid="0" * 40, env={"API_TOKEN": "secret"})
|
||||
@@ -1,417 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import aios_isolated_writer_policy as writer_policy
|
||||
import isolated_writer_path_policy as path_policy
|
||||
from isolated_writer_authz import AuthorizationError, _validate_grant
|
||||
|
||||
|
||||
TARGET = "deploy/systemd/aios-codex-review-test-worker.service"
|
||||
BRANCH = "patch/codex-review-test-worker-20260724"
|
||||
AUDIENCE = "aios-codex-review-worker-writer.service"
|
||||
|
||||
VALID_UNIT = """\
|
||||
[Unit]
|
||||
Description=AIOS Codex Review/Test Worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/usr/libexec/aios-codex-review-test-worker/current
|
||||
Environment=AIOS_CODEX_WORKER_MODE=shadow
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RuntimeDirectory=aios-codex-review-test-worker
|
||||
StateDirectory=aios-codex-review-test-worker
|
||||
CacheDirectory=aios-codex-review-test-worker
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadOnlyPaths=/usr/libexec/aios-codex-review-test-worker
|
||||
ReadWritePaths=/run/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/lib/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/cache/aios-codex-review-test-worker
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
def document(*, paths=None, **updates):
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"policy_id": "codex-review-worker-path-policy-v1",
|
||||
"audience": AUDIENCE,
|
||||
"repository_identity": "aios/aios",
|
||||
"allowed_branch": BRANCH,
|
||||
"allowed_operations": {
|
||||
"commit_file.atomic": {
|
||||
"exact_paths": paths or [TARGET],
|
||||
"max_content_bytes": 32768,
|
||||
"content_profile": "systemd-codex-review-worker-v1",
|
||||
}
|
||||
},
|
||||
}
|
||||
value.update(updates)
|
||||
return value
|
||||
|
||||
|
||||
class ExactPathPolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(os.environ)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.policy_dir = self.root / "policies"
|
||||
self.policy_dir.mkdir(mode=0o750)
|
||||
self.policy_file = self.policy_dir / "writer.json"
|
||||
self.workspace = self.root / "workspace"
|
||||
self.workspace.mkdir(mode=0o700)
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT": "required",
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE": AUDIENCE,
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
"AIOS_GITOPS_ALLOWED_BRANCH": BRANCH,
|
||||
}
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
path_policy.clear_path_policy_cache()
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved)
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_policy(self, value=None):
|
||||
payload = document() if value is None else value
|
||||
raw = (
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
)
|
||||
self.policy_file.write_text(raw, encoding="utf-8")
|
||||
self.policy_file.chmod(0o640)
|
||||
return path_policy.load_path_policy(
|
||||
self.policy_file,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def configured(self, loaded):
|
||||
return mock.patch.object(
|
||||
path_policy,
|
||||
"configured_path_policy",
|
||||
return_value=loaded,
|
||||
)
|
||||
|
||||
def test_exact_systemd_path_and_exact_token_path_are_allowed(self):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded):
|
||||
self.assertEqual(TARGET, writer_policy.validate_path(TARGET, self.workspace))
|
||||
writer_policy.validate_content_for_path(TARGET, VALID_UNIT)
|
||||
|
||||
def test_token_path_cannot_expand_service_policy(self):
|
||||
loaded = self.write_policy(document(paths=["deploy/systemd/other.service"]))
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_service_path_rejected"
|
||||
):
|
||||
writer_policy.validate_path(TARGET, self.workspace)
|
||||
|
||||
def test_service_policy_cannot_expand_token_path(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
grant = {
|
||||
"schema_version": 2,
|
||||
"audience": AUDIENCE,
|
||||
"source_cidrs": ["127.0.0.1/32"],
|
||||
"created_at": (now - timedelta(seconds=5)).isoformat(),
|
||||
"not_before": (now - timedelta(seconds=2)).isoformat(),
|
||||
"expires_at": (now + timedelta(minutes=5)).isoformat(),
|
||||
"revoked": False,
|
||||
"allowed_operations": ["commit_file.atomic"],
|
||||
"allowed_branch": BRANCH,
|
||||
"allowed_paths": ["deploy/systemd/other.service"],
|
||||
"approved_base_oid": "a" * 40,
|
||||
}
|
||||
with mock.patch(
|
||||
"isolated_writer_authz._git_is_ancestor", return_value=True
|
||||
), self.assertRaisesRegex(AuthorizationError, "writer_token_path_rejected"):
|
||||
_validate_grant(
|
||||
grant,
|
||||
audience=AUDIENCE,
|
||||
source_ip="127.0.0.1",
|
||||
operation="commit_file.atomic",
|
||||
branch=BRANCH,
|
||||
path=TARGET,
|
||||
workspace=self.workspace,
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
def test_other_service_file_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_service_path_rejected"
|
||||
):
|
||||
writer_policy.validate_path(
|
||||
"deploy/systemd/other.service", self.workspace
|
||||
)
|
||||
|
||||
def test_path_traversal_is_rejected(self):
|
||||
for value in ("deploy/systemd/../x", "../deploy/systemd/x"):
|
||||
with self.subTest(value=value), self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(value, self.workspace)
|
||||
|
||||
def test_url_encoded_traversal_is_rejected(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(
|
||||
"deploy/systemd/%2e%2e/x", self.workspace
|
||||
)
|
||||
|
||||
def test_symlink_policy_file_is_rejected(self):
|
||||
self.write_policy()
|
||||
link = self.policy_dir / "link.json"
|
||||
link.symlink_to(self.policy_file)
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError, "isolated_writer_path_policy_symlink_rejected"
|
||||
):
|
||||
path_policy.load_path_policy(
|
||||
link,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def test_world_writable_policy_file_is_rejected(self):
|
||||
self.write_policy()
|
||||
self.policy_file.chmod(0o646)
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_permissions_rejected",
|
||||
):
|
||||
path_policy.load_path_policy(
|
||||
self.policy_file,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def test_unknown_policy_field_is_rejected(self):
|
||||
value = document()
|
||||
value["unexpected"] = True
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_fields_rejected",
|
||||
):
|
||||
self.write_policy(value)
|
||||
|
||||
def test_audience_mismatch_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
os.environ["AIOS_GITOPS_PATH_POLICY_FILE"] = str(self.policy_file)
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "other.service"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_audience_rejected",
|
||||
):
|
||||
path_policy.configured_path_policy()
|
||||
|
||||
def test_branch_mismatch_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
os.environ["AIOS_GITOPS_PATH_POLICY_FILE"] = str(self.policy_file)
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/other"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_branch_rejected",
|
||||
):
|
||||
path_policy.configured_path_policy()
|
||||
|
||||
def assert_profile_rejected(self, content):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_systemd_profile_rejected"
|
||||
):
|
||||
writer_policy.validate_content_for_path(TARGET, content)
|
||||
|
||||
def test_disallowed_execution_and_credential_directives_are_rejected(self):
|
||||
directives = (
|
||||
"ExecStartPre=/usr/bin/id",
|
||||
"ExecStartPost=/usr/bin/id",
|
||||
"ExecReload=/usr/bin/id",
|
||||
"ExecStop=/usr/bin/id",
|
||||
"ExecStopPost=/usr/bin/id",
|
||||
"ExecCondition=/usr/bin/true",
|
||||
"EnvironmentFile=/run/worker.env",
|
||||
"PassEnvironment=PATH",
|
||||
"LoadCredential=worker:/run/worker",
|
||||
"SetCredential=worker:value",
|
||||
"ImportCredential=worker",
|
||||
"StandardInput=socket",
|
||||
"StandardOutput=socket",
|
||||
)
|
||||
for directive in directives:
|
||||
with self.subTest(directive=directive):
|
||||
self.assert_profile_rejected(
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
f"Restart=on-failure\n{directive}",
|
||||
)
|
||||
)
|
||||
|
||||
def test_disallowed_namespace_and_capability_directives_are_rejected(self):
|
||||
directives = (
|
||||
"RootDirectory=/srv/worker",
|
||||
"RootImage=/srv/worker.raw",
|
||||
"ReadWritePaths=/",
|
||||
"BindPaths=/etc:/etc",
|
||||
"BindReadOnlyPaths=/etc:/etc",
|
||||
"TemporaryFileSystem=/etc",
|
||||
"CapabilityBoundingSet=CAP_SYS_ADMIN",
|
||||
"AmbientCapabilities=CAP_SYS_ADMIN",
|
||||
"DeviceAllow=/dev/sda rw",
|
||||
"SupplementaryGroups=docker",
|
||||
"Sockets=unsafe.socket",
|
||||
)
|
||||
for directive in directives:
|
||||
with self.subTest(directive=directive):
|
||||
self.assert_profile_rejected(
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
f"Restart=on-failure\n{directive}",
|
||||
)
|
||||
)
|
||||
|
||||
def test_duplicate_single_directives_and_sections_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("User=aiman", "User=aiman\nUser=aiman"),
|
||||
VALID_UNIT.replace(
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test\n"
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
),
|
||||
VALID_UNIT + "\n[Service]\nType=simple\n",
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_empty_reset_continuation_crlf_nul_and_long_lines_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace(
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
"ExecStart=",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
"Restart=on-failure\n continuation-injection",
|
||||
),
|
||||
VALID_UNIT.replace("\n", "\r\n"),
|
||||
VALID_UNIT.replace("Restart=on-failure", "Restart=on-failure\x00"),
|
||||
VALID_UNIT.replace(
|
||||
"Description=AIOS Codex Review/Test Worker",
|
||||
"Description=" + ("A" * 4097),
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(kind=repr(value[:80])):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_unknown_environment_and_production_execution_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace(
|
||||
"Environment=PYTHONDONTWRITEBYTECODE=1",
|
||||
"Environment=UNKNOWN_KEY=value",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=false",
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=true",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"Environment=PYTHONDONTWRITEBYTECODE=1",
|
||||
"Environment=Authorization: Bearer placeholder",
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_identity_working_directory_and_path_prefix_bypasses_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("User=aiman", "User=root"),
|
||||
VALID_UNIT.replace(
|
||||
"/usr/libexec/aios-codex-review-test-worker/current",
|
||||
"/data/aios-gitops/worktrees/worker",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"ReadWritePaths=/run/aios-codex-review-test-worker",
|
||||
"ReadWritePaths=/etc",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"ReadOnlyPaths=/data/aios-gitops/repo.git",
|
||||
"ReadOnlyPaths=/data",
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_unknown_section_directive_and_unicode_lookalike_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("[Install]", "[Socket]"),
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
"Restart=on-failure\nProtectClock=true",
|
||||
),
|
||||
VALID_UNIT.replace("ExecStart=", "\u0415xecStart="),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_invalid_unicode_surrogate_and_missing_final_newline_are_rejected(self):
|
||||
for value in (VALID_UNIT + "\ud800", VALID_UNIT.rstrip("\n")):
|
||||
with self.subTest(value=repr(value[-30:])):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_writer_request_cannot_modify_policy_file(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(
|
||||
"etc/aios-gitops/writer-path-policies/codex-review-worker.json",
|
||||
self.workspace,
|
||||
)
|
||||
|
||||
def test_coarse_file_and_commit_endpoints_remain_forbidden(self):
|
||||
for endpoint in ("/api/file", "/api/commit"):
|
||||
with self.subTest(endpoint=endpoint), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_operation_forbidden"
|
||||
):
|
||||
writer_policy.validate_endpoint(
|
||||
"POST", endpoint, self.workspace, self.root
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,10 +15,6 @@ class IsolatedWriterPolicyTests(unittest.TestCase):
|
||||
os.environ["AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT"] = "required"
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/aios-agent-task-control-plane-20260724"
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BASE_OID"] = "a8e97868976b0dce17b7cb8487eadc1d9c687622"
|
||||
os.environ["AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED"] = "2"
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "test-isolated-writer.service"
|
||||
os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"] = "/tmp/test-writer-token-registry.sqlite3"
|
||||
os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"] = "/tmp/test-writer-operations.sqlite3"
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.clear()
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
|
||||
import aios_isolated_writer_policy as policy
|
||||
from isolated_writer_authz import AuthorizationError, authorize, operation_for
|
||||
from isolated_writer_commit_file import (
|
||||
AtomicCommitFile,
|
||||
CommitFileError,
|
||||
OperationLedger,
|
||||
SimulatedCrash,
|
||||
)
|
||||
from isolated_writer_token_registry import TokenRegistry, token_digest
|
||||
|
||||
|
||||
BRANCH = "patch/codex-review-test-worker-20260724"
|
||||
AUDIENCE = "aios-codex-review-worker-writer.service"
|
||||
TASK_ID = "AIOS-CODEX-WRITER-SMOKE-20260724"
|
||||
TARGET_PATH = "docs/reliability/codex-review-worker-writer-smoke.json"
|
||||
|
||||
|
||||
def run(cwd, *args):
|
||||
return subprocess.check_output(args, cwd=cwd, text=True).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyGrant:
|
||||
token_id: str = "test-token-id"
|
||||
task_id: str = TASK_ID
|
||||
operation: str = "commit_file.atomic"
|
||||
record: dict = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.record is None:
|
||||
self.record = {"audience": AUDIENCE, "_source_ip": "127.0.0.1"}
|
||||
|
||||
|
||||
class RepositoryCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(os.environ)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.authoritative = self.root / "authoritative.git"
|
||||
self.gitea = self.root / "gitea.git"
|
||||
self.seed = self.root / "seed"
|
||||
self.workspace = self.root / "writer"
|
||||
subprocess.run(["git", "init", "--bare", str(self.authoritative)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(self.seed)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "writer@example.invalid"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Writer Test"], cwd=self.seed, check=True)
|
||||
(self.seed / "README.md").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "base"], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(["git", "branch", "-M", "main"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "remote", "add", "origin", str(self.authoritative)], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "push", "origin", "main"], cwd=self.seed, check=True, capture_output=True)
|
||||
self.base = run(self.seed, "git", "rev-parse", "HEAD")
|
||||
subprocess.run(["git", "checkout", "-b", BRANCH], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(["git", "push", "origin", BRANCH], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "--git-dir", str(self.authoritative), "worktree", "add", str(self.workspace), BRANCH],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "config", "user.email", "writer@example.invalid"], cwd=self.workspace, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Writer Test"], cwd=self.workspace, check=True)
|
||||
subprocess.run(["git", "clone", "--bare", str(self.authoritative), str(self.gitea)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "remote", "add", "gitea-http", str(self.gitea)], cwd=self.workspace, check=True)
|
||||
self.registry = TokenRegistry(self.root / "registry.sqlite3")
|
||||
os.environ.update({
|
||||
"AIOS_GITOPS_ALLOWED_BRANCH": BRANCH,
|
||||
"AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED": "2",
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE": AUDIENCE,
|
||||
"AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT": "required",
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved)
|
||||
self.temp.cleanup()
|
||||
|
||||
def grant(self, **updates):
|
||||
now = datetime.now(timezone.utc)
|
||||
grant = {
|
||||
"token_id": "smoke-" + hashlib.sha256(os.urandom(16)).hexdigest()[:12],
|
||||
"audience": AUDIENCE,
|
||||
"task_id": TASK_ID,
|
||||
"source_cidrs": ["127.0.0.1/32", "::1/128"],
|
||||
"allowed_branch": BRANCH,
|
||||
"approved_base_oid": self.base,
|
||||
"allowed_operations": [
|
||||
"runtime.status.read",
|
||||
"baseline.read",
|
||||
"patches.read",
|
||||
"commit_file.atomic",
|
||||
],
|
||||
"allowed_paths": [TARGET_PATH],
|
||||
"not_before": (now - timedelta(seconds=2)).isoformat(),
|
||||
"expires_at": (now + timedelta(minutes=20)).isoformat(),
|
||||
"max_uses": {
|
||||
"runtime.status.read": 2,
|
||||
"baseline.read": 2,
|
||||
"patches.read": 2,
|
||||
"commit_file.atomic": 4,
|
||||
},
|
||||
"issued_by": "owner-authorized-local-maintenance-executor",
|
||||
"created_at": (now - timedelta(seconds=3)).isoformat(),
|
||||
}
|
||||
grant.update(updates)
|
||||
return grant
|
||||
|
||||
def issue(self, **updates):
|
||||
return self.registry.issue(self.grant(**updates))
|
||||
|
||||
def auth(self, token, **updates):
|
||||
args = {
|
||||
"method": "GET",
|
||||
"path": "/api/runtime-status",
|
||||
"source_ip": "127.0.0.1",
|
||||
"workspace": self.workspace,
|
||||
}
|
||||
args.update(updates)
|
||||
return authorize(self.registry, token, **args)
|
||||
|
||||
def request(self, key="writer-smoke-0001", content='{"status":"READY"}\n'):
|
||||
return {
|
||||
"branch": BRANCH,
|
||||
"path": TARGET_PATH,
|
||||
"content": content,
|
||||
"message": "chore(task-plane): verify Codex writer endpoint",
|
||||
"expected_head_oid": run(self.workspace, "git", "rev-parse", "HEAD"),
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
def writer(self, crash=None, remote="gitea-http", audit=None):
|
||||
return AtomicCommitFile(
|
||||
workspace=self.workspace,
|
||||
bare_repo=self.authoritative,
|
||||
ledger=OperationLedger(self.root / "operations.sqlite3"),
|
||||
remote=remote,
|
||||
audit=audit or (lambda record: None),
|
||||
crash_after_state=crash,
|
||||
)
|
||||
|
||||
|
||||
class TokenPolicyV2Tests(RepositoryCase):
|
||||
def test_v1_coarse_scope_token_is_rejected(self):
|
||||
token, record = self.issue()
|
||||
record["schema_version"] = 1
|
||||
with self.registry._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE token_grants SET grant_json=? WHERE token_id=?",
|
||||
(json.dumps(record), record["token_id"]),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_policy_v2_required"):
|
||||
self.auth(token)
|
||||
|
||||
def test_audience_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(audience="other.service")
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_audience_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_source_ip_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(source_cidrs=["192.0.2.0/24"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_source_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_branch_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(allowed_branch="patch/other")
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_branch_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_operation_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(allowed_operations=["baseline.read"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_operation_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_path_mismatch_is_rejected(self):
|
||||
token, _ = self.issue()
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_path_rejected"):
|
||||
self.auth(
|
||||
token,
|
||||
method="POST",
|
||||
path="/api/commit-file",
|
||||
branch=BRANCH,
|
||||
target_path="docs/other.json",
|
||||
)
|
||||
|
||||
def test_expired_token_is_rejected(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
token, _ = self.issue(
|
||||
created_at=(now - timedelta(minutes=10)).isoformat(),
|
||||
not_before=(now - timedelta(minutes=9)).isoformat(),
|
||||
expires_at=(now - timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_expired"):
|
||||
self.auth(token)
|
||||
|
||||
def test_not_before_token_is_rejected(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
token, _ = self.issue(
|
||||
created_at=now.isoformat(),
|
||||
not_before=(now + timedelta(minutes=1)).isoformat(),
|
||||
expires_at=(now + timedelta(minutes=5)).isoformat(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_not_yet_valid"):
|
||||
self.auth(token)
|
||||
|
||||
def test_revoked_token_is_rejected(self):
|
||||
token, grant = self.issue()
|
||||
self.assertTrue(self.registry.revoke(grant["token_id"]))
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_revoked"):
|
||||
self.auth(token)
|
||||
|
||||
def test_operation_use_count_is_atomic_and_exhausted(self):
|
||||
token, _ = self.issue(max_uses={
|
||||
"runtime.status.read": 1,
|
||||
"baseline.read": 1,
|
||||
"patches.read": 1,
|
||||
"commit_file.atomic": 1,
|
||||
})
|
||||
self.auth(token)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_operation_exhausted"):
|
||||
self.auth(token)
|
||||
|
||||
def test_concurrent_use_count_never_exceeds_limit(self):
|
||||
token, _ = self.issue(max_uses={
|
||||
"runtime.status.read": 2,
|
||||
"baseline.read": 1,
|
||||
"patches.read": 1,
|
||||
"commit_file.atomic": 1,
|
||||
})
|
||||
successes, failures = [], []
|
||||
|
||||
def invoke():
|
||||
try:
|
||||
successes.append(self.auth(token))
|
||||
except AuthorizationError as exc:
|
||||
failures.append(exc.code)
|
||||
|
||||
threads = [threading.Thread(target=invoke) for _ in range(6)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(2, len(successes))
|
||||
self.assertEqual(4, failures.count("writer_token_operation_exhausted"))
|
||||
|
||||
def test_registry_does_not_store_plaintext_token(self):
|
||||
token, _ = self.issue()
|
||||
registry_bytes = b"".join(
|
||||
item.read_bytes()
|
||||
for item in self.registry.path.parent.glob(self.registry.path.name + "*")
|
||||
if item.is_file()
|
||||
)
|
||||
self.assertNotIn(token.encode(), registry_bytes)
|
||||
with self.registry._connect() as db:
|
||||
stored = db.execute("SELECT token_digest FROM token_grants").fetchone()[0]
|
||||
self.assertEqual(token_digest(token), stored)
|
||||
|
||||
def test_coarse_routes_have_no_operation_mapping(self):
|
||||
for path in ("/api/file", "/api/commit", "/api/tag", "/api/rollback", "/api/model"):
|
||||
with self.subTest(path=path), self.assertRaises(AuthorizationError) as captured:
|
||||
operation_for("POST", path)
|
||||
self.assertEqual(403, captured.exception.status)
|
||||
|
||||
def test_route_mapping_is_fixed_not_request_controlled(self):
|
||||
self.assertEqual("runtime.status.read", operation_for("GET", "/api/runtime-status"))
|
||||
self.assertEqual("baseline.read", operation_for("GET", "/api/baseline"))
|
||||
self.assertEqual("patches.read", operation_for("GET", "/api/patches"))
|
||||
self.assertEqual("commit_file.atomic", operation_for("POST", "/api/commit-file"))
|
||||
|
||||
def test_x_forwarded_for_cannot_replace_socket_peer(self):
|
||||
token, _ = self.issue(source_cidrs=["203.0.113.9/32"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_source_rejected"):
|
||||
self.auth(token, source_ip="127.0.0.1")
|
||||
|
||||
|
||||
class AtomicCommitFileTests(RepositoryCase):
|
||||
def test_commit_file_succeeds_and_verifies_bare_and_gitea(self):
|
||||
result = self.writer().execute(DummyGrant(), self.request())
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
self.assertEqual(
|
||||
result["commit_oid"],
|
||||
run(self.root, "git", "--git-dir", str(self.gitea), "rev-parse", f"refs/heads/{BRANCH}"),
|
||||
)
|
||||
|
||||
def test_same_idempotency_key_returns_same_commit(self):
|
||||
request = self.request()
|
||||
first = self.writer().execute(DummyGrant(), request)
|
||||
count = run(self.workspace, "git", "rev-list", "--count", "HEAD")
|
||||
second = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(first["commit_oid"], second["commit_oid"])
|
||||
self.assertEqual(count, run(self.workspace, "git", "rev-list", "--count", "HEAD"))
|
||||
self.assertTrue(second["idempotent"])
|
||||
|
||||
def test_same_key_with_different_payload_returns_409(self):
|
||||
request = self.request()
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request["content"] = '{"status":"DIFFERENT"}\n'
|
||||
with self.assertRaises(CommitFileError) as captured:
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(409, captured.exception.status)
|
||||
|
||||
def test_same_key_from_different_task_returns_409(self):
|
||||
request = self.request()
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
other = DummyGrant(token_id="other-token", task_id="OTHER-TASK")
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_idempotency_owner_conflict"):
|
||||
self.writer().execute(other, request)
|
||||
|
||||
def test_file_written_crash_is_recoverable(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="FILE_WRITTEN").execute(DummyGrant(), request)
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
|
||||
def test_committed_crash_is_recoverable_without_second_commit(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="COMMITTED").execute(DummyGrant(), request)
|
||||
committed = run(self.workspace, "git", "rev-parse", "HEAD")
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(committed, result["commit_oid"])
|
||||
|
||||
def test_pushed_crash_only_reverifies(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="PUSHED").execute(DummyGrant(), request)
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
|
||||
def test_push_failure_is_failed_recoverable(self):
|
||||
request = self.request()
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_gitea_push_failed"):
|
||||
self.writer(remote="missing-remote").execute(DummyGrant(), request)
|
||||
row = OperationLedger(self.root / "operations.sqlite3").get(request["idempotency_key"])
|
||||
self.assertEqual("FAILED_RECOVERABLE", row["state"])
|
||||
|
||||
def test_branch_head_conflict_requires_manual_review(self):
|
||||
request = self.request()
|
||||
ledger = OperationLedger(self.root / "operations.sqlite3")
|
||||
ledger.get_or_create(DummyGrant(), request, __import__("isolated_writer_commit_file").canonical_digest(request))
|
||||
(self.workspace / "README.md").write_text("drift\n", encoding="utf-8")
|
||||
subprocess.run(["git", "commit", "-am", "drift"], cwd=self.workspace, check=True, capture_output=True)
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_branch_head_conflict"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("MANUAL_REVIEW_REQUIRED", ledger.get(request["idempotency_key"])["state"])
|
||||
|
||||
def test_extra_staged_file_is_rejected(self):
|
||||
(self.workspace / "README.md").write_text("staged\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.workspace, check=True)
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_extra_staged_files_rejected"):
|
||||
self.writer().execute(DummyGrant(), self.request())
|
||||
|
||||
def test_path_traversal_and_protected_roots_are_rejected(self):
|
||||
for path in (
|
||||
"../docs/x", "%2e%2e/docs/x", "/etc/x", r"C:\temp\x",
|
||||
r"\\server\share\x", "baseline/current.json", "runtime/x",
|
||||
):
|
||||
request = self.request(key="writer-path-" + hashlib.sha256(path.encode()).hexdigest()[:12])
|
||||
request["path"] = path
|
||||
with self.subTest(path=path), self.assertRaises((PermissionError, CommitFileError)):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_audit_contains_ids_but_not_content_or_authorization(self):
|
||||
records = []
|
||||
request = self.request(content='{"value":"non-secret-body-marker"}\n')
|
||||
self.writer(audit=records.append).execute(DummyGrant(), request)
|
||||
encoded = json.dumps(records)
|
||||
self.assertIn("test-token-id", encoded)
|
||||
self.assertNotIn("non-secret-body-marker", encoded)
|
||||
self.assertNotIn("Authorization", encoded)
|
||||
|
||||
def test_concurrent_same_idempotency_creates_one_commit(self):
|
||||
request = self.request()
|
||||
results, errors = [], []
|
||||
|
||||
def invoke():
|
||||
try:
|
||||
results.append(self.writer().execute(DummyGrant(), request))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=invoke) for _ in range(4)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertFalse(errors)
|
||||
self.assertEqual(1, len({item["commit_oid"] for item in results}))
|
||||
|
||||
def test_different_operations_are_serialized_by_branch_lock(self):
|
||||
active = 0
|
||||
maximum = 0
|
||||
guard = threading.Lock()
|
||||
|
||||
class Probe(AtomicCommitFile):
|
||||
def _execute_locked(inner, grant, data, digest):
|
||||
nonlocal active, maximum
|
||||
with guard:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
time.sleep(0.05)
|
||||
with guard:
|
||||
active -= 1
|
||||
return {"state": "PROBED"}
|
||||
|
||||
probe = Probe(
|
||||
workspace=self.workspace,
|
||||
bare_repo=self.authoritative,
|
||||
ledger=OperationLedger(self.root / "probe.sqlite3"),
|
||||
remote="gitea-http",
|
||||
audit=lambda record: None,
|
||||
)
|
||||
requests = [self.request(key=f"writer-lock-{index:04d}") for index in range(3)]
|
||||
threads = [threading.Thread(target=probe.execute, args=(DummyGrant(), item)) for item in requests]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(1, maximum)
|
||||
|
||||
def test_invalid_expected_head_and_idempotency_are_rejected(self):
|
||||
request = self.request()
|
||||
request["expected_head_oid"] = "short"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_expected_head_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request = self.request()
|
||||
request["idempotency_key"] = "../bad"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_idempotency_key_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_invalid_message_and_nul_content_are_rejected(self):
|
||||
request = self.request()
|
||||
request["message"] = "bad\nmessage"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_message_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request = self.request()
|
||||
request["content"] = "bad\x00content"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_content_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_force_flags_are_403_and_unknown_fields_are_rejected(self):
|
||||
for field in ("force", "force_push"):
|
||||
request = self.request(key=f"writer-force-{field}")
|
||||
request[field] = True
|
||||
with self.subTest(field=field), self.assertRaises(CommitFileError) as captured:
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(403, captured.exception.status)
|
||||
request = self.request(key="writer-extra-field")
|
||||
request["operation"] = "commit_file.atomic"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_request_fields_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user