474 lines
16 KiB
Python
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)
|