chore(python): audit plugins shared modules

_managed_files owns the file snapshot reader and public metadata helpers used by pinned directories, durable copies, and opaque snapshots; transaction staging owns read_target and same_identity_and_contents; dead helpers removed; hook_template no longer re-exports the guard generator; mypy targets win32 so _windows_security type-checks.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
雷电芽衣 committed 2026-09-02 23:16:15 -04:00
1 parent 0f72a5e2f1
commit ed34831c20
21 files changed
+104 -360

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ from ._runtime_paths import (
from .crypto import get_public_key_base64url from .crypto import get_public_key_base64url
from .plugins.base import AgentPlugin, InstallConfig from .plugins.base import AgentPlugin, InstallConfig
from .plugins.registry import SUPPORTED_AGENTS, get_agent_names from .plugins.registry import SUPPORTED_AGENTS, get_agent_names
from .plugins.hook_template import generate_guard_script from .plugins.guard_template import generate_guard_script
from .plugins.augment import AugmentPlugin from .plugins.augment import AugmentPlugin
from .plugins.claudecode import ClaudeCodePlugin from .plugins.claudecode import ClaudeCodePlugin
from .plugins.codex import CodexPlugin from .plugins.codex import CodexPlugin
+2 -11
View File
@@ -6,6 +6,7 @@ import os
import stat import stat
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
from ._managed_files import same_file_metadata
from ._pinned_directory import PinnedDirectory from ._pinned_directory import PinnedDirectory
@@ -27,16 +28,6 @@ def _regular_identity(
raise OSError(f"{label} changed while copying: {path}") raise OSError(f"{label} changed while copying: {path}")
def _stable_metadata(metadata: os.stat_result) -> tuple[int, int, int]:
# Timestamps are excluded: Windows exposes file times with delayed
# cross-handle visibility, so they are unsound identity evidence.
return (
metadata.st_dev,
metadata.st_ino,
metadata.st_size,
)
def copy_into_reserved( def copy_into_reserved(
directory: PinnedDirectory, directory: PinnedDirectory,
source_path: str, source_path: str,
@@ -88,7 +79,7 @@ def copy_into_reserved(
directory.chmod_descriptor(destination_descriptor, mode) directory.chmod_descriptor(destination_descriptor, mode)
os.fsync(destination_descriptor) os.fsync(destination_descriptor)
after = os.fstat(source_descriptor) after = os.fstat(source_descriptor)
if _stable_metadata(after) != _stable_metadata(before): if not same_file_metadata(after, before):
raise OSError(f"{label} changed while copying: {source_path}") raise OSError(f"{label} changed while copying: {source_path}")
finally: finally:
for descriptor in (destination_descriptor, source_descriptor): for descriptor in (destination_descriptor, source_descriptor):
+1 -45
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
import json import json
import os import os
import stat
import tempfile import tempfile
from typing import Any, Dict, Optional from typing import Any, Dict
JsonObject = Dict[str, Any] JsonObject = Dict[str, Any]
@@ -64,46 +63,3 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
def write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None: def write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None:
write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode, label) write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode, label)
def remove_file(path: str, label: str) -> None:
try:
os.remove(path)
except FileNotFoundError:
return
except OSError as error:
raise OSError(f"Remove {label} at {path}: {error}") from error
def regular_file_exists(path: str, label: str) -> bool:
try:
metadata = os.stat(path)
except FileNotFoundError:
return False
except OSError as error:
raise OSError(f"Read {label} at {path}: {error}") from error
return stat.S_ISREG(metadata.st_mode)
def require_runtime(path: str, label: str) -> None:
if not path:
raise ValueError(f"{label} path is required")
if not regular_file_exists(path, label):
raise FileNotFoundError(f"{label} is missing: {path}")
def read_json(path: str, label: str) -> Optional[JsonObject]:
try:
with open(path, "r", encoding="utf-8") as file:
raw = file.read()
except FileNotFoundError:
return None
except OSError as error:
raise OSError(f"Read {label} at {path}: {error}") from error
try:
value = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
if not isinstance(value, dict):
raise ValueError(f"{label} at {path} must contain a JSON object")
return value
+29 -46
View File
@@ -28,14 +28,7 @@ class DirectorySnapshot:
def identity_mode_bits(mode: int) -> int: def identity_mode_bits(mode: int) -> int:
"""Collapse permission bits that are not identity evidence on Windows. """Windows synthesizes mode bits, so they are not identity there."""
Windows synthesizes POSIX modes and derives execute bits from the file
extension for path stats but not for handle stats, so the synthetic value
cannot serve as identity there. Windows permissions are enforced through
DACLs in _windows_security instead. Captured snapshots still store the
raw bits because restoration re-applies them.
"""
return mode if os.name != "nt" else 0 return mode if os.name != "nt" else 0
@@ -44,12 +37,8 @@ def portable_file_mode(metadata: os.stat_result) -> int:
return identity_mode_bits(stat.S_IMODE(metadata.st_mode)) return identity_mode_bits(stat.S_IMODE(metadata.st_mode))
def _stable_file_metadata( def stable_file_metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
metadata: os.stat_result, """Identity without timestamps; Windows file times lag across handles."""
) -> tuple[int, int, int, int]:
# Timestamps are excluded deliberately: Windows exposes file times with
# delayed cross-handle visibility, so they are unsound identity evidence.
# Content changes are caught by the verification re-read instead.
return ( return (
metadata.st_dev, metadata.st_dev,
metadata.st_ino, metadata.st_ino,
@@ -58,11 +47,8 @@ def _stable_file_metadata(
) )
def _same_file_metadata( def same_file_metadata(first: os.stat_result, second: os.stat_result) -> bool:
first: os.stat_result, return stable_file_metadata(first) == stable_file_metadata(second)
second: os.stat_result,
) -> bool:
return _stable_file_metadata(first) == _stable_file_metadata(second)
def assert_content_stable( def assert_content_stable(
@@ -73,12 +59,7 @@ def assert_content_stable(
raw: bytes, raw: bytes,
maximum_bytes: int, maximum_bytes: int,
) -> None: ) -> None:
"""Re-read a just-read file and require identical identity and bytes. """Re-read the file and require identical identity and bytes."""
This is the deterministic replacement for timestamp comparisons: a
concurrent in-place rewrite is proven by content divergence between the
two reads, independent of filesystem timer visibility.
"""
descriptor = opener() descriptor = opener()
try: try:
current = os.fstat(descriptor) current = os.fstat(descriptor)
@@ -110,26 +91,25 @@ def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result
return metadata return metadata
def read_physical_file( def read_file_snapshot(
before: os.stat_result,
opener: Callable[[int], int],
file_path: str, file_path: str,
label: str, label: str,
maximum_bytes: int = MAX_SOURCE_BYTES, maximum_bytes: int,
) -> Optional[FileSnapshot]: ) -> FileSnapshot:
before = _inspect_regular_file(file_path, label) """Read one regular file through opener and prove it stayed stable."""
if before is None:
return None
if before.st_size > maximum_bytes: if before.st_size > maximum_bytes:
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}") raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0) flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0) flags |= getattr(os, "O_NOFOLLOW", 0)
descriptor = -1 descriptor = -1
try: try:
descriptor = os.open(file_path, flags) descriptor = opener(flags)
after = os.fstat(descriptor) after = os.fstat(descriptor)
if not stat.S_ISREG(after.st_mode): if not stat.S_ISREG(after.st_mode):
raise OSError(f"{label} path is not a physical file: {file_path}") raise OSError(f"{label} path is not a physical file: {file_path}")
if not _same_file_metadata(before, after): if not same_file_metadata(before, after):
raise OSError(f"{label} changed while opening: {file_path}") raise OSError(f"{label} changed while opening: {file_path}")
if after.st_size > maximum_bytes: if after.st_size > maximum_bytes:
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}") raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
@@ -137,15 +117,10 @@ def read_physical_file(
descriptor = -1 descriptor = -1
raw = file.read(maximum_bytes + 1) raw = file.read(maximum_bytes + 1)
finished = os.fstat(file.fileno()) finished = os.fstat(file.fileno())
if not _same_file_metadata(finished, after): if not same_file_metadata(finished, after):
raise OSError(f"{label} changed while reading: {file_path}") raise OSError(f"{label} changed while reading: {file_path}")
assert_content_stable( assert_content_stable(
lambda: os.open(file_path, flags), lambda: opener(flags), file_path, label, after, raw, maximum_bytes
file_path,
label,
after,
raw,
maximum_bytes,
) )
except OSError as error: except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error raise OSError(f"Read {label} at {file_path}: {error}") from error
@@ -159,11 +134,19 @@ def read_physical_file(
contents = raw.decode("utf-8") contents = raw.decode("utf-8")
except UnicodeDecodeError as error: except UnicodeDecodeError as error:
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
return FileSnapshot( return FileSnapshot(contents, after.st_dev, after.st_ino, stat.S_IMODE(after.st_mode))
contents=contents,
device=after.st_dev,
inode=after.st_ino, def read_physical_file(
mode=stat.S_IMODE(after.st_mode), file_path: str,
label: str,
maximum_bytes: int = MAX_SOURCE_BYTES,
) -> Optional[FileSnapshot]:
before = _inspect_regular_file(file_path, label)
if before is None:
return None
return read_file_snapshot(
before, lambda flags: os.open(file_path, flags), file_path, label, maximum_bytes
) )
+5 -23
View File
@@ -8,7 +8,7 @@ import os
import stat import stat
from typing import Optional from typing import Optional
from ._managed_files import portable_file_mode from ._managed_files import same_file_metadata
from ._pinned_directory import PinnedDirectory from ._pinned_directory import PinnedDirectory
@@ -17,12 +17,7 @@ _HASH_CHUNK_BYTES = 1024 * 1024
@dataclass(frozen=True) @dataclass(frozen=True)
class OpaqueFileSnapshot: class OpaqueFileSnapshot:
"""Content and physical identity without retaining file bytes in memory. """Device, inode, mode, size, and SHA-256 without retaining the bytes."""
Identity is content-based (device, inode, mode, size, SHA-256). File
timestamps are excluded because Windows exposes them with delayed
visibility across handles, which makes them unsound identity evidence.
"""
device: int device: int
inode: int inode: int
@@ -31,19 +26,6 @@ class OpaqueFileSnapshot:
sha256: str sha256: str
def _metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
return (
metadata.st_dev,
metadata.st_ino,
portable_file_mode(metadata),
metadata.st_size,
)
def _same_metadata(first: os.stat_result, second: os.stat_result) -> bool:
return _metadata(first) == _metadata(second)
def _require_regular( def _require_regular(
metadata: os.stat_result, metadata: os.stat_result,
file_path: str, file_path: str,
@@ -82,7 +64,7 @@ def stream_opaque_snapshot(
descriptor = directory.open_file(name, flags) descriptor = directory.open_file(name, flags)
opened = os.fstat(descriptor) opened = os.fstat(descriptor)
_require_regular(opened, file_path, label, require_owner_only) _require_regular(opened, file_path, label, require_owner_only)
if not _same_metadata(opened, before): if not same_file_metadata(opened, before):
raise OSError(f"{label} changed while opening: {file_path}") raise OSError(f"{label} changed while opening: {file_path}")
digest = hashlib.sha256() digest = hashlib.sha256()
while True: while True:
@@ -92,7 +74,7 @@ def stream_opaque_snapshot(
digest.update(chunk) digest.update(chunk)
finished = os.fstat(descriptor) finished = os.fstat(descriptor)
_require_regular(finished, file_path, label, require_owner_only) _require_regular(finished, file_path, label, require_owner_only)
if not _same_metadata(finished, opened): if not same_file_metadata(finished, opened):
raise OSError(f"{label} changed while hashing: {file_path}") raise OSError(f"{label} changed while hashing: {file_path}")
except OSError as error: except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error raise OSError(f"Read {label} at {file_path}: {error}") from error
@@ -105,7 +87,7 @@ def stream_opaque_snapshot(
except FileNotFoundError as error: except FileNotFoundError as error:
raise OSError(f"{label} changed while hashing: {file_path}") from error raise OSError(f"{label} changed while hashing: {file_path}") from error
_require_regular(visible, file_path, label, require_owner_only) _require_regular(visible, file_path, label, require_owner_only)
if not _same_metadata(visible, finished): if not same_file_metadata(visible, finished):
raise OSError(f"{label} changed while hashing: {file_path}") raise OSError(f"{label} changed while hashing: {file_path}")
return OpaqueFileSnapshot( return OpaqueFileSnapshot(
finished.st_dev, finished.st_dev,
+8 -60
View File
@@ -10,8 +10,7 @@ from typing import Any, Optional, Tuple
from ._managed_files import ( from ._managed_files import (
DirectorySnapshot, DirectorySnapshot,
FileSnapshot, FileSnapshot,
_same_file_metadata, read_file_snapshot,
assert_content_stable,
read_physical_directory, read_physical_directory,
) )
@@ -381,8 +380,7 @@ class PinnedDirectory:
"""Persist directory entries where the host exposes directory fsync.""" """Persist directory entries where the host exposes directory fsync."""
self._require_open() self._require_open()
if os.name == "nt": if os.name == "nt":
# NTFS journals atomic rename metadata. Windows directory handles do # Windows directory handles do not support FlushFileBuffers.
# not support FlushFileBuffers, while every staged file is flushed.
return return
if os.name != "posix": if os.name != "posix":
raise OSError(f"Unsupported transaction platform: {os.name}") raise OSError(f"Unsupported transaction platform: {os.name}")
@@ -410,72 +408,22 @@ class PinnedDirectory:
label: str, label: str,
maximum_bytes: int, maximum_bytes: int,
) -> Optional[FileSnapshot]: ) -> Optional[FileSnapshot]:
file_path = self.path_for(name)
try: try:
before = self.stat_file(name) before = self.stat_file(name)
except FileNotFoundError: except FileNotFoundError:
return None return None
except OSError as error: except OSError as error:
raise OSError(f"Inspect {label} at {self.path_for(name)}: {error}") from error raise OSError(f"Inspect {label} at {file_path}: {error}") from error
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode): if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
raise OSError(f"{label} path is not a physical file: {self.path_for(name)}") raise OSError(f"{label} path is not a physical file: {file_path}")
if before.st_size > maximum_bytes: return read_file_snapshot(
raise ValueError(
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
)
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
descriptor = -1
try:
descriptor = self.open_file(name, flags)
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(
before, before,
opened, lambda flags: self.open_file(name, flags),
): file_path,
raise OSError(f"{label} changed while opening: {self.path_for(name)}")
if opened.st_size > maximum_bytes:
raise ValueError(
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
)
with os.fdopen(descriptor, "rb") as file:
descriptor = -1
raw = file.read(maximum_bytes + 1)
finished = os.fstat(file.fileno())
if not _same_file_metadata(finished, opened):
raise OSError(
f"{label} changed while reading: {self.path_for(name)}"
)
assert_content_stable(
lambda: self.open_file(name, flags),
self.path_for(name),
label, label,
opened,
raw,
maximum_bytes, maximum_bytes,
) )
except OSError as error:
raise OSError(f"Read {label} at {self.path_for(name)}: {error}") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if len(raw) > maximum_bytes:
raise ValueError(
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
)
try:
contents = raw.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError(
f"{label} at {self.path_for(name)} must contain UTF-8 text"
) from error
return FileSnapshot(
contents,
opened.st_dev,
opened.st_ino,
stat.S_IMODE(opened.st_mode),
)
def chmod_file( def chmod_file(
self, self,
+1 -26
View File
@@ -6,12 +6,7 @@ import os
from typing import List, Optional, Sequence, cast from typing import List, Optional, Sequence, cast
from uuid import uuid4 from uuid import uuid4
from ._managed_files import ( from ._managed_files import FileSnapshot, MAX_SOURCE_BYTES, read_physical_file
FileSnapshot,
MAX_SOURCE_BYTES,
physical_file_exists,
read_physical_file,
)
from ._opaque_cleanup import cleanup_opaque_removal from ._opaque_cleanup import cleanup_opaque_removal
from ._opaque_removal import ( from ._opaque_removal import (
OpaqueRemovalChange, OpaqueRemovalChange,
@@ -65,15 +60,6 @@ def recover_pending_transactions() -> None:
_recover_pending_transactions() _recover_pending_transactions()
def read_optional(
file_path: str,
label: str,
maximum_bytes: int = MAX_SOURCE_BYTES,
) -> Optional[str]:
snapshot = read_physical_file(file_path, label, maximum_bytes)
return None if snapshot is None else snapshot.contents
def source_change( def source_change(
file_path: str, file_path: str,
label: str, label: str,
@@ -479,14 +465,3 @@ def write_changes(
directory_preconditions, directory_preconditions,
opaque_removals, opaque_removals,
) )
def regular_file_exists(file_path: str, label: str) -> bool:
return physical_file_exists(file_path, label)
def require_runtime(file_path: str, label: str) -> None:
if not file_path:
raise ValueError(f"{label} path is required")
if not regular_file_exists(file_path, label):
raise FileNotFoundError(f"{label} is missing: {file_path}")
+11 -29
View File
@@ -13,6 +13,8 @@ from ._transaction_recovery import remove_committed_creation
from ._transaction_staging import ( from ._transaction_staging import (
assert_transaction_preconditions, assert_transaction_preconditions,
assert_unchanged, assert_unchanged,
read_target,
same_identity_and_contents,
same_snapshot, same_snapshot,
) )
from ._transaction_types import FileChange, StagedChange from ._transaction_types import FileChange, StagedChange
@@ -38,14 +40,6 @@ def _original_snapshot(change: FileChange) -> Optional[FileSnapshot]:
) )
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
return staged.directory.read_file(
staged.directory.name_for(staged.change.file_path),
staged.change.label,
staged.change.maximum_bytes,
)
def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]: def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]:
if staged.temporary_path is None: if staged.temporary_path is None:
return None return None
@@ -66,18 +60,6 @@ def _read_rollback(staged: StagedChange) -> Optional[FileSnapshot]:
) )
def _same_identity_and_contents(
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
) -> bool:
if current is None or expected is None:
return current is expected
return (
current.contents == expected.contents
and current.device == expected.device
and current.inode == expected.inode
)
def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError: def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError:
if staged.rollback_path is None: if staged.rollback_path is None:
return error if isinstance(error, OSError) else OSError(str(error)) return error if isinstance(error, OSError) else OSError(str(error))
@@ -97,7 +79,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
raise OSError(f"Missing rollback reservation for {staged.change.label}") raise OSError(f"Missing rollback reservation for {staged.change.label}")
capture_file(staged.change.file_path, rollback_path, staged.directory) capture_file(staged.change.file_path, rollback_path, staged.directory)
assert_transaction_preconditions(*staged.preconditions, staged.directories) assert_transaction_preconditions(*staged.preconditions, staged.directories)
current = _read_target(staged) current = read_target(staged)
if not same_snapshot(current, original): if not same_snapshot(current, original):
raise OSError( raise OSError(
f"{staged.change.label} changed at commit boundary: " f"{staged.change.label} changed at commit boundary: "
@@ -119,7 +101,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
staged.change.label, staged.change.label,
staged.change.maximum_bytes, staged.change.maximum_bytes,
) )
current = _read_target(staged) current = read_target(staged)
if not same_snapshot(current, original): if not same_snapshot(current, original):
raise OSError( raise OSError(
f"{staged.change.label} changed while preparing rollback: " f"{staged.change.label} changed while preparing rollback: "
@@ -151,7 +133,7 @@ def _replace_next(
raise OSError(f"Missing staged file for {staged.change.label}") raise OSError(f"Missing staged file for {staged.change.label}")
replace_file(temporary_path, staged.change.file_path, staged.directory) replace_file(temporary_path, staged.change.file_path, staged.directory)
assert_transaction_preconditions(*staged.preconditions, staged.directories) assert_transaction_preconditions(*staged.preconditions, staged.directories)
current = _read_target(staged) current = read_target(staged)
expected = _original_snapshot(staged.change) expected = _original_snapshot(staged.change)
if not same_snapshot(current, expected): if not same_snapshot(current, expected):
error = OSError( error = OSError(
@@ -227,7 +209,7 @@ def commit_change(
else: else:
_replace_next(staged, replace_file, after_replace) _replace_next(staged, replace_file, after_replace)
current = _read_target(staged) current = read_target(staged)
if not same_snapshot(current, staged.committed_snapshot): if not same_snapshot(current, staged.committed_snapshot):
raise OSError( raise OSError(
f"{staged.change.label} changed immediately after commit: " f"{staged.change.label} changed immediately after commit: "
@@ -251,7 +233,7 @@ def _restore_rollback_file(
rollback = staged.rollback_snapshot rollback = staged.rollback_snapshot
if rollback_path is None or rollback is None: if rollback_path is None or rollback is None:
raise OSError(f"Missing rollback path for {staged.change.label}") raise OSError(f"Missing rollback path for {staged.change.label}")
if not same_snapshot(_read_target(staged), expected_current): if not same_snapshot(read_target(staged), expected_current):
raise _preserve_rollback( raise _preserve_rollback(
staged, staged,
OSError( OSError(
@@ -263,7 +245,7 @@ def _restore_rollback_file(
restore_file(rollback_path, staged.change.file_path, staged.directory) restore_file(rollback_path, staged.change.file_path, staged.directory)
except Exception as error: except Exception as error:
raise _preserve_rollback(staged, error) from error raise _preserve_rollback(staged, error) from error
if not same_snapshot(_read_target(staged), expected_current): if not same_snapshot(read_target(staged), expected_current):
raise _preserve_rollback( raise _preserve_rollback(
staged, staged,
OSError( OSError(
@@ -272,7 +254,7 @@ def _restore_rollback_file(
), ),
) )
current_rollback = _read_rollback(staged) current_rollback = _read_rollback(staged)
if not _same_identity_and_contents(current_rollback, rollback): if not same_identity_and_contents(current_rollback, rollback):
raise _preserve_rollback( raise _preserve_rollback(
staged, staged,
OSError( OSError(
@@ -288,7 +270,7 @@ def _restore_rollback_file(
) )
atomic_replace(staged.directory, rollback_path, staged.change.file_path) atomic_replace(staged.directory, rollback_path, staged.change.file_path)
staged.rollback_path = None staged.rollback_path = None
restored = _read_target(staged) restored = read_target(staged)
if ( if (
restored is None restored is None
or restored.contents != expected.contents or restored.contents != expected.contents
@@ -307,7 +289,7 @@ def rollback_change(
) -> None: ) -> None:
if not staged.committed: if not staged.committed:
return return
current = _read_target(staged) current = read_target(staged)
if not same_snapshot(current, staged.committed_snapshot): if not same_snapshot(current, staged.committed_snapshot):
raise _preserve_rollback( raise _preserve_rollback(
staged, staged,
+7 -7
View File
@@ -13,7 +13,7 @@ from uuid import uuid4
from ._managed_files import ( from ._managed_files import (
DirectorySnapshot, DirectorySnapshot,
FileSnapshot, FileSnapshot,
_same_file_metadata, same_file_metadata,
identity_mode_bits, identity_mode_bits,
) )
from ._opaque_snapshot import OpaqueFileSnapshot from ._opaque_snapshot import OpaqueFileSnapshot
@@ -300,7 +300,7 @@ def inspect_fingerprint(
descriptor = directory.open_file(name, flags) descriptor = directory.open_file(name, flags)
try: try:
opened = os.fstat(descriptor) opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(before, opened): if not stat.S_ISREG(opened.st_mode) or not same_file_metadata(before, opened):
raise OSError(f"{label} changed while opening: {file_path}") raise OSError(f"{label} changed while opening: {file_path}")
digest = hashlib.sha256() digest = hashlib.sha256()
size = 0 size = 0
@@ -311,12 +311,12 @@ def inspect_fingerprint(
digest.update(chunk) digest.update(chunk)
size += len(chunk) size += len(chunk)
finished = os.fstat(descriptor) finished = os.fstat(descriptor)
if not _same_file_metadata(finished, opened): if not same_file_metadata(finished, opened):
raise OSError(f"{label} changed while hashing: {file_path}") raise OSError(f"{label} changed while hashing: {file_path}")
finally: finally:
os.close(descriptor) os.close(descriptor)
visible = directory.stat_file(name) visible = directory.stat_file(name)
if not _same_file_metadata(visible, finished): if not same_file_metadata(visible, finished):
raise OSError(f"{label} changed while hashing: {file_path}") raise OSError(f"{label} changed while hashing: {file_path}")
return FileFingerprint( return FileFingerprint(
digest.hexdigest(), digest.hexdigest(),
@@ -419,7 +419,7 @@ def load_journal() -> Optional[TransactionJournal]:
descriptor = os.open(path, flags) descriptor = os.open(path, flags)
try: try:
opened = os.fstat(descriptor) opened = os.fstat(descriptor)
if not _same_file_metadata(opened, metadata): if not same_file_metadata(opened, metadata):
raise OSError(f"Transaction journal changed while opening: {path}") raise OSError(f"Transaction journal changed while opening: {path}")
chunks = [] chunks = []
remaining = MAX_JOURNAL_BYTES + 1 remaining = MAX_JOURNAL_BYTES + 1
@@ -431,7 +431,7 @@ def load_journal() -> Optional[TransactionJournal]:
remaining -= len(chunk) remaining -= len(chunk)
raw = b"".join(chunks) raw = b"".join(chunks)
finished = os.fstat(descriptor) finished = os.fstat(descriptor)
if not _same_file_metadata(finished, opened): if not same_file_metadata(finished, opened):
raise OSError(f"Transaction journal changed while reading: {path}") raise OSError(f"Transaction journal changed while reading: {path}")
finally: finally:
os.close(descriptor) os.close(descriptor)
@@ -439,7 +439,7 @@ def load_journal() -> Optional[TransactionJournal]:
visible = os.lstat(path) visible = os.lstat(path)
except FileNotFoundError as error: except FileNotFoundError as error:
raise OSError(f"Transaction journal changed while reading: {path}") from error raise OSError(f"Transaction journal changed while reading: {path}") from error
if not _same_file_metadata(visible, finished): if not same_file_metadata(visible, finished):
raise OSError(f"Transaction journal changed while reading: {path}") raise OSError(f"Transaction journal changed while reading: {path}")
if len(raw) > MAX_JOURNAL_BYTES: if len(raw) > MAX_JOURNAL_BYTES:
raise ValueError("Transaction journal exceeds its size limit") raise ValueError("Transaction journal exceeds its size limit")
+2 -93
View File
@@ -9,21 +9,13 @@ from uuid import uuid4
from ._managed_files import FileSnapshot from ._managed_files import FileSnapshot
from ._pinned_directory import PinnedDirectory from ._pinned_directory import PinnedDirectory
from ._private_artifact import QuarantinedFile, reserve_private_path from ._private_artifact import QuarantinedFile, reserve_private_path
from ._transaction_staging import same_snapshot from ._transaction_staging import read_target, same_snapshot
from ._transaction_types import StagedChange from ._transaction_types import StagedChange
ReplaceFile = Callable[[str, str, PinnedDirectory], None] ReplaceFile = Callable[[str, str, PinnedDirectory], None]
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
return staged.directory.read_file(
staged.directory.name_for(staged.change.file_path),
staged.change.label,
staged.change.maximum_bytes,
)
def _read_private_path( def _read_private_path(
staged: StagedChange, staged: StagedChange,
file_path: str, file_path: str,
@@ -36,18 +28,6 @@ def _read_private_path(
) )
def _same_identity_and_contents(
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
) -> bool:
if current is None or expected is None:
return current is expected
return (
current.contents == expected.contents
and current.device == expected.device
and current.inode == expected.inode
)
def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]: def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]:
basename = os.path.basename(staged.change.file_path) basename = os.path.basename(staged.change.file_path)
path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery") path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery")
@@ -63,76 +43,6 @@ def _preserve_private_path(error: Exception, file_path: str) -> OSError:
return OSError(f"{error}; transaction data preserved at {file_path}") return OSError(f"{error}; transaction data preserved at {file_path}")
def _release_rollback_source(staged: StagedChange, source_path: str) -> bool:
if staged.rollback_path is not None and os.path.normcase(
os.path.abspath(staged.rollback_path)
) == os.path.normcase(os.path.abspath(source_path)):
staged.rollback_path = None
return True
return False
def _preserve_source_path(
error: Exception,
file_path: str,
original_source: bool,
) -> OSError:
kind = "original content" if original_source else "transaction data"
return OSError(f"{error}; {kind} preserved at {file_path}")
def install_private_file(
staged: StagedChange,
source_path: str,
expected: FileSnapshot,
replace_file: ReplaceFile,
) -> None:
try:
replace_file(source_path, staged.change.file_path, staged.directory)
except Exception:
raise
captured = QuarantinedFile.capture(
staged.directory,
source_path,
f"{staged.change.label} recovery source",
)
original_source = _release_rollback_source(staged, source_path)
captured_snapshot = captured.text_snapshot(staged.change.maximum_bytes)
if not same_snapshot(captured_snapshot, expected):
try:
captured.restore(source_path)
except Exception as error:
preserved = captured.preserve() if captured.active else source_path
raise OSError(
f"{error}; concurrent data preserved at {preserved}"
) from error
raise OSError(
f"{staged.change.label} recovery source changed: {source_path}; "
f"concurrent data preserved at {source_path}"
)
try:
captured.link(staged.change.file_path)
except Exception as error:
preserved = captured.preserve() if captured.active else source_path
raise _preserve_source_path(
error,
preserved,
original_source,
) from error
restored = _read_target(staged)
if not same_snapshot(restored, expected):
preserved = captured.preserve()
raise _preserve_source_path(
OSError(
f"{staged.change.label} changed while installing recovery data: "
f"{staged.change.file_path}"
),
preserved,
original_source,
)
captured.discard()
def capture_recovery_target( def capture_recovery_target(
staged: StagedChange, staged: StagedChange,
expected: FileSnapshot, expected: FileSnapshot,
@@ -263,7 +173,7 @@ def remove_committed_creation(
replace_file, replace_file,
capture_file, capture_file,
) )
if _read_target(staged) is not None: if read_target(staged) is not None:
discard_private_file(staged, recovery_path, recovery_snapshot) discard_private_file(staged, recovery_path, recovery_snapshot)
raise OSError( raise OSError(
f"{staged.change.label} changed after recovery capture: " f"{staged.change.label} changed after recovery capture: "
@@ -275,6 +185,5 @@ def remove_committed_creation(
__all__ = [ __all__ = [
"capture_recovery_target", "capture_recovery_target",
"discard_private_file", "discard_private_file",
"install_private_file",
"remove_committed_creation", "remove_committed_creation",
] ]
+21 -2
View File
@@ -46,6 +46,26 @@ def same_snapshot(
return current == expected return current == expected
def read_target(staged: StagedChange) -> Optional[FileSnapshot]:
return staged.directory.read_file(
staged.directory.name_for(staged.change.file_path),
staged.change.label,
staged.change.maximum_bytes,
)
def same_identity_and_contents(
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
) -> bool:
if current is None or expected is None:
return current is expected
return (
current.contents == expected.contents
and current.device == expected.device
and current.inode == expected.inode
)
def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None: def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None:
if not file_path: if not file_path:
return return
@@ -338,8 +358,7 @@ def _remove_exact_text(
!= (expected.device, expected.inode) != (expected.device, expected.inode)
) )
if not changed: if not changed:
# An in-place rewrite keeps the inode, so the removal is only exact # An in-place rewrite keeps the inode; bytes must match too.
# once the bytes also match the owned snapshot.
try: try:
snapshot = captured.text_snapshot( snapshot = captured.text_snapshot(
len(expected.contents.encode("utf-8")) len(expected.contents.encode("utf-8"))
-7
View File
@@ -210,12 +210,6 @@ def _current_user_sid() -> Tuple[Any, str]:
close_handle(int(token_value)) close_handle(int(token_value))
def current_user_sid_string() -> str:
"""Return the stable current-user SID used by the state namespace."""
_sid, sid_text = _current_user_sid()
return sid_text
def _security_descriptor(directory: bool) -> Tuple[int, int]: def _security_descriptor(directory: bool) -> Tuple[int, int]:
_sid, sid_text = _current_user_sid() _sid, sid_text = _current_user_sid()
inheritance = "OICI" if directory else "" inheritance = "OICI" if directory else ""
@@ -491,7 +485,6 @@ def verify_private_path(path: str, *, directory: bool) -> None:
__all__ = [ __all__ = [
"close_handle", "close_handle",
"current_user_sid_string",
"ensure_private_directory", "ensure_private_directory",
"open_private_file", "open_private_file",
"verify_private_path", "verify_private_path",
+2 -1
View File
@@ -27,7 +27,8 @@ from .claudecode_io import (
validate_private_key, validate_private_key,
validate_runtime_tree, validate_runtime_tree,
) )
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
@dataclass(frozen=True) @dataclass(frozen=True)
+2 -1
View File
@@ -36,7 +36,8 @@ from .codex_io import (
validate_runtime_tree, validate_runtime_tree,
write_codex_changes, write_codex_changes,
) )
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
def _runtime_config(config: InstallConfig, agent_id: str) -> dict: def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
+2 -1
View File
@@ -36,7 +36,8 @@ from .cursor_io import (
validate_runtime_identity, validate_runtime_identity,
write_cursor_changes, write_cursor_changes,
) )
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
def _runtime_config(config: InstallConfig, agent_id: str) -> dict: def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
+2 -1
View File
@@ -26,7 +26,8 @@ from .gemini_io import (
validate_private_key, validate_private_key,
validate_runtime_tree, validate_runtime_tree,
) )
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
@dataclass(frozen=True) @dataclass(frozen=True)
+2 -1
View File
@@ -25,7 +25,8 @@ from .grok_io import (
validate_private_key, validate_private_key,
validate_runtime_tree, validate_runtime_tree,
) )
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
@dataclass(frozen=True) @dataclass(frozen=True)
-3
View File
@@ -5,11 +5,8 @@ from __future__ import annotations
import sys import sys
from .error_log_template import ERROR_LOG_RUNTIME from .error_log_template import ERROR_LOG_RUNTIME
from .guard_template import generate_guard_script
from .runtime_reader_template import PROTECTED_RUNTIME_READER from .runtime_reader_template import PROTECTED_RUNTIME_READER
__all__ = ["generate_guard_script", "generate_hook_script"]
def generate_hook_script( def generate_hook_script(
*, *,
+2 -1
View File
@@ -12,7 +12,8 @@ from elydora._runtime_paths import resolve_agent_directory
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
from ._transaction import FileChange, file_change, source_change, write_changes from ._transaction import FileChange, file_change, source_change, write_changes
from .base import InstallConfig from .base import InstallConfig
from .hook_template import generate_guard_script, generate_hook_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
from .kimi_command import runtime_root, same_kimi_path from .kimi_command import runtime_root, same_kimi_path
from .kimi_contract import ( from .kimi_contract import (
AGENT_KEY, AGENT_KEY,
+3
View File
@@ -47,3 +47,6 @@ elydora = ["py.typed"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = {attr = "elydora._version.__version__"} version = {attr = "elydora._version.__version__"}
[tool.mypy]
platform = "win32"
+1 -1
View File
@@ -9,7 +9,7 @@ import sys
from threading import Thread from threading import Thread
import time import time
from elydora.plugins.hook_template import generate_guard_script from elydora.plugins.guard_template import generate_guard_script
AGENT_ID = "agent 1" AGENT_ID = "agent 1"