memory.safety¶
memory.safety ¶
Shared safety primitives for the angelo backend.
This module is intentionally stdlib-only so that every subsystem (memory, zettelkasten, coordinator, angelo_cli, dashboards, artifacts) can import it without creating package coupling or pulling in heavy optional dependencies.
It provides the building blocks that the hardening work depends on:
- :func:
validate_id-- containment check for IDs/names used to build paths. - :func:
safe_join-- join + resolve + assert the result stays under a root. - :func:
is_sensitive_path-- denylist for files that must never be committed/pushed. - :func:
atomic_write-- crash-safe write (temp file + fsync +os.replace). - :func:
file_lock-- best-effort cross-process advisory lock.
Design notes¶
validate_idis a containment check, NOT a format validator. It rejects path-traversal characters only, so existing mixed-format IDs (anno-v2security,chec-memory-recovery-20260610, namespacedext:note-1) all remain valid.- The real containment guarantee comes from :func:
safe_join, which resolves the final path and asserts it is relative to the root (usingis_relative_torather than the bypassablestr().startswith()pattern).
Nothing in this module is wired into existing code paths yet; it is pure additive code intended to be adopted call site by call site.
validate_id ¶
Validate that value is safe to interpolate into a filesystem path.
By default this is a containment check, not a format check. It rejects
empty values, path separators, .. sequences, absolute paths, control
characters, and over-long strings -- but otherwise permits any characters
(letters, digits, -, _, ., : for namespaced IDs, etc.) so
existing IDs keep working.
When for_filename=True the check is stricter: it additionally rejects
Windows-illegal filename characters (including :, which would otherwise
create an NTFS alternate data stream), trailing dots/spaces (silently
stripped by Windows), and reserved device names. Use this mode at sites that
turn the value directly into a filename.
Returns the value unchanged when valid; raises :class:ValueError otherwise.
Source code in memory/safety.py
safe_join ¶
Join parts onto root and guarantee the result stays inside root.
Resolves both the root and the candidate path, then asserts the candidate is
relative to the resolved root. This catches .. traversal, absolute-path
escapes, and (because it resolves symlinks) symlink escapes -- unlike the
str(resolved).startswith(str(root)) pattern used elsewhere, which is
vulnerable to sibling-prefix and casing tricks.
Returns the resolved :class:~pathlib.Path; raises :class:ValueError if the
candidate would escape root.
Source code in memory/safety.py
is_sensitive_path ¶
Return True if path looks like a secret/credential file.
Used as a denylist (never an allowlist) so that ordinary code/data files remain eligible for git pinning and DVC tracking -- only credential-bearing files are excluded.
Source code in memory/safety.py
atomic_write ¶
atomic_write(path: Union[str, PathLike], data: Union[str, bytes], *, encoding: str = 'utf-8') -> Path
Write data to path atomically.
Writes to a temporary file in the same directory, flushes and fsyncs it, then
uses :func:os.replace for an atomic rename. A crash mid-write leaves either
the old file or the new file -- never a truncated one. Parent directories are
created as needed.
Returns the destination :class:~pathlib.Path.
Source code in memory/safety.py
atomic_graph_save ¶
Persist a KGLite graph to path atomically.
KGLite writes the cache file itself (it can't be handed pre-serialized
bytes, so :func:atomic_write doesn't apply), so the graph is saved to a
uniquely-named temp file in the same directory and then os.replace-d into
place. The unique name (pid + uuid) matters because more than one process can
share a single cache file -- e.g. the memory MCP server and the dashboard
backend both persist memory.kgl -- and a fixed temp path would let two
concurrent saves clobber each other into a torn file. os.replace is atomic
on the same filesystem, so a concurrent reader never sees a half-written cache
and the worst case is a harmless last-writer-wins (the cache is disposable and
rebuilt from the source-of-truth files).
Returns the destination :class:~pathlib.Path.
Source code in memory/safety.py
file_lock ¶
file_lock(path: Union[str, PathLike], *, timeout: float = 10.0, poll: float = 0.05, stale_after: float = 600.0) -> Iterator[None]
Best-effort cross-process advisory lock via an exclusive-create lockfile.
Uses os.open(..., O_CREAT | O_EXCL), which is atomic on local filesystems
on both Windows and POSIX. A lockfile older than stale_after seconds is
treated as abandoned (e.g. a crashed process) and reclaimed.
Lockfiles should live under a runtime directory (e.g. the gitignored
.angelo/), not inside a cloud-synced tree, to avoid sync interference.
Raises :class:TimeoutError if the lock cannot be acquired within timeout.