"""One place the agent writes state that must survive a restart. Three things now need agent-owned persistence and they were about to grow three different implementations: the per-server credentials and grants (:mod:`distinct_agent.servers`), the deny list (:mod:`distinct_agent.guard`), and the lifetime energy figure. They share a single mechanism here, because three atomic-write routines is three chances to get the permissions wrong. The write is atomic and private: a sibling temporary file is created in the destination directory with its final permissions **already set**, written, flushed, fsynced, and then renamed over the target. A reader sees the old complete file or the new complete file, never a partial one and never a world-readable one. Platform honesty, because the guarantee is not the same on both: * **POSIX.** Directory ``0o700``, file ``0o600``, set at creation rather than afterwards, so there is no window during which the contents are readable by anyone else. * **Windows.** The file inherits the user profile ACL. That keeps it from other user accounts on the machine and does **nothing** against another process running as the same user. :func:`describe_storage` returns the correct sentence per platform rather than one that is true on only one. Persistence is always opt-in. With no path, everything stays in memory and a restart is a blunt but complete reset, which is what ``README.md`` already promises for credentials. """ from __future__ import annotations import json import os import stat import tempfile from collections.abc import Mapping from pathlib import Path from typing import Any class StateError(RuntimeError): """A state file could not be read, written, or understood.""" def default_state_dir() -> Path: """Where agent state lives when the operator does not say otherwise.""" if os.name == "nt": base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") return Path(base) / "distinct" / "agent" base = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") return Path(base) / "distinct" / "agent" def describe_storage(path: Path | None) -> str: """The line an operator must see before anything sensitive touches a disk.""" if path is None: return ( "Agent state is held in memory only. A restart clears every pairing, " "grant and denial. Pass a state directory to keep them." ) if os.name == "nt": return ( f"Agent state is written to {path}. It inherits this user profile's " "file permissions, which keeps it from other user accounts on this " "machine and does nothing against another process running as you." ) return ( f"Agent state is written to {path}, mode 0600 inside a 0700 directory, " "readable only by this user account." ) def write_private(path: Path, payload: Mapping[str, Any]) -> None: """Write JSON state atomically and without a readable window.""" if not isinstance(payload, Mapping): raise StateError("state payload must be a mapping") text = json.dumps(dict(payload), sort_keys=True, indent=2) directory = path.parent directory.mkdir(parents=True, exist_ok=True) if os.name != "nt": os.chmod(directory, stat.S_IRWXU) handle, temporary = tempfile.mkstemp(dir=str(directory), prefix=f".{path.name}-", suffix=".tmp") try: if os.name != "nt": os.fchmod(handle, stat.S_IRUSR | stat.S_IWUSR) with os.fdopen(handle, "w", encoding="utf-8") as stream: stream.write(text) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) except BaseException: # Leaving a half-written temporary file behind would be mistaken for # state on the next read, so clean up on every failure path. try: os.unlink(temporary) except OSError: pass raise def read_private(path: Path) -> dict[str, Any]: """Read JSON state, or return an empty mapping when there is none.""" if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise StateError(f"agent state at {path} could not be read: {type(exc).__name__}") from exc if not isinstance(value, dict): raise StateError(f"agent state at {path} is not a JSON object") return value def zeroise(buffer: bytearray) -> None: """Overwrite a secret in place. This clears the value from this object. It does **not** promise the bytes are gone from the process: Python may have copied them, and there is no portable way to find or clear those copies. """ for index in range(len(buffer)): buffer[index] = 0 del buffer[:] __all__ = [ "StateError", "default_state_dir", "describe_storage", "read_private", "write_private", "zeroise", ]