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