| """Least-privilege policy, action approvals, and secret-safe tool output.""" |
|
|
| from __future__ import annotations |
|
|
| import fcntl |
| import hashlib |
| import hmac |
| import json |
| import os |
| import re |
| import secrets |
| import time |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Any, Literal, Mapping, cast |
|
|
| from .contracts import ToolCall, ToolRisk, ToolSpec |
| from .sandbox import control_root |
|
|
|
|
| PolicyDecision = Literal["allow", "approve", "deny"] |
| _SECRET_ENV_MARKERS = ( |
| "API_KEY", |
| "PASSWORD", |
| "PRIVATE_KEY", |
| "SECRET", |
| "TOKEN", |
| ) |
| _BEARER_RE = re.compile(r"(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+") |
| _URL_CREDENTIAL_RE = re.compile(r"(https?://)[^/@\s]+@", re.IGNORECASE) |
| _SENSITIVE_KEY_RE = re.compile( |
| r"(?:^|_)(?:access_token|api_key|authorization|bearer|client_secret|" |
| r"credential|password|passphrase|private_key|secret|token)(?:$|_)", |
| re.IGNORECASE, |
| ) |
|
|
|
|
| def _atomic_json(path: Path, payload: Mapping[str, Any]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) |
| os.chmod(path.parent, 0o700) |
| temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") |
| temporary.write_text( |
| json.dumps(payload, sort_keys=True, separators=(",", ":")), |
| encoding="utf-8", |
| ) |
| temporary.chmod(0o600) |
| with temporary.open("rb") as handle: |
| os.fsync(handle.fileno()) |
| os.replace(temporary, path) |
| directory_fd = os.open(path.parent, os.O_RDONLY) |
| try: |
| os.fsync(directory_fd) |
| finally: |
| os.close(directory_fd) |
|
|
|
|
| class SecretRedactor: |
| """Redact execution-only credentials before output reaches model or logs.""" |
|
|
| def __init__( |
| self, |
| environment: Mapping[str, str] | None = None, |
| extra_values: tuple[str, ...] = (), |
| ) -> None: |
| source = os.environ if environment is None else environment |
| values = { |
| str(value) |
| for name, value in source.items() |
| if value |
| and len(str(value)) >= 8 |
| and any(marker in name.upper() for marker in _SECRET_ENV_MARKERS) |
| } |
| values.update(value for value in extra_values if len(value) >= 8) |
| self._values = tuple(sorted(values, key=len, reverse=True)) |
|
|
| def redact(self, text: str) -> str: |
| redacted = text |
| for value in self._values: |
| redacted = redacted.replace(value, "[REDACTED]") |
| redacted = _BEARER_RE.sub(r"\1[REDACTED]", redacted) |
| return _URL_CREDENTIAL_RE.sub(r"\1[REDACTED]@", redacted) |
|
|
|
|
| def redact_sensitive_value( |
| value: Any, |
| *, |
| redactor: SecretRedactor | None = None, |
| sensitive: bool = False, |
| ) -> Any: |
| """Recursively redact structured execution data before durable reuse.""" |
|
|
| active = redactor if redactor is not None else SecretRedactor() |
| if sensitive: |
| return "[REDACTED]" |
| if isinstance(value, str): |
| return active.redact(value) |
| if isinstance(value, list): |
| return [ |
| redact_sensitive_value(item, redactor=active) |
| for item in value |
| ] |
| if isinstance(value, tuple): |
| return tuple( |
| redact_sensitive_value(item, redactor=active) |
| for item in value |
| ) |
| if isinstance(value, Mapping): |
| return { |
| str(key): redact_sensitive_value( |
| item, |
| redactor=active, |
| sensitive=bool(_SENSITIVE_KEY_RE.search(str(key))), |
| ) |
| for key, item in value.items() |
| } |
| return value |
|
|
|
|
| @dataclass(frozen=True) |
| class ToolPolicy: |
| read: PolicyDecision = "allow" |
| workspace_write: PolicyDecision = "allow" |
| external_effect: PolicyDecision = "approve" |
| destructive: PolicyDecision = "approve" |
| tool_overrides: tuple[tuple[str, PolicyDecision], ...] = () |
|
|
| @classmethod |
| def load(cls, workspace: str | Path) -> "ToolPolicy": |
| path = control_root(workspace) / "policy.json" |
| if not path.is_file(): |
| return cls() |
| payload = json.loads(path.read_text(encoding="utf-8")) |
| if not isinstance(payload, dict): |
| raise ValueError("tool policy must contain an object") |
| allowed = {"allow", "approve", "deny"} |
|
|
| def decision(name: str, default: PolicyDecision) -> PolicyDecision: |
| value = str(payload.get(name, default)) |
| if value not in allowed: |
| raise ValueError(f"invalid policy decision for {name}") |
| return cast(PolicyDecision, value) |
|
|
| raw_overrides = payload.get("tools") |
| overrides: list[tuple[str, PolicyDecision]] = [] |
| if isinstance(raw_overrides, dict): |
| for name, value in sorted(raw_overrides.items()): |
| rendered = str(value) |
| if rendered not in allowed: |
| raise ValueError(f"invalid tool policy decision for {name}") |
| overrides.append((str(name), cast(PolicyDecision, rendered))) |
| return cls( |
| read=decision("read", "allow"), |
| workspace_write=decision("workspace_write", "allow"), |
| external_effect=decision("external_effect", "approve"), |
| destructive=decision("destructive", "approve"), |
| tool_overrides=tuple(overrides), |
| ) |
|
|
| def decision(self, spec: ToolSpec) -> PolicyDecision: |
| overrides = dict(self.tool_overrides) |
| if spec.name in overrides: |
| return cast(PolicyDecision, overrides[spec.name]) |
| return cast(PolicyDecision, getattr(self, spec.risk)) |
|
|
|
|
| @dataclass(frozen=True) |
| class ApprovalRecord: |
| approval_id: str |
| session_sha256: str |
| action_sha256: str |
| tool: str |
| risk: ToolRisk |
| status: str |
| created_unix: int |
| decided_unix: int = 0 |
| consumed_unix: int = 0 |
|
|
|
|
| class ApprovalStore: |
| """One-shot approval records bound to one exact model-selected action.""" |
|
|
| def __init__(self, workspace: str | Path) -> None: |
| self.root = control_root(workspace) / "approvals" |
| self.root.mkdir(parents=True, exist_ok=True, mode=0o700) |
| os.chmod(self.root, 0o700) |
|
|
| @staticmethod |
| def action_sha256(session_id: str, call: ToolCall) -> str: |
| canonical = json.dumps( |
| { |
| "session_id": session_id, |
| "call_id": call.call_id, |
| "name": call.name, |
| "args": call.args, |
| }, |
| sort_keys=True, |
| separators=(",", ":"), |
| ).encode("utf-8") |
| return hashlib.sha256(canonical).hexdigest() |
|
|
| def _path(self, approval_id: str) -> Path: |
| if not re.fullmatch(r"[a-f0-9]{32}", approval_id): |
| raise ValueError("approval id is invalid") |
| return self.root / f"{approval_id}.json" |
|
|
| def _load_locked(self, path: Path) -> ApprovalRecord: |
| payload = json.loads(path.read_text(encoding="utf-8")) |
| if not isinstance(payload, dict): |
| raise RuntimeError("approval record is invalid") |
| return ApprovalRecord(**payload) |
|
|
| def request( |
| self, |
| session_id: str, |
| call: ToolCall, |
| spec: ToolSpec, |
| ) -> ApprovalRecord: |
| action_sha256 = self.action_sha256(session_id, call) |
| for path in self.root.glob("*.json"): |
| try: |
| existing = self._load_locked(path) |
| except (OSError, TypeError, ValueError, RuntimeError): |
| continue |
| if ( |
| hmac.compare_digest(existing.action_sha256, action_sha256) |
| and existing.status in {"pending", "approved"} |
| ): |
| return existing |
| record = ApprovalRecord( |
| approval_id=secrets.token_hex(16), |
| session_sha256=hashlib.sha256(session_id.encode("utf-8")).hexdigest(), |
| action_sha256=action_sha256, |
| tool=call.name, |
| risk=spec.risk, |
| status="pending", |
| created_unix=int(time.time()), |
| ) |
| _atomic_json(self._path(record.approval_id), asdict(record)) |
| return record |
|
|
| def decide( |
| self, |
| approval_id: str, |
| *, |
| approved: bool, |
| session_id: str = "", |
| ) -> ApprovalRecord: |
| path = self._path(approval_id) |
| lock_path = path.with_suffix(".lock") |
| with lock_path.open("a+b") as lock_handle: |
| lock_path.chmod(0o600) |
| fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) |
| try: |
| record = self._load_locked(path) |
| if session_id: |
| expected_session = hashlib.sha256( |
| session_id.encode("utf-8") |
| ).hexdigest() |
| if not hmac.compare_digest( |
| record.session_sha256, expected_session |
| ): |
| raise PermissionError( |
| "approval does not belong to this session" |
| ) |
| if record.status not in {"pending", "approved"}: |
| raise RuntimeError("approval is no longer pending") |
| updated = ApprovalRecord( |
| **{ |
| **asdict(record), |
| "status": "approved" if approved else "denied", |
| "decided_unix": int(time.time()), |
| } |
| ) |
| _atomic_json(path, asdict(updated)) |
| return updated |
| finally: |
| fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) |
|
|
| def consume( |
| self, |
| approval_id: str, |
| *, |
| session_id: str, |
| call: ToolCall, |
| ) -> ApprovalRecord: |
| path = self._path(approval_id) |
| lock_path = path.with_suffix(".lock") |
| with lock_path.open("a+b") as lock_handle: |
| lock_path.chmod(0o600) |
| fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) |
| try: |
| record = self._load_locked(path) |
| expected = self.action_sha256(session_id, call) |
| if not hmac.compare_digest(record.action_sha256, expected): |
| raise PermissionError("approval does not match the selected action") |
| if record.status != "approved": |
| raise PermissionError(f"approval status is {record.status}") |
| updated = ApprovalRecord( |
| **{ |
| **asdict(record), |
| "status": "consumed", |
| "consumed_unix": int(time.time()), |
| } |
| ) |
| _atomic_json(path, asdict(updated)) |
| return updated |
| finally: |
| fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) |
|
|
| @staticmethod |
| def _session_sha256(session_id: str) -> str: |
| return hashlib.sha256(session_id.encode("utf-8")).hexdigest() |
|
|
| @classmethod |
| def _require_session( |
| cls, record: ApprovalRecord, session_id: str |
| ) -> ApprovalRecord: |
| if session_id and not hmac.compare_digest( |
| record.session_sha256, cls._session_sha256(session_id) |
| ): |
| raise PermissionError("approval does not belong to this session") |
| return record |
|
|
| def get(self, approval_id: str, *, session_id: str = "") -> ApprovalRecord: |
| return self._require_session( |
| self._load_locked(self._path(approval_id)), session_id |
| ) |
|
|
| def list(self, *, session_id: str = "") -> tuple[ApprovalRecord, ...]: |
| records: list[ApprovalRecord] = [] |
| for path in sorted(self.root.glob("*.json")): |
| try: |
| record = self._load_locked(path) |
| except (OSError, TypeError, ValueError, RuntimeError): |
| continue |
| if session_id and not hmac.compare_digest( |
| record.session_sha256, self._session_sha256(session_id) |
| ): |
| continue |
| records.append(record) |
| return tuple(records) |
|
|
|
|
| __all__ = [ |
| "ApprovalRecord", |
| "ApprovalStore", |
| "PolicyDecision", |
| "SecretRedactor", |
| "ToolPolicy", |
| "redact_sensitive_value", |
| ] |
|
|