Files
aios/active-runtime/app/governed_local_writer.py
T

474 lines
16 KiB
Python

"""Shared primitives for the V2R1C6 governed local writer contract."""
from __future__ import annotations
import hashlib
import json
import os
import posixpath
import re
import socket
import stat
import struct
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence
TASK_ID = "V2R1C6_GOVERNED_LOCAL_BOOTSTRAP"
REPO = "aios/aios"
PRINCIPAL = "governed-local-bootstrap"
OIDSIZ = 40
AUTHORIZATION_TTL = timedelta(hours=2)
class GovernedLocalWriterError(Exception):
"""Base error for governed-local-writer validation and runtime failures."""
class AuthorizationError(GovernedLocalWriterError):
"""Raised when authorization input is invalid, expired, or tampered."""
@dataclass(frozen=True)
class BranchCreatePolicy:
"""Exact branch creation contract."""
branch: str
base_oid: str
head_oid: str
@dataclass(frozen=True)
class BranchAppendPolicy:
"""Exact append-commit contract for candidate branch updates."""
branch: str
expected_head_oid: str | None
allowed_paths: tuple[str, ...]
@dataclass(frozen=True)
class BranchPolicy:
"""Per-branch operation policies loaded from authorization."""
create_branch: BranchCreatePolicy | None
append_commit: BranchAppendPolicy | None
@dataclass(frozen=True)
class GovernedAuthorization:
"""Parsed and validated authorization object."""
task_id: str
repo: str
created_at: datetime
expires_at: datetime
authorization_sha256: str
one_time_use: bool
branch_operations: dict[str, BranchPolicy]
denied_operations: tuple[str, ...]
@property
def create_branches(self) -> set[str]:
"""Branches that are allowed for branch-creation operations."""
return {
branch
for branch, policy in self.branch_operations.items()
if policy.create_branch is not None
}
@property
def append_branches(self) -> set[str]:
"""Branches that are allowed for commit-append operations."""
return {
branch
for branch, policy in self.branch_operations.items()
if policy.append_commit is not None
}
@dataclass(frozen=True)
class PeerIdentity:
"""Verified peer identity from SO_PEERCRED."""
pid: int
uid: int
gid: int
executable: str | None
@dataclass(frozen=True)
class FileChange:
"""Single file modification payload."""
path: str
content: str
@dataclass(frozen=True)
class CreateBranchRequest:
"""Validated create-branch request payload."""
branch: str
base_oid: str
head_oid: str | None
authorization_digest: str
@dataclass(frozen=True)
class AppendCommitRequest:
"""Validated append-commit request payload."""
branch: str
expected_head_oid: str
changes: tuple[FileChange, ...]
authorization_digest: str
commit_message: str = "chore(governance): append governed commit"
_OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
_DENIED_PATH_SEGMENTS = (
"main",
"tag",
"baseline",
"credential",
"credentials",
"secret",
"registry",
)
_SECRET_PATTERNS = (
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"(?i)api[_-]?key\s*[:=]\s*\S+"),
re.compile(r"(?i)secret\s*[:=]\s*\S+"),
re.compile(r"(?i)password\s*[:=]\s*\S+"),
re.compile(r"(?i)token\s*[:=]\s*\S+"),
)
_SAFE_SECRET_MARKERS = re.compile(
r"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*(?:REDACTED|TEST_FIXTURE|NOT_A_SECRET)"
)
def canonicalize_authorization_payload(payload: Mapping[str, Any]) -> str:
"""Serialize authorization payload without the signature hash, in canonical form."""
canonical = dict(payload)
canonical.pop("authorization_sha256", None)
return json.dumps(
canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True
)
def compute_authorization_sha256(payload: Mapping[str, Any]) -> str:
"""Compute canonical sha256 over authorization payload."""
digest = hashlib.sha256()
digest.update(canonicalize_authorization_payload(payload).encode("utf-8"))
return digest.hexdigest()
def _assert_authorization_file_permissions(path: Path) -> None:
"""Fail-closed checks for root-only secret-like authorization files."""
if path.is_symlink():
raise AuthorizationError("authorization path must not be a symlink")
st = path.stat()
if not stat.S_ISREG(st.st_mode):
raise AuthorizationError("authorization path must be a regular file")
if (st.st_mode & 0o777) != 0o600:
raise AuthorizationError("authorization file mode must be exactly 0600")
if hasattr(st, "st_uid") and os.name != "nt" and st.st_uid != 0:
raise AuthorizationError("authorization file owner must be uid 0")
def _parse_iso8601(value: str) -> datetime:
"""Parse ISO 8601 timestamps used by authorization payloads."""
try:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError) as exc:
raise AuthorizationError("created_at must be an ISO 8601 timestamp") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _require_type(value: Any, expected: type, *, key: str) -> None:
if not isinstance(value, expected):
raise AuthorizationError(f"{key} must be {expected.__name__}")
def _parse_oid(value: Any, *, field: str) -> str:
if not isinstance(value, str):
raise AuthorizationError(f"{field} must be a string")
if not _OID_RE.fullmatch(value):
raise AuthorizationError(f"{field} must be a 40-char hex object id")
return value.lower()
def _parse_allowed_paths(paths: Any) -> tuple[str, ...]:
if not isinstance(paths, list) or not paths:
raise AuthorizationError("allowed_paths must be a non-empty list")
normalized: list[str] = []
for path in paths:
if not isinstance(path, str) or not path.strip():
raise AuthorizationError("allowed_paths entries must be non-empty strings")
normalized.append(path.strip())
return tuple(normalized)
def parse_authorization(
raw: Mapping[str, Any], *, now: datetime | None = None
) -> GovernedAuthorization:
"""Validate an authorization payload and return a typed object."""
_require_type(raw.get("task_id"), str, key="task_id")
if raw["task_id"] != TASK_ID:
raise AuthorizationError("unexpected task_id")
_require_type(raw.get("repo"), str, key="repo")
if raw["repo"] != REPO:
raise AuthorizationError("unexpected repo value")
created_at = _parse_iso8601(raw.get("created_at"))
check_time = now or datetime.now(tz=timezone.utc)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if created_at - check_time > timedelta(seconds=30):
raise AuthorizationError("authorization created_at is in the future")
if check_time - created_at > AUTHORIZATION_TTL:
raise AuthorizationError("authorization expired")
raw_expires_at = raw.get("expires_at")
expires_at = (
_parse_iso8601(raw_expires_at)
if raw_expires_at is not None
else created_at + AUTHORIZATION_TTL
)
if expires_at <= check_time:
raise AuthorizationError("authorization expiry is in the past")
if expires_at > created_at + AUTHORIZATION_TTL:
raise AuthorizationError("authorization expiry exceeds two-hour maximum")
_require_type(raw.get("one_time_use"), bool, key="one_time_use")
if raw["one_time_use"] is not True:
raise AuthorizationError("one_time_use must be true")
_require_type(raw.get("authorization_sha256"), str, key="authorization_sha256")
expected_hash = compute_authorization_sha256(raw)
provided_hash = str(raw["authorization_sha256"]).lower()
if provided_hash != expected_hash:
raise AuthorizationError("authorization_sha256 mismatch")
op_map = raw.get("allowed_branch_operations")
if not isinstance(op_map, dict) or not op_map:
raise AuthorizationError("allowed_branch_operations must be a non-empty object")
branch_operations: dict[str, BranchPolicy] = {}
for branch, operation in op_map.items():
if not isinstance(branch, str) or not branch.strip():
raise AuthorizationError("branch names must be non-empty strings")
if not isinstance(operation, dict):
raise AuthorizationError("operation policy must be an object")
create_block = operation.get("create_branch")
append_block = operation.get("append_commit")
create_policy: BranchCreatePolicy | None = None
append_policy: BranchAppendPolicy | None = None
if create_block is not None:
if not isinstance(create_block, dict):
raise AuthorizationError("create_branch block must be an object")
raw_head_oid = create_block.get("head_oid")
create_policy = BranchCreatePolicy(
branch=branch,
base_oid=_parse_oid(create_block.get("base_oid"), field="create_branch.base_oid"),
head_oid=(
_parse_oid(raw_head_oid, field="create_branch.head_oid")
if raw_head_oid is not None
else None
),
)
if append_block is not None:
if not isinstance(append_block, dict):
raise AuthorizationError("append_commit block must be an object")
raw_expected_head_oid = append_block.get("expected_head_oid")
append_policy = BranchAppendPolicy(
branch=branch,
expected_head_oid=(
_parse_oid(
raw_expected_head_oid,
field="append_commit.expected_head_oid",
)
if raw_expected_head_oid is not None
else None
),
allowed_paths=_parse_allowed_paths(append_block.get("allowed_paths")),
)
if create_policy is None and append_policy is None:
raise AuthorizationError("branch policy must define at least one operation")
branch_operations[branch] = BranchPolicy(
create_branch=create_policy, append_commit=append_policy
)
denied_raw = raw.get("denied_operations", ())
if not isinstance(denied_raw, list):
raise AuthorizationError("denied_operations must be a list")
denied_operations: list[str] = []
for value in denied_raw:
if not isinstance(value, str):
raise AuthorizationError("denied operations must be strings")
denied_operations.append(value.lower())
return GovernedAuthorization(
task_id=raw["task_id"],
repo=raw["repo"],
created_at=created_at,
expires_at=expires_at,
authorization_sha256=provided_hash,
one_time_use=True,
branch_operations=branch_operations,
denied_operations=tuple(denied_operations),
)
def load_authorization_file(
path: str | os.PathLike[str], *, now: datetime | None = None
) -> GovernedAuthorization:
"""Load and validate the root-only authorization file."""
auth_path = Path(path)
_assert_authorization_file_permissions(auth_path)
try:
raw_text = auth_path.read_text(encoding="utf-8")
except (OSError, ValueError) as exc:
raise AuthorizationError("unable to read authorization file") from exc
try:
raw_obj = json.loads(raw_text)
except json.JSONDecodeError as exc:
raise AuthorizationError("authorization must be JSON") from exc
if not isinstance(raw_obj, dict):
raise AuthorizationError("authorization top-level must be an object")
return parse_authorization(raw_obj, now=now)
def _resolve_peer_executable(pid: int) -> str | None:
"""Resolve peer executable from /proc/<pid>/exe when available."""
if pid <= 0:
return None
exe_path = f"/proc/{pid}/exe"
try:
return os.readlink(exe_path)
except OSError:
return None
def extract_peer_identity(
connection: socket.socket, *, require_root_uid: bool = True
) -> PeerIdentity:
"""Validate SO_PEERCRED and return peer identity."""
if not hasattr(socket, "SO_PEERCRED"):
raise AuthorizationError("SO_PEERCRED not available on this platform")
data = connection.getsockopt(
socket.SOL_SOCKET,
socket.SO_PEERCRED,
struct.calcsize("3i"),
)
if len(data) != struct.calcsize("3i"):
raise AuthorizationError("SO_PEERCRED payload size mismatch")
pid, uid, gid = struct.unpack("3i", data)
if require_root_uid and uid != 0:
raise PermissionError("peer uid must be 0")
return PeerIdentity(
pid=pid,
uid=uid,
gid=gid,
executable=_resolve_peer_executable(pid),
)
def _normalize_file_path(path: str) -> str:
normalized = posixpath.normpath(path.replace("\\", "/").strip())
return normalized.lstrip("/")
def _path_has_disallowed_segment(path: str) -> bool:
lowered = path.lower()
if ".." in path.split("/"):
return True
parts = [p for p in lowered.split("/") if p]
for part in parts:
for segment in _DENIED_PATH_SEGMENTS:
if part == segment or part.startswith(f"{segment}.") or segment in part:
return True
return False
def _is_allowed_path(path: str, allowed_paths: Sequence[str]) -> bool:
normalized = _normalize_file_path(path)
for prefix in allowed_paths:
normalized_prefix = _normalize_file_path(prefix)
if normalized == normalized_prefix:
return True
if normalized.startswith(normalized_prefix + "/"):
return True
return False
def contains_secret_like_content(content: str) -> bool:
"""Heuristic detector for obvious secret-like strings."""
content = _SAFE_SECRET_MARKERS.sub("", content or "")
for pattern in _SECRET_PATTERNS:
if pattern.search(content):
return True
return False
def validate_file_changes(
changes: Any, allowed_paths: Sequence[str]
) -> tuple[FileChange, ...]:
"""Validate append-commit file changes against path and content policy."""
if not isinstance(changes, list):
raise AuthorizationError("changes must be a list")
result: list[FileChange] = []
for change in changes:
if not isinstance(change, Mapping):
raise AuthorizationError("each file change must be an object")
raw_path = change.get("path")
raw_content = change.get("content")
if not isinstance(raw_path, str) or not raw_path.strip():
raise AuthorizationError("file change path must be a non-empty string")
if raw_path.startswith(("/", "\\")):
raise AuthorizationError("file change path must be relative")
if not isinstance(raw_content, str):
raise AuthorizationError("file change content must be a string")
normalized = _normalize_file_path(raw_path)
if not normalized or normalized.startswith("../") or normalized == "..":
raise AuthorizationError("file paths must be normalized relative paths")
if not _is_allowed_path(normalized, allowed_paths):
raise AuthorizationError("file path outside allowed_paths")
if _path_has_disallowed_segment(normalized):
raise AuthorizationError("file path uses denied segment")
if contains_secret_like_content(raw_content):
raise AuthorizationError("file content failed secret-like scan")
result.append(FileChange(path=normalized, content=raw_content))
return tuple(result)