fix: make Codex hooks transactional
This commit is contained in:
1 parent
c3602da5f3
commit
cb6173e6ee
17 files changed
+2321
-883
No files matched your search
@@ -29,6 +29,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
|
|||||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||||
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte, resolve Python runtimes from their absolute shebangs, and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte, resolve Python runtimes from their absolute shebangs, and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
||||||
|
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Preserve additive user TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups with ten-second handlers, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
|
||||||
- Write Factory Droid hooks only to user-level `~/.factory/hooks.json`, its active legacy fallback, or the `hooks` object in `~/.factory/settings.json`; keep project and organization sources read-only. Preserve JSONC syntax, select sources per event, store direct event maps in hook files, keep settings hooks nested, validate JavaScript matchers before installation, use exact command ownership, and instruct users to review changes through `/hooks`.
|
- Write Factory Droid hooks only to user-level `~/.factory/hooks.json`, its active legacy fallback, or the `hooks` object in `~/.factory/settings.json`; keep project and organization sources read-only. Preserve JSONC syntax, select sources per event, store direct event maps in hook files, keep settings hooks nested, validate JavaScript matchers before installation, use exact command ownership, and instruct users to review changes through `/hooks`.
|
||||||
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`; keep workspace settings read-only. Preserve comments while rejecting trailing commas and duplicate keys, store hooks under the nested `hooks` object, treat `disableAllHooks` as unhealthy, validate JavaScript matchers before installation, use millisecond timeouts and explicit host shells, preserve PowerShell native exit codes, and instruct users to review changes through `/hooks`.
|
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`; keep workspace settings read-only. Preserve comments while rejecting trailing commas and duplicate keys, store hooks under the nested `hooks` object, treat `disableAllHooks` as unhealthy, validate JavaScript matchers before installation, use millisecond timeouts and explicit host shells, preserve PowerShell native exit codes, and instruct users to review changes through `/hooks`.
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
|
|||||||
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
|
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
|
||||||
- Accept CLI install credentials through hidden terminal input or physical owner-only credential files. Keep credentials out of process arguments, reject legacy secret options with redacted errors, and require one UTF-8 line of at most 64 KiB.
|
- Accept CLI install credentials through hidden terminal input or physical owner-only credential files. Keep credentials out of process arguments, reject legacy secret options with redacted errors, and require one UTF-8 line of at most 64 KiB.
|
||||||
- Persist the signing key once at `~/.elydora/<agent-id>/private.key` with mode `0600`. Generated audit runtimes read and validate that physical file at execution time.
|
- Persist the signing key once at `~/.elydora/<agent-id>/private.key` with mode `0600`. Generated audit runtimes read and validate that physical file at execution time.
|
||||||
|
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors.
|
||||||
- Write runtime secrets, generated scripts, and provider configuration through same-directory temporary files followed by atomic replacement. Surface permission and replacement failures.
|
- Write runtime secrets, generated scripts, and provider configuration through same-directory temporary files followed by atomic replacement. Surface permission and replacement failures.
|
||||||
- Interpret `max_retries` as retries after the initial attempt and reject negative or non-integer values.
|
- Interpret `max_retries` as retries after the initial attempt and reject negative or non-integer values.
|
||||||
- Automatically retry RFC-idempotent methods; retry non-idempotent methods only when the transport proves the request was never sent.
|
- Automatically retry RFC-idempotent methods; retry non-idempotent methods only when the transport proves the request was never sent.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ elydora install \
|
|||||||
|
|
||||||
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token through hidden prompts. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. The installed signing key is stored once at `~/.elydora/<agent-id>/private.key` with mode `0600`, and generated audit hooks read that file at runtime.
|
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token through hidden prompts. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. The installed signing key is stored once at `~/.elydora/<agent-id>/private.key` with mode `0600`, and generated audit hooks read that file at runtime.
|
||||||
|
|
||||||
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
|
||||||
|
|
||||||
Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora forwards Cursor's native success and failure payloads, preserves unrelated user hooks, and leaves project and enterprise sources unchanged. The generated guard and audit handlers use a 10-second fail-closed boundary, exact freeze exit-code propagation, protected runtime credentials, and one rollback-capable transaction for all runtime and hook configuration files.
|
Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora forwards Cursor's native success and failure payloads, preserves unrelated user hooks, and leaves project and enterprise sources unchanged. The generated guard and audit handlers use a 10-second fail-closed boundary, exact freeze exit-code propagation, protected runtime credentials, and one rollback-capable transaction for all runtime and hook configuration files.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Physical file and directory primitives for managed hook state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SECRET_BYTES = 64 * 1024
|
||||||
|
MAX_CONFIG_BYTES = 512 * 1024
|
||||||
|
MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileSnapshot:
|
||||||
|
contents: str
|
||||||
|
device: int
|
||||||
|
inode: int
|
||||||
|
mode: int
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result]:
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(file_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def read_physical_file(
|
||||||
|
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
|
||||||
|
if before.st_size > maximum_bytes:
|
||||||
|
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 |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = -1
|
||||||
|
try:
|
||||||
|
descriptor = os.open(file_path, flags)
|
||||||
|
after = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(after.st_mode):
|
||||||
|
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||||
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||||
|
raise OSError(f"{label} changed while opening: {file_path}")
|
||||||
|
if after.st_size > maximum_bytes:
|
||||||
|
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||||
|
with os.fdopen(descriptor, "rb") as file:
|
||||||
|
descriptor = -1
|
||||||
|
raw = file.read(maximum_bytes + 1)
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
if len(raw) > maximum_bytes:
|
||||||
|
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||||
|
try:
|
||||||
|
contents = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as error:
|
||||||
|
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
|
||||||
|
return FileSnapshot(
|
||||||
|
contents=contents,
|
||||||
|
device=after.st_dev,
|
||||||
|
inode=after.st_ino,
|
||||||
|
mode=stat.S_IMODE(after.st_mode),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def physical_file_exists(file_path: str, label: str) -> bool:
|
||||||
|
return _inspect_regular_file(file_path, label) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def physical_directory_exists(directory: str, label: str) -> bool:
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(directory)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Inspect {label} at {directory}: {error}") from error
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise OSError(f"{label} is not a physical directory: {directory}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_physical_directory(directory: str, label: str) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, mode=0o700, exist_ok=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Prepare {label} at {directory}: {error}") from error
|
||||||
|
if not physical_directory_exists(directory, label):
|
||||||
|
raise OSError(f"{label} is missing: {directory}")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Strict JSON parsing with duplicate-key rejection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
JsonObject = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class _DuplicateKey(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_object(pairs: List[Tuple[str, Any]]) -> JsonObject:
|
||||||
|
result: JsonObject = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise _DuplicateKey(f'duplicate field "{key}"')
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_object(raw: str, label: str) -> JsonObject:
|
||||||
|
try:
|
||||||
|
value = json.loads(raw, object_pairs_hook=_unique_object)
|
||||||
|
except (_DuplicateKey, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"Failed to parse {label}: {error}") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"{label} must contain a JSON object")
|
||||||
|
return value
|
||||||
@@ -4,11 +4,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
import stat
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import List, Optional, Sequence
|
from typing import List, Optional, Sequence
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ._managed_files import (
|
||||||
|
MAX_SOURCE_BYTES,
|
||||||
|
ensure_physical_directory,
|
||||||
|
physical_file_exists,
|
||||||
|
read_physical_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FileChange:
|
class FileChange:
|
||||||
@@ -18,6 +24,9 @@ class FileChange:
|
|||||||
next_source: Optional[str]
|
next_source: Optional[str]
|
||||||
mode: int
|
mode: int
|
||||||
original_mode: Optional[int]
|
original_mode: Optional[int]
|
||||||
|
original_device: Optional[int]
|
||||||
|
original_inode: Optional[int]
|
||||||
|
maximum_bytes: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -28,23 +37,13 @@ class _StagedChange:
|
|||||||
committed: bool = False
|
committed: bool = False
|
||||||
|
|
||||||
|
|
||||||
def read_optional(file_path: str, label: str) -> Optional[str]:
|
def read_optional(
|
||||||
try:
|
file_path: str,
|
||||||
with open(file_path, "r", encoding="utf-8", newline="") as file:
|
label: str,
|
||||||
return file.read()
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||||
except FileNotFoundError:
|
) -> Optional[str]:
|
||||||
return None
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||||
except OSError as error:
|
return None if snapshot is None else snapshot.contents
|
||||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
|
||||||
|
|
||||||
|
|
||||||
def _existing_mode(file_path: str) -> Optional[int]:
|
|
||||||
try:
|
|
||||||
return stat.S_IMODE(os.stat(file_path).st_mode)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(f"Read file mode at {file_path}: {error}") from error
|
|
||||||
|
|
||||||
|
|
||||||
def source_change(
|
def source_change(
|
||||||
@@ -53,16 +52,24 @@ def source_change(
|
|||||||
original: Optional[str],
|
original: Optional[str],
|
||||||
next_source: Optional[str],
|
next_source: Optional[str],
|
||||||
mode: int,
|
mode: int,
|
||||||
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||||
) -> Optional[FileChange]:
|
) -> Optional[FileChange]:
|
||||||
if original == next_source:
|
if original == next_source:
|
||||||
return None
|
return None
|
||||||
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||||
|
current = None if snapshot is None else snapshot.contents
|
||||||
|
if current != original:
|
||||||
|
raise OSError(f"{label} changed before staging: {file_path}")
|
||||||
return FileChange(
|
return FileChange(
|
||||||
file_path,
|
file_path,
|
||||||
label,
|
label,
|
||||||
original,
|
original,
|
||||||
next_source,
|
next_source,
|
||||||
mode,
|
mode,
|
||||||
_existing_mode(file_path),
|
None if snapshot is None else snapshot.mode,
|
||||||
|
None if snapshot is None else snapshot.device,
|
||||||
|
None if snapshot is None else snapshot.inode,
|
||||||
|
maximum_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,13 +78,16 @@ def file_change(
|
|||||||
label: str,
|
label: str,
|
||||||
next_source: Optional[str],
|
next_source: Optional[str],
|
||||||
mode: int,
|
mode: int,
|
||||||
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||||
) -> Optional[FileChange]:
|
) -> Optional[FileChange]:
|
||||||
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||||
return source_change(
|
return source_change(
|
||||||
file_path,
|
file_path,
|
||||||
label,
|
label,
|
||||||
read_optional(file_path, label),
|
None if snapshot is None else snapshot.contents,
|
||||||
next_source,
|
next_source,
|
||||||
mode,
|
mode,
|
||||||
|
maximum_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,20 +147,24 @@ def _write_staged(
|
|||||||
|
|
||||||
|
|
||||||
def _assert_unchanged(change: FileChange) -> None:
|
def _assert_unchanged(change: FileChange) -> None:
|
||||||
current = read_optional(change.file_path, change.label)
|
snapshot = read_physical_file(
|
||||||
if current != change.original:
|
change.file_path,
|
||||||
|
change.label,
|
||||||
|
change.maximum_bytes,
|
||||||
|
)
|
||||||
|
current = None if snapshot is None else snapshot.contents
|
||||||
|
identity_changed = snapshot is not None and (
|
||||||
|
snapshot.device != change.original_device
|
||||||
|
or snapshot.inode != change.original_inode
|
||||||
|
)
|
||||||
|
if current != change.original or identity_changed:
|
||||||
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
||||||
|
|
||||||
|
|
||||||
def _stage(change: FileChange) -> _StagedChange:
|
def _stage(change: FileChange) -> _StagedChange:
|
||||||
_assert_unchanged(change)
|
_assert_unchanged(change)
|
||||||
directory = os.path.dirname(change.file_path)
|
directory = os.path.dirname(change.file_path)
|
||||||
try:
|
ensure_physical_directory(directory, f"{change.label} directory")
|
||||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(
|
|
||||||
f"Create directory for {change.label} at {directory}: {error}"
|
|
||||||
) from error
|
|
||||||
basename = os.path.basename(change.file_path)
|
basename = os.path.basename(change.file_path)
|
||||||
temporary_path = None
|
temporary_path = None
|
||||||
rollback_path = None
|
rollback_path = None
|
||||||
@@ -258,12 +272,7 @@ def write_changes(changes: Sequence[FileChange], label: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||||
try:
|
return physical_file_exists(file_path, label)
|
||||||
return stat.S_ISREG(os.stat(file_path).st_mode)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
|
||||||
|
|
||||||
|
|
||||||
def require_runtime(file_path: str, label: str) -> None:
|
def require_runtime(file_path: str, label: str) -> None:
|
||||||
|
|||||||
+195
-374
@@ -1,415 +1,236 @@
|
|||||||
"""OpenAI Codex lifecycle-hook integration."""
|
"""OpenAI Codex native user-hook integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
import urllib.parse
|
||||||
import stat
|
from typing import List, Optional
|
||||||
# subprocess provides argument quoting only; this module starts no process.
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
from elydora._runtime_paths import resolve_agent_directory
|
||||||
|
from elydora.utils import base64url_encode
|
||||||
|
|
||||||
|
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES
|
||||||
|
from ._transaction import FileChange
|
||||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||||
from .hook_template import generate_hook_script
|
from .codex_contract import (
|
||||||
|
AGENT_KEY,
|
||||||
|
AUDIT_SCRIPT,
|
||||||
AGENT_KEY = "codex"
|
AUDIT_STATUS,
|
||||||
OWNED_DESCRIPTION = "Elydora audit and freeze enforcement"
|
GUARD_SCRIPT,
|
||||||
GUARD_STATUS = "Checking Elydora agent state"
|
GUARD_STATUS,
|
||||||
AUDIT_STATUS = "Recording Elydora tool use"
|
build_handler,
|
||||||
GUARD_SCRIPT = "guard.py"
|
remove_managed_hooks,
|
||||||
AUDIT_SCRIPT = "hook.py"
|
render_document,
|
||||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
runtime_contracts,
|
||||||
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codex", "hooks.json")
|
runtime_root,
|
||||||
|
same_path,
|
||||||
JsonObject = Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_failed_write(path: str, label: str, cause: Exception) -> None:
|
|
||||||
try:
|
|
||||||
os.remove(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
except OSError as cleanup_error:
|
|
||||||
raise OSError(
|
|
||||||
f"Write {label} failed: {cause}; cleanup of {path} failed: {cleanup_error}"
|
|
||||||
) from cause
|
|
||||||
|
|
||||||
|
|
||||||
def _write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
|
|
||||||
directory = os.path.dirname(path)
|
|
||||||
try:
|
|
||||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(f"Create directory for {label} at {directory}: {error}") from error
|
|
||||||
|
|
||||||
descriptor = -1
|
|
||||||
temporary_path = ""
|
|
||||||
try:
|
|
||||||
descriptor, temporary_path = tempfile.mkstemp(
|
|
||||||
prefix=f".{os.path.basename(path)}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
dir=directory,
|
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as file:
|
from .codex_io import (
|
||||||
descriptor = -1
|
read_document,
|
||||||
file.write(content)
|
rendered_change,
|
||||||
file.flush()
|
runtime_change,
|
||||||
os.fsync(file.fileno())
|
runtime_files_exist,
|
||||||
os.chmod(temporary_path, mode)
|
validate_hooks_directory,
|
||||||
os.replace(temporary_path, path)
|
validate_runtime_tree,
|
||||||
except Exception as error:
|
write_codex_changes,
|
||||||
if descriptor >= 0:
|
|
||||||
try:
|
|
||||||
os.close(descriptor)
|
|
||||||
except OSError as close_error:
|
|
||||||
_cleanup_failed_write(temporary_path, label, close_error)
|
|
||||||
raise OSError(
|
|
||||||
f"Write {label} at {path} failed: {error}; "
|
|
||||||
f"close failed: {close_error}"
|
|
||||||
) from error
|
|
||||||
_cleanup_failed_write(temporary_path, label, error)
|
|
||||||
raise OSError(f"Write {label} at {path}: {error}") from error
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None:
|
|
||||||
encoded = json.dumps(value, indent=2) + "\n"
|
|
||||||
_write_text_atomic(path, encoded, 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 _build_handler(script_path: str, status_message: str) -> JsonObject:
|
|
||||||
return {
|
|
||||||
"type": "command",
|
|
||||||
"command": f"{shlex.quote(sys.executable)} {shlex.quote(script_path)}",
|
|
||||||
"commandWindows": subprocess.list2cmdline([sys.executable, script_path]),
|
|
||||||
"timeout": 10,
|
|
||||||
"statusMessage": status_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _hooks_object(settings: JsonObject) -> JsonObject:
|
|
||||||
if "hooks" not in settings:
|
|
||||||
return {}
|
|
||||||
hooks = settings["hooks"]
|
|
||||||
if not isinstance(hooks, dict):
|
|
||||||
raise ValueError('Codex hooks config field "hooks" must be an object')
|
|
||||||
return dict(hooks)
|
|
||||||
|
|
||||||
|
|
||||||
def _event_groups(hooks: JsonObject, event: str) -> List[JsonObject]:
|
|
||||||
if event not in hooks:
|
|
||||||
return []
|
|
||||||
groups = hooks[event]
|
|
||||||
if not isinstance(groups, list) or not all(
|
|
||||||
isinstance(group, dict) for group in groups
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
f'Codex hooks config field "hooks.{event}" must be an array of objects'
|
|
||||||
)
|
)
|
||||||
return groups
|
from .hook_template import generate_guard_script, generate_hook_script
|
||||||
|
|
||||||
|
|
||||||
def _is_managed_command(command: Any, script_name: str, agent_id: str = "") -> bool:
|
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||||
if not isinstance(command, str):
|
value = {
|
||||||
return False
|
|
||||||
normalized = os.path.normcase(command)
|
|
||||||
if ".elydora" not in normalized or script_name.lower() not in normalized.lower():
|
|
||||||
return False
|
|
||||||
if not agent_id:
|
|
||||||
return True
|
|
||||||
expected_path = os.path.normcase(
|
|
||||||
os.path.join(ELYDORA_DIR, agent_id, script_name)
|
|
||||||
)
|
|
||||||
return expected_path in normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _is_elydora_handler(handler: JsonObject, agent_id: str = "") -> bool:
|
|
||||||
status_message = handler.get("statusMessage")
|
|
||||||
if status_message == GUARD_STATUS:
|
|
||||||
script_name = GUARD_SCRIPT
|
|
||||||
elif status_message == AUDIT_STATUS:
|
|
||||||
script_name = AUDIT_SCRIPT
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
return any(
|
|
||||||
_is_managed_command(handler.get(key), script_name, agent_id)
|
|
||||||
for key in ("command", "commandWindows")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _without_elydora(
|
|
||||||
groups: List[JsonObject],
|
|
||||||
agent_id: str = "",
|
|
||||||
) -> List[JsonObject]:
|
|
||||||
filtered_groups: List[JsonObject] = []
|
|
||||||
for group in groups:
|
|
||||||
handlers = group.get("hooks")
|
|
||||||
if not isinstance(handlers, list) or not all(
|
|
||||||
isinstance(handler, dict) for handler in handlers
|
|
||||||
):
|
|
||||||
raise ValueError("Codex hook matcher group must contain a hooks array")
|
|
||||||
filtered = [
|
|
||||||
handler
|
|
||||||
for handler in handlers
|
|
||||||
if not _is_elydora_handler(handler, agent_id)
|
|
||||||
]
|
|
||||||
if filtered:
|
|
||||||
filtered_groups.append({**group, "hooks": filtered})
|
|
||||||
return filtered_groups
|
|
||||||
|
|
||||||
|
|
||||||
def _find_handler(
|
|
||||||
groups: List[JsonObject],
|
|
||||||
status_message: str,
|
|
||||||
) -> Optional[JsonObject]:
|
|
||||||
for group in groups:
|
|
||||||
handlers = group.get("hooks")
|
|
||||||
if not isinstance(handlers, list) or not all(
|
|
||||||
isinstance(handler, dict) for handler in handlers
|
|
||||||
):
|
|
||||||
raise ValueError("Codex hook matcher group must contain a hooks array")
|
|
||||||
for handler in handlers:
|
|
||||||
if (
|
|
||||||
handler.get("statusMessage") == status_message
|
|
||||||
and _is_elydora_handler(handler)
|
|
||||||
):
|
|
||||||
return handler
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _command_references(handler: JsonObject, script_path: str) -> bool:
|
|
||||||
return any(
|
|
||||||
isinstance(handler.get(key), str) and script_path in handler[key]
|
|
||||||
for key in ("command", "commandWindows")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_owned_settings(settings: JsonObject, hooks: JsonObject) -> bool:
|
|
||||||
settings_keys = {"description", "hooks"}
|
|
||||||
hook_keys = {"PreToolUse", "PostToolUse"}
|
|
||||||
return (
|
|
||||||
all(key in settings_keys for key in settings)
|
|
||||||
and settings.get("description") == OWNED_DESCRIPTION
|
|
||||||
and all(key in hook_keys for key in hooks)
|
|
||||||
and all(isinstance(value, list) and not value for value in hooks.values())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_scripts_exist(guard: JsonObject, audit: JsonObject) -> bool:
|
|
||||||
try:
|
|
||||||
with os.scandir(ELYDORA_DIR) as iterator:
|
|
||||||
entries = sorted(iterator, key=lambda entry: entry.name)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(
|
|
||||||
f"Read Elydora runtime directory at {ELYDORA_DIR}: {error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
is_directory = entry.is_dir(follow_symlinks=False)
|
|
||||||
except OSError as error:
|
|
||||||
raise OSError(
|
|
||||||
f"Read Elydora runtime entry at {entry.path}: {error}"
|
|
||||||
) from error
|
|
||||||
if not is_directory:
|
|
||||||
continue
|
|
||||||
|
|
||||||
guard_path = os.path.join(entry.path, GUARD_SCRIPT)
|
|
||||||
hook_path = os.path.join(entry.path, AUDIT_SCRIPT)
|
|
||||||
if not (
|
|
||||||
_command_references(guard, guard_path)
|
|
||||||
and _command_references(audit, hook_path)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
config_path = os.path.join(entry.path, "config.json")
|
|
||||||
config = _read_json(config_path, "Elydora runtime config")
|
|
||||||
if config is None:
|
|
||||||
continue
|
|
||||||
agent_name = config.get("agent_name")
|
|
||||||
if not isinstance(agent_name, str):
|
|
||||||
raise ValueError(
|
|
||||||
f'Elydora runtime config at {config_path} field "agent_name" '
|
|
||||||
"must be a string"
|
|
||||||
)
|
|
||||||
if agent_name != AGENT_KEY:
|
|
||||||
continue
|
|
||||||
return _regular_file_exists(
|
|
||||||
guard_path, "Elydora guard runtime"
|
|
||||||
) and _regular_file_exists(hook_path, "Elydora audit runtime")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class CodexPlugin(AgentPlugin):
|
|
||||||
"""Install Elydora into Codex user lifecycle hooks."""
|
|
||||||
|
|
||||||
def install(self, config: InstallConfig) -> None:
|
|
||||||
agent_id = config.get("agent_id", "")
|
|
||||||
if not agent_id:
|
|
||||||
raise ValueError("agent_id is required")
|
|
||||||
|
|
||||||
existing_settings = _read_json(CONFIG_PATH, "Codex hooks config")
|
|
||||||
settings = (
|
|
||||||
existing_settings
|
|
||||||
if existing_settings is not None
|
|
||||||
else {"description": OWNED_DESCRIPTION}
|
|
||||||
)
|
|
||||||
hooks = _hooks_object(settings)
|
|
||||||
pre_tool_use = _without_elydora(_event_groups(hooks, "PreToolUse"))
|
|
||||||
post_tool_use = _without_elydora(_event_groups(hooks, "PostToolUse"))
|
|
||||||
|
|
||||||
guard_path = config.get("guard_script_path", "")
|
|
||||||
_require_runtime(guard_path, "Elydora guard runtime")
|
|
||||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
|
||||||
hook_path = os.path.join(agent_dir, AUDIT_SCRIPT)
|
|
||||||
hooks["PreToolUse"] = [
|
|
||||||
*pre_tool_use,
|
|
||||||
{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": [_build_handler(guard_path, GUARD_STATUS)],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
hooks["PostToolUse"] = [
|
|
||||||
*post_tool_use,
|
|
||||||
{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": [_build_handler(hook_path, AUDIT_STATUS)],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
runtime_config: JsonObject = {
|
|
||||||
"org_id": config.get("org_id", ""),
|
"org_id": config.get("org_id", ""),
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"kid": config.get("kid", ""),
|
"kid": config.get("kid", ""),
|
||||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||||
"token": config.get("token", ""),
|
"agent_name": AGENT_KEY,
|
||||||
"agent_name": config.get("agent_name", AGENT_KEY) or AGENT_KEY,
|
|
||||||
}
|
}
|
||||||
hook_script = generate_hook_script(
|
token = config.get("token")
|
||||||
|
if token:
|
||||||
|
value["token"] = token
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _json_source(value: dict) -> str:
|
||||||
|
return json.dumps(value, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||||
|
return [change for change in changes if change is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_paths(config: InstallConfig) -> tuple[str, str, str, str]:
|
||||||
|
agent_id = config.get("agent_id", "")
|
||||||
|
if not agent_id:
|
||||||
|
raise ValueError("agent_id is required")
|
||||||
|
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||||
|
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||||
|
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||||
|
configured_guard = config.get("guard_script_path", "")
|
||||||
|
if not same_path(configured_guard, guard_path):
|
||||||
|
raise ValueError(
|
||||||
|
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||||
|
)
|
||||||
|
return agent_id, agent_directory, guard_path, audit_path
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_private_key(value: str) -> None:
|
||||||
|
try:
|
||||||
|
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||||
|
seed = base64.b64decode(
|
||||||
|
padded.replace("-", "+").replace("_", "/"),
|
||||||
|
validate=True,
|
||||||
|
)
|
||||||
|
except (ValueError, UnicodeEncodeError) as error:
|
||||||
|
raise ValueError(
|
||||||
|
"private_key must be a canonical 32-byte base64url value"
|
||||||
|
) from error
|
||||||
|
if len(seed) != 32 or base64url_encode(seed) != value:
|
||||||
|
raise ValueError("private_key must be a canonical 32-byte base64url value")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_install_config(config: InstallConfig) -> None:
|
||||||
|
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||||
|
value = config.get(field)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
if config.get("agent_name") != AGENT_KEY:
|
||||||
|
raise ValueError(f"Codex installation requires agent_name {AGENT_KEY}")
|
||||||
|
_validate_private_key(config["private_key"])
|
||||||
|
|
||||||
|
base_url = config["base_url"]
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(base_url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
parsed.port
|
||||||
|
has_credentials = parsed.username is not None or parsed.password is not None
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("base_url must be an absolute HTTP or HTTPS URL") from error
|
||||||
|
invalid_character = "\\" in base_url or any(
|
||||||
|
character.isspace() or ord(character) < 32 for character in base_url
|
||||||
|
)
|
||||||
|
valid_origin = (
|
||||||
|
parsed.scheme in ("http", "https")
|
||||||
|
and bool(parsed.netloc)
|
||||||
|
and hostname is not None
|
||||||
|
and not invalid_character
|
||||||
|
)
|
||||||
|
if not valid_origin:
|
||||||
|
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
||||||
|
if has_credentials or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError(
|
||||||
|
"base_url must exclude credentials, query parameters, and fragments"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "token" in config:
|
||||||
|
token = config["token"]
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
raise ValueError("token must be a non-empty string when provided")
|
||||||
|
_agent_paths(config)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexPlugin(AgentPlugin):
|
||||||
|
"""Install Elydora into Codex's native global user hooks."""
|
||||||
|
|
||||||
|
manages_guard_runtime = True
|
||||||
|
|
||||||
|
def preflight_install(self, config: InstallConfig) -> None:
|
||||||
|
_validate_install_config(config)
|
||||||
|
document = read_document()
|
||||||
|
validate_hooks_directory(document.file_path)
|
||||||
|
agent_id, agent_directory, _, _ = _agent_paths(config)
|
||||||
|
validate_runtime_tree(runtime_root(), agent_directory, agent_id)
|
||||||
|
|
||||||
|
def install(self, config: InstallConfig) -> None:
|
||||||
|
self.preflight_install(config)
|
||||||
|
document = read_document()
|
||||||
|
agent_id, agent_directory, guard_path, audit_path = _agent_paths(config)
|
||||||
|
|
||||||
|
hooks = remove_managed_hooks(document.hooks)
|
||||||
|
hooks["PreToolUse"] = [
|
||||||
|
*hooks.get("PreToolUse", []),
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [build_handler(guard_path, GUARD_STATUS)],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
hooks["PostToolUse"] = [
|
||||||
|
*hooks.get("PostToolUse", []),
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [build_handler(audit_path, AUDIT_STATUS)],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
guard_script = generate_guard_script(AGENT_KEY, agent_id)
|
||||||
|
audit_script = generate_hook_script(
|
||||||
org_id=config.get("org_id", ""),
|
org_id=config.get("org_id", ""),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
kid=config.get("kid", ""),
|
kid=config.get("kid", ""),
|
||||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||||
|
native_payload=True,
|
||||||
|
agent_name=AGENT_KEY,
|
||||||
)
|
)
|
||||||
_write_json_atomic(
|
changes = _present(
|
||||||
os.path.join(agent_dir, "config.json"),
|
[
|
||||||
runtime_config,
|
runtime_change(
|
||||||
0o600,
|
guard_path,
|
||||||
|
"Elydora guard runtime",
|
||||||
|
guard_script,
|
||||||
|
0o700,
|
||||||
|
),
|
||||||
|
runtime_change(
|
||||||
|
os.path.join(agent_directory, "config.json"),
|
||||||
"Elydora runtime config",
|
"Elydora runtime config",
|
||||||
)
|
_json_source(_runtime_config(config, agent_id)),
|
||||||
_write_text_atomic(
|
0o600,
|
||||||
os.path.join(agent_dir, "private.key"),
|
MAX_CONFIG_BYTES,
|
||||||
|
),
|
||||||
|
runtime_change(
|
||||||
|
os.path.join(agent_directory, "private.key"),
|
||||||
|
"Elydora private key",
|
||||||
config.get("private_key", ""),
|
config.get("private_key", ""),
|
||||||
0o600,
|
0o600,
|
||||||
"Elydora private key",
|
MAX_SECRET_BYTES,
|
||||||
)
|
),
|
||||||
_write_text_atomic(
|
runtime_change(
|
||||||
hook_path,
|
audit_path,
|
||||||
hook_script,
|
|
||||||
0o700,
|
|
||||||
"Elydora audit runtime",
|
"Elydora audit runtime",
|
||||||
|
audit_script,
|
||||||
|
0o700,
|
||||||
|
),
|
||||||
|
rendered_change(render_document(document, hooks)),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
_write_json_atomic(
|
write_codex_changes(changes, "Install Codex hooks")
|
||||||
CONFIG_PATH,
|
print(f" Codex hooks: {document.file_path}")
|
||||||
{**settings, "hooks": hooks},
|
print(" Codex trust: run /hooks and approve both Elydora command hooks.")
|
||||||
0o600,
|
|
||||||
"Codex hooks config",
|
|
||||||
)
|
|
||||||
print("Codex: run /hooks to review and trust the Elydora hooks.")
|
|
||||||
|
|
||||||
def uninstall(self, agent_id: str = "") -> None:
|
def uninstall(self, agent_id: str = "") -> None:
|
||||||
settings = _read_json(CONFIG_PATH, "Codex hooks config")
|
document = read_document()
|
||||||
if settings is None:
|
hooks = remove_managed_hooks(document.hooks, agent_id)
|
||||||
|
change = rendered_change(render_document(document, hooks))
|
||||||
|
if change is None:
|
||||||
return
|
return
|
||||||
hooks = _hooks_object(settings)
|
validate_hooks_directory(document.file_path)
|
||||||
hooks["PreToolUse"] = _without_elydora(
|
write_codex_changes([change], "Uninstall Codex hooks")
|
||||||
_event_groups(hooks, "PreToolUse"),
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
hooks["PostToolUse"] = _without_elydora(
|
|
||||||
_event_groups(hooks, "PostToolUse"),
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
if _is_owned_settings(settings, hooks):
|
|
||||||
_remove_file(CONFIG_PATH, "Codex hooks config")
|
|
||||||
else:
|
|
||||||
_write_json_atomic(
|
|
||||||
CONFIG_PATH,
|
|
||||||
{**settings, "hooks": hooks},
|
|
||||||
0o600,
|
|
||||||
"Codex hooks config",
|
|
||||||
)
|
|
||||||
|
|
||||||
def status(self) -> PluginStatus:
|
def status(self) -> PluginStatus:
|
||||||
settings = _read_json(CONFIG_PATH, "Codex hooks config")
|
document = read_document()
|
||||||
guard: Optional[JsonObject] = None
|
contracts = runtime_contracts(document.hooks)
|
||||||
audit: Optional[JsonObject] = None
|
if not contracts:
|
||||||
if settings is not None:
|
|
||||||
hooks = _hooks_object(settings)
|
|
||||||
guard = _find_handler(_event_groups(hooks, "PreToolUse"), GUARD_STATUS)
|
|
||||||
audit = _find_handler(_event_groups(hooks, "PostToolUse"), AUDIT_STATUS)
|
|
||||||
|
|
||||||
if guard is None or audit is None:
|
|
||||||
return PluginStatus(
|
return PluginStatus(
|
||||||
installed=False,
|
installed=False,
|
||||||
agent=AGENT_KEY,
|
agent=AGENT_KEY,
|
||||||
details="Not installed",
|
details="Not installed",
|
||||||
)
|
)
|
||||||
installed = _runtime_scripts_exist(guard, audit)
|
installed = runtime_files_exist(contracts)
|
||||||
details = (
|
details = (
|
||||||
f"Config: {CONFIG_PATH}"
|
f"Config: {document.file_path}"
|
||||||
if installed
|
if installed
|
||||||
else f"Configured at {CONFIG_PATH}; runtime scripts missing"
|
else f"Configured at {document.file_path}; runtime files missing"
|
||||||
)
|
)
|
||||||
return PluginStatus(
|
return PluginStatus(
|
||||||
installed=installed,
|
installed=installed,
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Codex user-hook contract, exact ownership, and rendering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import ntpath
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess # nosec B404 - used only for legacy argument rendering
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ._strict_json import JsonObject, parse_json_object
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_KEY = "codex"
|
||||||
|
CONFIG_FILE = "hooks.json"
|
||||||
|
GUARD_SCRIPT = "guard.py"
|
||||||
|
AUDIT_SCRIPT = "hook.py"
|
||||||
|
HOOK_TIMEOUT_SECONDS = 10
|
||||||
|
OWNED_DESCRIPTION = "Elydora audit and freeze enforcement"
|
||||||
|
GUARD_STATUS = "Checking Elydora agent state"
|
||||||
|
AUDIT_STATUS = "Recording Elydora tool use"
|
||||||
|
|
||||||
|
CodexHooks = Dict[str, List[JsonObject]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CodexDocument:
|
||||||
|
exists: bool
|
||||||
|
file_path: str
|
||||||
|
root: JsonObject
|
||||||
|
hooks: CodexHooks
|
||||||
|
raw: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RenderedDocument:
|
||||||
|
document: CodexDocument
|
||||||
|
changed: bool
|
||||||
|
next_source: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeContract:
|
||||||
|
agent_id: str
|
||||||
|
guard_path: str
|
||||||
|
audit_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ParsedArgument:
|
||||||
|
value: str
|
||||||
|
next_index: int
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_root() -> str:
|
||||||
|
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||||
|
|
||||||
|
|
||||||
|
def same_path(left: str, right: str) -> bool:
|
||||||
|
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||||
|
os.path.abspath(right)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_agent_id(left: str, right: str) -> bool:
|
||||||
|
return os.path.normcase(left) == os.path.normcase(right)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_posix(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_powershell(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_powershell_path() -> str:
|
||||||
|
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||||
|
system_root = (
|
||||||
|
configured
|
||||||
|
if configured
|
||||||
|
and ntpath.isabs(configured)
|
||||||
|
and re.search(r'["%\r\n]', configured) is None
|
||||||
|
else r"C:\Windows"
|
||||||
|
)
|
||||||
|
return ntpath.join(
|
||||||
|
system_root,
|
||||||
|
"System32",
|
||||||
|
"WindowsPowerShell",
|
||||||
|
"v1.0",
|
||||||
|
"powershell.exe",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_command(script_path: str) -> str:
|
||||||
|
source = (
|
||||||
|
f"& {_quote_powershell(sys.executable)} "
|
||||||
|
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||||
|
return (
|
||||||
|
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||||
|
f"-NonInteractive -EncodedCommand {encoded}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_handler(script_path: str, status_message: str) -> JsonObject:
|
||||||
|
return {
|
||||||
|
"type": "command",
|
||||||
|
"command": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}",
|
||||||
|
"commandWindows": _windows_command(script_path),
|
||||||
|
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||||
|
"statusMessage": status_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||||
|
if start >= len(command) or command[start] != "'":
|
||||||
|
return None
|
||||||
|
apostrophe = "'\"'\"'"
|
||||||
|
value = ""
|
||||||
|
index = start + 1
|
||||||
|
while index < len(command):
|
||||||
|
if command.startswith(apostrophe, index):
|
||||||
|
value += "'"
|
||||||
|
index += len(apostrophe)
|
||||||
|
continue
|
||||||
|
if command[index] == "'":
|
||||||
|
return _ParsedArgument(value, index + 1)
|
||||||
|
value += command[index]
|
||||||
|
index += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_posix_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||||
|
if not isinstance(command, str):
|
||||||
|
return None
|
||||||
|
executable = _read_posix_argument(command, 0)
|
||||||
|
if (
|
||||||
|
executable is None
|
||||||
|
or command[executable.next_index : executable.next_index + 1] != " "
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
script = _read_posix_argument(command, executable.next_index + 1)
|
||||||
|
if script is None or script.next_index != len(command):
|
||||||
|
return None
|
||||||
|
return executable.value, script.value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_powershell_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||||
|
if start >= len(command) or command[start] != "'":
|
||||||
|
return None
|
||||||
|
value = ""
|
||||||
|
index = start + 1
|
||||||
|
while index < len(command):
|
||||||
|
if command[index] != "'":
|
||||||
|
value += command[index]
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if index + 1 < len(command) and command[index + 1] == "'":
|
||||||
|
value += "'"
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
return _ParsedArgument(value, index + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||||
|
if not source.startswith("& "):
|
||||||
|
return None
|
||||||
|
executable = _read_powershell_argument(source, 2)
|
||||||
|
if (
|
||||||
|
executable is None
|
||||||
|
or source[executable.next_index : executable.next_index + 1] != " "
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||||
|
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||||
|
return None
|
||||||
|
return executable.value, script.value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_windows_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||||
|
if not isinstance(command, str):
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(
|
||||||
|
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||||
|
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
match is None
|
||||||
|
or not ntpath.isabs(match.group(1))
|
||||||
|
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(match.group(2), validate=True)
|
||||||
|
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||||
|
return None
|
||||||
|
source = raw.decode("utf-16le")
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
return _parse_powershell_source(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_legacy_commands(handler: JsonObject) -> Optional[Tuple[str, str]]:
|
||||||
|
command = handler.get("command")
|
||||||
|
command_windows = handler.get("commandWindows")
|
||||||
|
if not isinstance(command, str) or not isinstance(command_windows, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
arguments = shlex.split(command, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if len(arguments) != 2:
|
||||||
|
return None
|
||||||
|
expected_posix = f"{shlex.quote(arguments[0])} {shlex.quote(arguments[1])}"
|
||||||
|
if command != expected_posix or command_windows != subprocess.list2cmdline(
|
||||||
|
arguments
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return arguments[0], arguments[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_python_executable(file_path: str) -> bool:
|
||||||
|
return (
|
||||||
|
re.fullmatch(
|
||||||
|
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||||
|
os.path.basename(file_path),
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_script_path(
|
||||||
|
handler: JsonObject,
|
||||||
|
status_message: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if (
|
||||||
|
set(handler)
|
||||||
|
!= {"type", "command", "commandWindows", "timeout", "statusMessage"}
|
||||||
|
or handler.get("type") != "command"
|
||||||
|
or type(handler.get("timeout")) is not int
|
||||||
|
or handler.get("timeout") != HOOK_TIMEOUT_SECONDS
|
||||||
|
or handler.get("statusMessage") != status_message
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
posix = _parse_posix_command(handler.get("command"))
|
||||||
|
windows = _parse_windows_command(handler.get("commandWindows"))
|
||||||
|
if posix is None or windows is None:
|
||||||
|
legacy = _parse_legacy_commands(handler)
|
||||||
|
posix = legacy
|
||||||
|
windows = legacy
|
||||||
|
if (
|
||||||
|
posix is None
|
||||||
|
or windows is None
|
||||||
|
or not os.path.isabs(posix[0])
|
||||||
|
or not os.path.isabs(posix[1])
|
||||||
|
or not _is_python_executable(posix[0])
|
||||||
|
or not _is_python_executable(windows[0])
|
||||||
|
or not same_path(posix[0], windows[0])
|
||||||
|
or not same_path(posix[1], windows[1])
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return posix[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_agent_id(
|
||||||
|
handler: JsonObject,
|
||||||
|
script_name: str,
|
||||||
|
status_message: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
script_path = _managed_script_path(handler, status_message)
|
||||||
|
if script_path is None or os.path.basename(script_path) != script_name:
|
||||||
|
return None
|
||||||
|
agent_directory = os.path.dirname(script_path)
|
||||||
|
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||||
|
return None
|
||||||
|
agent_id = os.path.basename(agent_directory)
|
||||||
|
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_hooks(value: Any, label: str) -> CodexHooks:
|
||||||
|
if value is None or not isinstance(value, dict):
|
||||||
|
raise ValueError(f'{label} field "hooks" must be an object')
|
||||||
|
hooks: CodexHooks = {}
|
||||||
|
for event, groups in value.items():
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
raise ValueError(f'{label} field "hooks.{event}" must be an array')
|
||||||
|
parsed_groups = []
|
||||||
|
for index, group in enumerate(groups):
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} matcher group hooks.{event}[{index}] must be an object"
|
||||||
|
)
|
||||||
|
handlers = group.get("hooks")
|
||||||
|
if not isinstance(handlers, list) or not all(
|
||||||
|
isinstance(handler, dict) for handler in handlers
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} matcher group hooks.{event}[{index}] must contain a hooks array"
|
||||||
|
)
|
||||||
|
parsed_groups.append(group)
|
||||||
|
hooks[event] = parsed_groups
|
||||||
|
return hooks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_document(file_path: str, raw: str) -> CodexDocument:
|
||||||
|
label = f"Codex user hooks at {file_path}"
|
||||||
|
root = parse_json_object(raw, label)
|
||||||
|
hooks = _read_hooks(root.get("hooks", {}), label)
|
||||||
|
return CodexDocument(True, file_path, root, hooks, raw)
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(file_path: str) -> CodexDocument:
|
||||||
|
return CodexDocument(False, file_path, {"description": OWNED_DESCRIPTION}, {})
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_matcher_group(group: JsonObject) -> bool:
|
||||||
|
return set(group) == {"matcher", "hooks"} and group.get("matcher") == "*"
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_from_groups(
|
||||||
|
groups: List[JsonObject],
|
||||||
|
script_name: str,
|
||||||
|
status_message: str,
|
||||||
|
agent_id: str,
|
||||||
|
) -> List[JsonObject]:
|
||||||
|
result = []
|
||||||
|
for group in groups:
|
||||||
|
handlers = []
|
||||||
|
for handler in group["hooks"]:
|
||||||
|
managed_id = _managed_agent_id(handler, script_name, status_message)
|
||||||
|
remove = managed_id is not None and (
|
||||||
|
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||||
|
)
|
||||||
|
if not remove:
|
||||||
|
handlers.append(handler)
|
||||||
|
if handlers or not _exact_matcher_group(group):
|
||||||
|
result.append({**group, "hooks": handlers})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def remove_managed_hooks(hooks: CodexHooks, agent_id: str = "") -> CodexHooks:
|
||||||
|
result = {event: list(groups) for event, groups in hooks.items()}
|
||||||
|
for event, script_name, status_message in (
|
||||||
|
("PreToolUse", GUARD_SCRIPT, GUARD_STATUS),
|
||||||
|
("PostToolUse", AUDIT_SCRIPT, AUDIT_STATUS),
|
||||||
|
):
|
||||||
|
groups = _remove_from_groups(
|
||||||
|
result.get(event, []),
|
||||||
|
script_name,
|
||||||
|
status_message,
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
if groups:
|
||||||
|
result[event] = groups
|
||||||
|
else:
|
||||||
|
result.pop(event, None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _entirely_managed(document: CodexDocument) -> bool:
|
||||||
|
if (
|
||||||
|
not document.exists
|
||||||
|
or document.root.get("description") != OWNED_DESCRIPTION
|
||||||
|
or not set(document.root).issubset({"description", "hooks"})
|
||||||
|
or not document.hooks
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
count = 0
|
||||||
|
for event, groups in document.hooks.items():
|
||||||
|
contract = {
|
||||||
|
"PreToolUse": (GUARD_SCRIPT, GUARD_STATUS),
|
||||||
|
"PostToolUse": (AUDIT_SCRIPT, AUDIT_STATUS),
|
||||||
|
}.get(event)
|
||||||
|
if contract is None or not groups:
|
||||||
|
return False
|
||||||
|
for group in groups:
|
||||||
|
handlers = group["hooks"]
|
||||||
|
if (
|
||||||
|
not _exact_matcher_group(group)
|
||||||
|
or not handlers
|
||||||
|
or any(
|
||||||
|
_managed_agent_id(handler, contract[0], contract[1]) is None
|
||||||
|
for handler in handlers
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
count += len(handlers)
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
|
def render_document(document: CodexDocument, hooks: CodexHooks) -> RenderedDocument:
|
||||||
|
if not document.exists and not hooks:
|
||||||
|
return RenderedDocument(document, False)
|
||||||
|
if hooks == document.hooks:
|
||||||
|
return RenderedDocument(document, False)
|
||||||
|
if not hooks and _entirely_managed(document):
|
||||||
|
return RenderedDocument(document, True)
|
||||||
|
root = dict(document.root)
|
||||||
|
if hooks:
|
||||||
|
root["hooks"] = hooks
|
||||||
|
else:
|
||||||
|
root.pop("hooks", None)
|
||||||
|
next_source = json.dumps(root, indent=2) + "\n"
|
||||||
|
return RenderedDocument(document, next_source != document.raw, next_source)
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_ids(
|
||||||
|
groups: List[JsonObject],
|
||||||
|
script_name: str,
|
||||||
|
status_message: str,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
result = {}
|
||||||
|
for group in groups:
|
||||||
|
if not _exact_matcher_group(group):
|
||||||
|
continue
|
||||||
|
for handler in group["hooks"]:
|
||||||
|
agent_id = _managed_agent_id(handler, script_name, status_message)
|
||||||
|
if agent_id:
|
||||||
|
result[os.path.normcase(agent_id)] = agent_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_contracts(hooks: CodexHooks) -> List[RuntimeContract]:
|
||||||
|
guards = _managed_ids(hooks.get("PreToolUse", []), GUARD_SCRIPT, GUARD_STATUS)
|
||||||
|
audits = _managed_ids(hooks.get("PostToolUse", []), AUDIT_SCRIPT, AUDIT_STATUS)
|
||||||
|
contracts = []
|
||||||
|
for key, agent_id in guards.items():
|
||||||
|
if key not in audits:
|
||||||
|
continue
|
||||||
|
agent_directory = os.path.join(runtime_root(), agent_id)
|
||||||
|
contracts.append(
|
||||||
|
RuntimeContract(
|
||||||
|
agent_id,
|
||||||
|
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||||
|
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return contracts
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Codex hook source and managed runtime I/O."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from ._managed_files import (
|
||||||
|
MAX_CONFIG_BYTES,
|
||||||
|
MAX_SOURCE_BYTES,
|
||||||
|
physical_directory_exists,
|
||||||
|
physical_file_exists,
|
||||||
|
read_physical_file,
|
||||||
|
)
|
||||||
|
from ._strict_json import JsonObject, parse_json_object
|
||||||
|
from ._transaction import FileChange, source_change, write_changes
|
||||||
|
from .codex_contract import (
|
||||||
|
AGENT_KEY,
|
||||||
|
CONFIG_FILE,
|
||||||
|
CodexDocument,
|
||||||
|
RenderedDocument,
|
||||||
|
RuntimeContract,
|
||||||
|
create_document,
|
||||||
|
parse_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_agent_id(value: Any, expected: str) -> bool:
|
||||||
|
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def codex_home_path() -> str:
|
||||||
|
configured = os.environ.get("CODEX_HOME")
|
||||||
|
if configured is None or configured == "":
|
||||||
|
return os.path.join(os.path.expanduser("~"), ".codex")
|
||||||
|
try:
|
||||||
|
metadata = os.stat(configured)
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Resolve CODEX_HOME at {configured}: {error}") from error
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
raise OSError(f"CODEX_HOME is not a directory: {configured}")
|
||||||
|
try:
|
||||||
|
canonical = os.path.realpath(configured, strict=True)
|
||||||
|
except OSError as error:
|
||||||
|
raise OSError(f"Canonicalize CODEX_HOME at {configured}: {error}") from error
|
||||||
|
if not physical_directory_exists(canonical, "CODEX_HOME"):
|
||||||
|
raise OSError(f"CODEX_HOME is missing: {canonical}")
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
def config_path() -> str:
|
||||||
|
return os.path.join(codex_home_path(), CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def read_document() -> CodexDocument:
|
||||||
|
file_path = config_path()
|
||||||
|
snapshot = read_physical_file(file_path, "Codex user hooks", MAX_SOURCE_BYTES)
|
||||||
|
if snapshot is None:
|
||||||
|
return create_document(file_path)
|
||||||
|
return parse_document(file_path, snapshot.contents)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_hooks_directory(file_path: str) -> None:
|
||||||
|
directory = os.path.dirname(file_path)
|
||||||
|
physical_directory_exists(directory, "Codex hooks directory")
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||||
|
if not rendered.changed:
|
||||||
|
return None
|
||||||
|
return source_change(
|
||||||
|
rendered.document.file_path,
|
||||||
|
"Codex user hooks",
|
||||||
|
rendered.document.raw,
|
||||||
|
rendered.next_source,
|
||||||
|
0o600,
|
||||||
|
MAX_SOURCE_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_change(
|
||||||
|
file_path: str,
|
||||||
|
label: str,
|
||||||
|
next_source: str,
|
||||||
|
mode: int,
|
||||||
|
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||||
|
) -> Optional[FileChange]:
|
||||||
|
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||||
|
return source_change(
|
||||||
|
file_path,
|
||||||
|
label,
|
||||||
|
None if snapshot is None else snapshot.contents,
|
||||||
|
next_source,
|
||||||
|
mode,
|
||||||
|
maximum_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||||
|
snapshot = read_physical_file(
|
||||||
|
file_path,
|
||||||
|
"Elydora runtime config",
|
||||||
|
MAX_CONFIG_BYTES,
|
||||||
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
return None
|
||||||
|
return parse_json_object(
|
||||||
|
snapshot.contents,
|
||||||
|
f"Elydora runtime config at {file_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime_identity(agent_directory: str, agent_id: str) -> bool:
|
||||||
|
config_path_value = os.path.join(agent_directory, "config.json")
|
||||||
|
config = _read_runtime_config(config_path_value)
|
||||||
|
artifacts = [
|
||||||
|
("private.key", "Elydora private key"),
|
||||||
|
("guard.py", "Elydora guard runtime"),
|
||||||
|
("hook.py", "Elydora audit runtime"),
|
||||||
|
("chain-state.json", "Elydora chain state"),
|
||||||
|
("status-cache.json", "Elydora status cache"),
|
||||||
|
("error.log", "Elydora error log"),
|
||||||
|
]
|
||||||
|
artifact_states = [
|
||||||
|
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||||
|
for name, label in artifacts
|
||||||
|
]
|
||||||
|
artifact_exists = any(artifact_states)
|
||||||
|
if config is None:
|
||||||
|
if artifact_exists:
|
||||||
|
raise ValueError(
|
||||||
|
"Elydora runtime identity cannot be verified without config.json: "
|
||||||
|
f"{agent_directory}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
|
||||||
|
config.get("agent_id"),
|
||||||
|
agent_id,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Elydora runtime config identity does not match Codex agent {agent_id}: "
|
||||||
|
f"{config_path_value}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime_tree(
|
||||||
|
runtime_root: str, agent_directory: str, agent_id: str
|
||||||
|
) -> None:
|
||||||
|
if not physical_directory_exists(runtime_root, "Elydora runtime directory"):
|
||||||
|
return
|
||||||
|
if not physical_directory_exists(
|
||||||
|
agent_directory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
validate_runtime_identity(agent_directory, agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||||
|
for contract in contracts:
|
||||||
|
agent_directory = os.path.dirname(contract.guard_path)
|
||||||
|
if not physical_directory_exists(
|
||||||
|
agent_directory,
|
||||||
|
"Elydora agent runtime directory",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||||
|
if (
|
||||||
|
config is None
|
||||||
|
or config.get("agent_name") != AGENT_KEY
|
||||||
|
or not _same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if all(
|
||||||
|
(
|
||||||
|
physical_file_exists(contract.guard_path, "Elydora guard runtime"),
|
||||||
|
physical_file_exists(contract.audit_path, "Elydora audit runtime"),
|
||||||
|
physical_file_exists(
|
||||||
|
os.path.join(agent_directory, "private.key"),
|
||||||
|
"Elydora private key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_codex_changes(changes: List[FileChange], label: str) -> None:
|
||||||
|
write_changes(changes, label)
|
||||||
@@ -9,13 +9,14 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from ._strict_json import JsonObject, parse_json_object
|
||||||
|
|
||||||
|
|
||||||
AGENT_KEY = "cursor"
|
AGENT_KEY = "cursor"
|
||||||
GUARD_SCRIPT = "guard.py"
|
GUARD_SCRIPT = "guard.py"
|
||||||
AUDIT_SCRIPT = "hook.py"
|
AUDIT_SCRIPT = "hook.py"
|
||||||
HOOK_TIMEOUT_SECONDS = 10
|
HOOK_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
JsonObject = Dict[str, Any]
|
|
||||||
CursorHooks = Dict[str, List[JsonObject]]
|
CursorHooks = Dict[str, List[JsonObject]]
|
||||||
|
|
||||||
|
|
||||||
@@ -48,10 +49,6 @@ class _ParsedArgument:
|
|||||||
next_index: int
|
next_index: int
|
||||||
|
|
||||||
|
|
||||||
class _DuplicateKey(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_root() -> str:
|
def runtime_root() -> str:
|
||||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||||
|
|
||||||
@@ -109,7 +106,10 @@ def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
|||||||
|
|
||||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||||
executable = _read_posix_argument(command, 0)
|
executable = _read_posix_argument(command, 0)
|
||||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
if (
|
||||||
|
executable is None
|
||||||
|
or command[executable.next_index : executable.next_index + 1] != " "
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
script = _read_posix_argument(command, executable.next_index + 1)
|
script = _read_posix_argument(command, executable.next_index + 1)
|
||||||
if script is None or script.next_index != len(command):
|
if script is None or script.next_index != len(command):
|
||||||
@@ -142,7 +142,10 @@ def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
|||||||
if not command.startswith("& "):
|
if not command.startswith("& "):
|
||||||
return None
|
return None
|
||||||
executable = _read_powershell_argument(command, 2)
|
executable = _read_powershell_argument(command, 2)
|
||||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
if (
|
||||||
|
executable is None
|
||||||
|
or command[executable.next_index : executable.next_index + 1] != " "
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
script = _read_powershell_argument(command, executable.next_index + 1)
|
script = _read_powershell_argument(command, executable.next_index + 1)
|
||||||
if script is None or command[script.next_index :] != "; exit $LASTEXITCODE":
|
if script is None or command[script.next_index :] != "; exit $LASTEXITCODE":
|
||||||
@@ -196,27 +199,6 @@ def _managed_agent_id(
|
|||||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||||
|
|
||||||
|
|
||||||
def _unique_object(pairs: List[Tuple[str, Any]]) -> JsonObject:
|
|
||||||
result: JsonObject = {}
|
|
||||||
for key, value in pairs:
|
|
||||||
if key in result:
|
|
||||||
raise _DuplicateKey(f'duplicate field "{key}"')
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def parse_json_object(raw: str, label: str) -> JsonObject:
|
|
||||||
try:
|
|
||||||
value = json.loads(raw, object_pairs_hook=_unique_object)
|
|
||||||
except _DuplicateKey as error:
|
|
||||||
raise ValueError(f"Failed to parse {label}: {error}") from error
|
|
||||||
except json.JSONDecodeError as error:
|
|
||||||
raise ValueError(f"Failed to parse {label}: {error}") from error
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError(f"{label} must contain a JSON object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _read_hooks(value: Any, label: str) -> CursorHooks:
|
def _read_hooks(value: Any, label: str) -> CursorHooks:
|
||||||
if value is None:
|
if value is None:
|
||||||
raise ValueError(f'{label} field "hooks" must be an object')
|
raise ValueError(f'{label} field "hooks" must be an object')
|
||||||
@@ -227,9 +209,7 @@ def _read_hooks(value: Any, label: str) -> CursorHooks:
|
|||||||
if not isinstance(handlers, list):
|
if not isinstance(handlers, list):
|
||||||
raise ValueError(f'{label} field "hooks.{event}" must be an array')
|
raise ValueError(f'{label} field "hooks.{event}" must be an array')
|
||||||
if not all(isinstance(handler, dict) for handler in handlers):
|
if not all(isinstance(handler, dict) for handler in handlers):
|
||||||
raise ValueError(
|
raise ValueError(f'{label} field "hooks.{event}" must contain objects')
|
||||||
f'{label} field "hooks.{event}" must contain objects'
|
|
||||||
)
|
|
||||||
hooks[event] = list(handlers)
|
hooks[event] = list(handlers)
|
||||||
return hooks
|
return hooks
|
||||||
|
|
||||||
@@ -344,9 +324,11 @@ def runtime_contracts(hooks: CursorHooks) -> List[RuntimeContract]:
|
|||||||
if key not in audits or key not in failures:
|
if key not in audits or key not in failures:
|
||||||
continue
|
continue
|
||||||
agent_directory = os.path.join(runtime_root(), agent_id)
|
agent_directory = os.path.join(runtime_root(), agent_id)
|
||||||
contracts.append(RuntimeContract(
|
contracts.append(
|
||||||
|
RuntimeContract(
|
||||||
agent_id,
|
agent_id,
|
||||||
os.path.join(agent_directory, GUARD_SCRIPT),
|
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||||
os.path.join(agent_directory, AUDIT_SCRIPT),
|
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return contracts
|
return contracts
|
||||||
@@ -65,15 +65,29 @@ def read_input():
|
|||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
if FAIL_CLOSED:
|
if FAIL_CLOSED:
|
||||||
raise ValueError("Hook input is empty")
|
raise ValueError("Hook input is empty")
|
||||||
|
sys.stderr.write(
|
||||||
|
"[Elydora guard] Hook input is empty; "
|
||||||
|
"enforcement continued in fail-open mode.\\n"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
value = json.loads(raw)
|
value = json.loads(raw)
|
||||||
except (json.JSONDecodeError, ValueError) as error:
|
except (json.JSONDecodeError, ValueError) as error:
|
||||||
if FAIL_CLOSED:
|
if FAIL_CLOSED:
|
||||||
raise ValueError("Hook input is invalid JSON: " + str(error)) from error
|
raise ValueError("Hook input is invalid JSON: " + str(error)) from error
|
||||||
|
sys.stderr.write(
|
||||||
|
"[Elydora guard] Hook input is invalid JSON; "
|
||||||
|
"enforcement continued in fail-open mode: " + str(error) + "\\n"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if FAIL_CLOSED and not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
|
if FAIL_CLOSED:
|
||||||
raise ValueError("Hook input must contain a JSON object")
|
raise ValueError("Hook input must contain a JSON object")
|
||||||
|
sys.stderr.write(
|
||||||
|
"[Elydora guard] Hook input must contain a JSON object; "
|
||||||
|
"enforcement continued in fail-open mode.\\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def read_config():
|
def read_config():
|
||||||
@@ -102,7 +116,6 @@ def read_status_cache():
|
|||||||
STATUS_CACHE_PATH,
|
STATUS_CACHE_PATH,
|
||||||
"Status cache",
|
"Status cache",
|
||||||
MAX_PROTECTED_CONFIG_BYTES,
|
MAX_PROTECTED_CONFIG_BYTES,
|
||||||
require_private_permissions=False,
|
|
||||||
).decode("utf-8")
|
).decode("utf-8")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
@@ -124,34 +137,39 @@ def read_status_cache():
|
|||||||
|
|
||||||
|
|
||||||
def write_status_cache(status):
|
def write_status_cache(status):
|
||||||
token = os.urandom(8).hex()
|
write_protected_json(
|
||||||
temporary_path = STATUS_CACHE_PATH + "." + str(os.getpid()) + "." + token + ".tmp"
|
STATUS_CACHE_PATH,
|
||||||
descriptor = -1
|
"Status cache",
|
||||||
try:
|
{{"status": status, "cached_at": time.time()}},
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
)
|
||||||
descriptor = os.open(temporary_path, flags, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
|
|
||||||
descriptor = -1
|
|
||||||
json.dump({{"status": status, "cached_at": time.time()}}, file)
|
|
||||||
file.flush()
|
|
||||||
os.fsync(file.fileno())
|
|
||||||
os.replace(temporary_path, STATUS_CACHE_PATH)
|
|
||||||
finally:
|
|
||||||
if descriptor >= 0:
|
|
||||||
os.close(descriptor)
|
|
||||||
try:
|
|
||||||
os.remove(temporary_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_remote_status(config):
|
def resolve_remote_status(config):
|
||||||
base_url = config.get("base_url", "https://api.elydora.com")
|
base_url = config.get("base_url", "https://api.elydora.com")
|
||||||
if not isinstance(base_url, str):
|
if not isinstance(base_url, str):
|
||||||
raise ValueError("Agent config base_url must be a string")
|
raise ValueError("Agent config base_url must be a string")
|
||||||
|
try:
|
||||||
parsed = urllib.parse.urlsplit(base_url)
|
parsed = urllib.parse.urlsplit(base_url)
|
||||||
if parsed.scheme not in ("http", "https"):
|
hostname = parsed.hostname
|
||||||
raise ValueError("Agent config base_url must use HTTP or HTTPS")
|
parsed.port
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
"Agent config base_url must be an absolute HTTP or HTTPS URL"
|
||||||
|
) from error
|
||||||
|
invalid_character = "\\\\" in base_url or any(
|
||||||
|
character.isspace() or ord(character) < 32 for character in base_url
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in ("http", "https")
|
||||||
|
or not parsed.netloc
|
||||||
|
or hostname is None
|
||||||
|
or invalid_character
|
||||||
|
):
|
||||||
|
raise ValueError("Agent config base_url must be an absolute HTTP or HTTPS URL")
|
||||||
|
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError(
|
||||||
|
"Agent config base_url must exclude credentials, query parameters, and fragments"
|
||||||
|
)
|
||||||
token = config.get("token", "")
|
token = config.get("token", "")
|
||||||
if not isinstance(token, str):
|
if not isinstance(token, str):
|
||||||
raise ValueError("Agent config token must be a string")
|
raise ValueError("Agent config token must be a string")
|
||||||
|
|||||||
@@ -39,11 +39,8 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
ORG_ID = {org_id!r}
|
|
||||||
AGENT_ID = {agent_id!r}
|
AGENT_ID = {agent_id!r}
|
||||||
AGENT_NAME = {agent_name!r}
|
AGENT_NAME = {agent_name!r}
|
||||||
KID = {kid!r}
|
|
||||||
BASE_URL = {base_url!r}
|
|
||||||
SUCCESS_OUTPUT = {success_output!r}
|
SUCCESS_OUTPUT = {success_output!r}
|
||||||
FAIL_CLOSED = {fail_closed!r}
|
FAIL_CLOSED = {fail_closed!r}
|
||||||
NATIVE_PAYLOAD = {native_payload!r}
|
NATIVE_PAYLOAD = {native_payload!r}
|
||||||
@@ -139,27 +136,16 @@ def generate_uuidv7():
|
|||||||
|
|
||||||
|
|
||||||
def log_error(error):
|
def log_error(error):
|
||||||
descriptor = -1
|
|
||||||
try:
|
try:
|
||||||
message = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
message = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
message += " [elydora-hook] " + repr(error) + "\\n"
|
message += " [elydora-hook] " + repr(error) + "\\n"
|
||||||
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
append_protected_text(ERROR_LOG_PATH, "Error log", message)
|
||||||
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
||||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
descriptor = os.open(ERROR_LOG_PATH, flags, 0o600)
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise ValueError("Error log path is not a physical file: " + ERROR_LOG_PATH)
|
|
||||||
with os.fdopen(descriptor, "a", encoding="utf-8") as file:
|
|
||||||
descriptor = -1
|
|
||||||
file.write(message)
|
|
||||||
if os.name != "nt":
|
|
||||||
os.chmod(ERROR_LOG_PATH, 0o600)
|
|
||||||
except Exception as log_failure:
|
except Exception as log_failure:
|
||||||
sys.stderr.write("[Elydora audit] Failed to write error log: " + str(log_failure) + "\\n")
|
sys.stderr.write(
|
||||||
finally:
|
"[Elydora audit] Failed to write error log: "
|
||||||
if descriptor >= 0:
|
+ str(log_failure)
|
||||||
os.close(descriptor)
|
+ "\\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_chain_state():
|
def read_chain_state():
|
||||||
@@ -168,64 +154,30 @@ def read_chain_state():
|
|||||||
CHAIN_STATE_PATH,
|
CHAIN_STATE_PATH,
|
||||||
"Chain state",
|
"Chain state",
|
||||||
MAX_PROTECTED_CONFIG_BYTES,
|
MAX_PROTECTED_CONFIG_BYTES,
|
||||||
require_private_permissions=False,
|
|
||||||
).decode("utf-8")
|
).decode("utf-8")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return ZERO_CHAIN_HASH
|
return ZERO_CHAIN_HASH
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
if FAIL_CLOSED:
|
|
||||||
raise RuntimeError("Failed to read chain state: " + str(error)) from error
|
raise RuntimeError("Failed to read chain state: " + str(error)) from error
|
||||||
log_error(error)
|
|
||||||
return ZERO_CHAIN_HASH
|
|
||||||
try:
|
try:
|
||||||
state = json.loads(raw)
|
state = json.loads(raw)
|
||||||
except (json.JSONDecodeError, ValueError) as error:
|
except (json.JSONDecodeError, ValueError) as error:
|
||||||
if FAIL_CLOSED:
|
|
||||||
raise RuntimeError("Chain state is invalid JSON: " + str(error)) from error
|
raise RuntimeError("Chain state is invalid JSON: " + str(error)) from error
|
||||||
log_error(error)
|
|
||||||
return ZERO_CHAIN_HASH
|
|
||||||
previous = state.get("prev_chain_hash") if isinstance(state, dict) else None
|
previous = state.get("prev_chain_hash") if isinstance(state, dict) else None
|
||||||
if not isinstance(previous, str) or re.fullmatch(r"[A-Za-z0-9_-]{{43}}", previous) is None:
|
if not isinstance(previous, str) or re.fullmatch(r"[A-Za-z0-9_-]{{43}}", previous) is None:
|
||||||
error = ValueError("Chain state contains an invalid prev_chain_hash")
|
raise ValueError("Chain state contains an invalid prev_chain_hash")
|
||||||
if FAIL_CLOSED:
|
|
||||||
raise error
|
|
||||||
log_error(error)
|
|
||||||
return ZERO_CHAIN_HASH
|
|
||||||
return previous
|
return previous
|
||||||
|
|
||||||
|
|
||||||
def write_chain_state(chain_hash):
|
def write_chain_state(chain_hash):
|
||||||
token = os.urandom(8).hex()
|
write_protected_json(
|
||||||
temporary_path = CHAIN_STATE_PATH + "." + str(os.getpid()) + "." + token + ".tmp"
|
CHAIN_STATE_PATH,
|
||||||
descriptor = -1
|
"Chain state",
|
||||||
try:
|
{{"prev_chain_hash": chain_hash}},
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
)
|
||||||
descriptor = os.open(temporary_path, flags, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
|
|
||||||
descriptor = -1
|
|
||||||
json.dump({{"prev_chain_hash": chain_hash}}, file)
|
|
||||||
file.flush()
|
|
||||||
os.fsync(file.fileno())
|
|
||||||
os.replace(temporary_path, CHAIN_STATE_PATH)
|
|
||||||
finally:
|
|
||||||
if descriptor >= 0:
|
|
||||||
os.close(descriptor)
|
|
||||||
try:
|
|
||||||
os.remove(temporary_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def read_runtime_config():
|
def read_runtime_config():
|
||||||
if not FAIL_CLOSED:
|
|
||||||
return {{
|
|
||||||
"org_id": ORG_ID,
|
|
||||||
"agent_id": AGENT_ID,
|
|
||||||
"agent_name": AGENT_NAME,
|
|
||||||
"kid": KID,
|
|
||||||
"base_url": BASE_URL,
|
|
||||||
"token": "",
|
|
||||||
}}
|
|
||||||
try:
|
try:
|
||||||
raw = read_protected_file(
|
raw = read_protected_file(
|
||||||
CONFIG_PATH,
|
CONFIG_PATH,
|
||||||
@@ -237,7 +189,9 @@ def read_runtime_config():
|
|||||||
raise RuntimeError("Failed to read agent config: " + str(error)) from error
|
raise RuntimeError("Failed to read agent config: " + str(error)) from error
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
raise ValueError("Agent config must contain a JSON object")
|
raise ValueError("Agent config must contain a JSON object")
|
||||||
if config.get("agent_id") != AGENT_ID or config.get("agent_name") != AGENT_NAME:
|
if config.get("agent_id") != AGENT_ID:
|
||||||
|
raise ValueError("Agent config identity does not match the generated audit hook")
|
||||||
|
if AGENT_NAME and config.get("agent_name") != AGENT_NAME:
|
||||||
raise ValueError("Agent config identity does not match the generated audit hook")
|
raise ValueError("Agent config identity does not match the generated audit hook")
|
||||||
for field in ("org_id", "agent_id", "kid"):
|
for field in ("org_id", "agent_id", "kid"):
|
||||||
if not isinstance(config.get(field), str) or not config[field]:
|
if not isinstance(config.get(field), str) or not config[field]:
|
||||||
@@ -245,8 +199,28 @@ def read_runtime_config():
|
|||||||
base_url = config.get("base_url", "https://api.elydora.com")
|
base_url = config.get("base_url", "https://api.elydora.com")
|
||||||
if not isinstance(base_url, str):
|
if not isinstance(base_url, str):
|
||||||
raise ValueError("Agent config base_url must be a string")
|
raise ValueError("Agent config base_url must be a string")
|
||||||
if urllib.parse.urlsplit(base_url).scheme not in ("http", "https"):
|
try:
|
||||||
raise ValueError("Agent config base_url must use HTTP or HTTPS")
|
parsed = urllib.parse.urlsplit(base_url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
parsed.port
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
"Agent config base_url must be an absolute HTTP or HTTPS URL"
|
||||||
|
) from error
|
||||||
|
invalid_character = "\\\\" in base_url or any(
|
||||||
|
character.isspace() or ord(character) < 32 for character in base_url
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in ("http", "https")
|
||||||
|
or not parsed.netloc
|
||||||
|
or hostname is None
|
||||||
|
or invalid_character
|
||||||
|
):
|
||||||
|
raise ValueError("Agent config base_url must be an absolute HTTP or HTTPS URL")
|
||||||
|
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError(
|
||||||
|
"Agent config base_url must exclude credentials, query parameters, and fragments"
|
||||||
|
)
|
||||||
if not isinstance(config.get("token", ""), str):
|
if not isinstance(config.get("token", ""), str):
|
||||||
raise ValueError("Agent config token must be a string")
|
raise ValueError("Agent config token must be a string")
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
"""Protected runtime-file reader embedded in generated hook scripts."""
|
"""Protected runtime-file primitives embedded in generated hook scripts."""
|
||||||
|
|
||||||
PROTECTED_RUNTIME_READER = r'''
|
PROTECTED_RUNTIME_READER = r"""
|
||||||
MAX_PROTECTED_SECRET_BYTES = 64 * 1024
|
MAX_PROTECTED_SECRET_BYTES = 64 * 1024
|
||||||
MAX_PROTECTED_CONFIG_BYTES = 512 * 1024
|
MAX_PROTECTED_CONFIG_BYTES = 512 * 1024
|
||||||
|
MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_protected_metadata(metadata, file_path, label, max_bytes):
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise ValueError(label + " path is not a physical file: " + file_path)
|
||||||
|
if metadata.st_size > max_bytes:
|
||||||
|
raise ValueError(label + " exceeds the size limit: " + file_path)
|
||||||
|
if os.name != "nt" and metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
raise PermissionError(label + " permissions are too broad: " + file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_protected_file(file_path, label, max_bytes):
|
||||||
|
metadata = os.lstat(file_path)
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise ValueError(label + " path is not a physical file: " + file_path)
|
||||||
|
validate_protected_metadata(metadata, file_path, label, max_bytes)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
def read_protected_file(
|
def read_protected_file(
|
||||||
file_path,
|
file_path,
|
||||||
label,
|
label,
|
||||||
max_bytes=MAX_PROTECTED_SECRET_BYTES,
|
max_bytes=MAX_PROTECTED_SECRET_BYTES,
|
||||||
require_private_permissions=True,
|
|
||||||
):
|
):
|
||||||
before = os.lstat(file_path)
|
before = inspect_protected_file(file_path, label, max_bytes)
|
||||||
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
|
|
||||||
raise ValueError(label + " path is not a physical file: " + file_path)
|
|
||||||
if before.st_size > max_bytes:
|
|
||||||
raise ValueError(label + " exceeds the size limit: " + file_path)
|
|
||||||
if require_private_permissions and os.name != "nt" and before.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
||||||
raise PermissionError(label + " permissions are too broad: " + 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 = os.open(file_path, flags)
|
descriptor = os.open(file_path, flags)
|
||||||
try:
|
try:
|
||||||
after = os.fstat(descriptor)
|
after = os.fstat(descriptor)
|
||||||
if not stat.S_ISREG(after.st_mode):
|
validate_protected_metadata(after, file_path, label, max_bytes)
|
||||||
raise ValueError(label + " path is not a physical file: " + file_path)
|
|
||||||
if after.st_size > max_bytes:
|
|
||||||
raise ValueError(label + " exceeds the size limit: " + file_path)
|
|
||||||
if require_private_permissions and os.name != "nt" and after.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
||||||
raise PermissionError(label + " permissions are too broad: " + file_path)
|
|
||||||
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||||
raise OSError(label + " changed while opening: " + file_path)
|
raise OSError(label + " changed while opening: " + file_path)
|
||||||
with os.fdopen(descriptor, "rb") as file:
|
with os.fdopen(descriptor, "rb") as file:
|
||||||
@@ -41,4 +46,112 @@ def read_protected_file(
|
|||||||
if len(value) > max_bytes:
|
if len(value) > max_bytes:
|
||||||
raise ValueError(label + " exceeds the size limit: " + file_path)
|
raise ValueError(label + " exceeds the size limit: " + file_path)
|
||||||
return value
|
return value
|
||||||
'''
|
|
||||||
|
|
||||||
|
def open_protected_append(file_path, label, max_bytes):
|
||||||
|
for _attempt in range(3):
|
||||||
|
try:
|
||||||
|
before = inspect_protected_file(file_path, label, max_bytes)
|
||||||
|
except FileNotFoundError:
|
||||||
|
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_EXCL
|
||||||
|
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||||
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(file_path, flags, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
validate_protected_metadata(
|
||||||
|
os.fstat(descriptor),
|
||||||
|
file_path,
|
||||||
|
label,
|
||||||
|
max_bytes,
|
||||||
|
)
|
||||||
|
return descriptor
|
||||||
|
except Exception:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
|
flags = os.O_WRONLY | os.O_APPEND
|
||||||
|
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||||
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(file_path, flags)
|
||||||
|
try:
|
||||||
|
after = os.fstat(descriptor)
|
||||||
|
validate_protected_metadata(after, file_path, label, max_bytes)
|
||||||
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||||
|
raise OSError(label + " changed while opening: " + file_path)
|
||||||
|
return descriptor
|
||||||
|
except Exception:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
raise OSError(label + " changed repeatedly while opening: " + file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def append_protected_text(
|
||||||
|
file_path,
|
||||||
|
label,
|
||||||
|
text,
|
||||||
|
max_bytes=MAX_PROTECTED_LOG_BYTES,
|
||||||
|
):
|
||||||
|
encoded = text.encode("utf-8")
|
||||||
|
descriptor = open_protected_append(file_path, label, max_bytes)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if metadata.st_size + len(encoded) > max_bytes:
|
||||||
|
raise ValueError(label + " exceeds the size limit: " + file_path)
|
||||||
|
offset = 0
|
||||||
|
while offset < len(encoded):
|
||||||
|
written = os.write(descriptor, encoded[offset:])
|
||||||
|
if written <= 0:
|
||||||
|
raise OSError(label + " write made no progress: " + file_path)
|
||||||
|
offset += written
|
||||||
|
os.fsync(descriptor)
|
||||||
|
if os.name != "nt":
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def write_protected_json(file_path, label, value):
|
||||||
|
encoded = json.dumps(value, separators=(",", ":")).encode("utf-8")
|
||||||
|
if len(encoded) > MAX_PROTECTED_CONFIG_BYTES:
|
||||||
|
raise ValueError(label + " exceeds the size limit: " + file_path)
|
||||||
|
temporary_path = (
|
||||||
|
file_path
|
||||||
|
+ "."
|
||||||
|
+ str(os.getpid())
|
||||||
|
+ "."
|
||||||
|
+ os.urandom(8).hex()
|
||||||
|
+ ".tmp"
|
||||||
|
)
|
||||||
|
descriptor = -1
|
||||||
|
try:
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||||
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(temporary_path, flags, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as file:
|
||||||
|
descriptor = -1
|
||||||
|
file.write(encoded)
|
||||||
|
file.flush()
|
||||||
|
os.fsync(file.fileno())
|
||||||
|
if os.name != "nt":
|
||||||
|
os.chmod(temporary_path, 0o600)
|
||||||
|
try:
|
||||||
|
inspect_protected_file(
|
||||||
|
file_path,
|
||||||
|
label,
|
||||||
|
MAX_PROTECTED_CONFIG_BYTES,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
os.replace(temporary_path, file_path)
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
os.remove(temporary_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
"""
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins.codex import CodexPlugin
|
||||||
|
from elydora.utils import base64url_encode
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_ID = "agent-1"
|
||||||
|
MISSING = object()
|
||||||
|
VALID_PRIVATE_KEY = base64url_encode(bytes(range(32)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CodexFixture:
|
||||||
|
root_dir: Path
|
||||||
|
home_dir: Path
|
||||||
|
project_dir: Path
|
||||||
|
config_path: Path
|
||||||
|
agent_dir: Path
|
||||||
|
guard_path: Path
|
||||||
|
hook_path: Path
|
||||||
|
runtime_config_path: Path
|
||||||
|
private_key_path: Path
|
||||||
|
plugin: CodexPlugin
|
||||||
|
config: dict[str, str]
|
||||||
|
|
||||||
|
def install(self) -> None:
|
||||||
|
self.plugin.install(self.config)
|
||||||
|
|
||||||
|
def settings(self, path: Path | None = None) -> dict[str, Any]:
|
||||||
|
return json.loads((path or self.config_path).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class ElydoraApiHandler(BaseHTTPRequestHandler):
|
||||||
|
agent_status: Any = "active"
|
||||||
|
get_status = 200
|
||||||
|
post_status = 201
|
||||||
|
requests: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls) -> None:
|
||||||
|
cls.agent_status = "active"
|
||||||
|
cls.get_status = 200
|
||||||
|
cls.post_status = 201
|
||||||
|
cls.requests = []
|
||||||
|
|
||||||
|
def _respond(self, status: int, value: object) -> None:
|
||||||
|
body = json.dumps(value).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
type(self).requests.append(
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": self.path,
|
||||||
|
"authorization": self.headers.get("Authorization"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._respond(
|
||||||
|
type(self).get_status,
|
||||||
|
{"agent": {"status": type(self).agent_status}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
type(self).requests.append(
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": self.path,
|
||||||
|
"authorization": self.headers.get("Authorization"),
|
||||||
|
"raw": raw,
|
||||||
|
"json": json.loads(raw),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._respond(type(self).post_status, {"operation": {"accepted": True}})
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def start_api_server() -> ThreadingHTTPServer:
|
||||||
|
ElydoraApiHandler.reset()
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), ElydoraApiHandler)
|
||||||
|
Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def server_base_url(server: ThreadingHTTPServer) -> str:
|
||||||
|
return f"http://127.0.0.1:{server.server_port}"
|
||||||
|
|
||||||
|
|
||||||
|
def write_text(path: Path, source: str, mode: int = 0o600) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(source, encoding="utf-8")
|
||||||
|
os.chmod(path, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, value: object, mode: int = 0o600) -> None:
|
||||||
|
source = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
||||||
|
write_text(path, source, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_fixture(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
existing_config: object = MISSING,
|
||||||
|
base_url: str = "http://127.0.0.1:9",
|
||||||
|
) -> CodexFixture:
|
||||||
|
home_dir = tmp_path / "home with spaces and 'quote %ELYDORA_HOOK_PATH%"
|
||||||
|
project_dir = tmp_path / "project with spaces"
|
||||||
|
config_path = home_dir / ".codex" / "hooks.json"
|
||||||
|
agent_dir = home_dir / ".elydora" / AGENT_ID
|
||||||
|
guard_path = agent_dir / "guard.py"
|
||||||
|
hook_path = agent_dir / "hook.py"
|
||||||
|
runtime_config_path = agent_dir / "config.json"
|
||||||
|
private_key_path = agent_dir / "private.key"
|
||||||
|
monkeypatch.setenv("HOME", str(home_dir))
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(home_dir))
|
||||||
|
monkeypatch.setenv("CODEX_HOME", "")
|
||||||
|
monkeypatch.setenv("ELYDORA_HOOK_PATH", str(tmp_path / "expanded"))
|
||||||
|
project_dir.mkdir(parents=True)
|
||||||
|
agent_dir.mkdir(parents=True)
|
||||||
|
if existing_config is not MISSING:
|
||||||
|
write_json(config_path, existing_config)
|
||||||
|
return CodexFixture(
|
||||||
|
root_dir=tmp_path,
|
||||||
|
home_dir=home_dir,
|
||||||
|
project_dir=project_dir,
|
||||||
|
config_path=config_path,
|
||||||
|
agent_dir=agent_dir,
|
||||||
|
guard_path=guard_path,
|
||||||
|
hook_path=hook_path,
|
||||||
|
runtime_config_path=runtime_config_path,
|
||||||
|
private_key_path=private_key_path,
|
||||||
|
plugin=CodexPlugin(),
|
||||||
|
config={
|
||||||
|
"org_id": "org-1",
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"agent_name": "codex",
|
||||||
|
"private_key": VALID_PRIVATE_KEY,
|
||||||
|
"kid": "key-1",
|
||||||
|
"token": "token-1",
|
||||||
|
"base_url": base_url,
|
||||||
|
"guard_script_path": str(guard_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def managed_handler(
|
||||||
|
settings: Mapping[str, Any],
|
||||||
|
event: str,
|
||||||
|
status_message: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
for group in settings["hooks"][event]:
|
||||||
|
for handler in group["hooks"]:
|
||||||
|
if handler.get("statusMessage") == status_message:
|
||||||
|
return handler
|
||||||
|
raise AssertionError(f"handler {status_message!r} not found")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_native_handler(handler: Mapping[str, Any], status_message: str) -> None:
|
||||||
|
assert set(handler) == {
|
||||||
|
"type",
|
||||||
|
"command",
|
||||||
|
"commandWindows",
|
||||||
|
"timeout",
|
||||||
|
"statusMessage",
|
||||||
|
}
|
||||||
|
assert handler["type"] == "command"
|
||||||
|
assert handler["timeout"] == 10
|
||||||
|
assert handler["statusMessage"] == status_message
|
||||||
|
assert str(handler["command"]).startswith("'")
|
||||||
|
assert str(handler["commandWindows"]).startswith('"')
|
||||||
|
assert " -EncodedCommand " in str(handler["commandWindows"])
|
||||||
|
|
||||||
|
|
||||||
|
def run_handler(
|
||||||
|
handler: Mapping[str, Any],
|
||||||
|
payload: bytes,
|
||||||
|
fixture: CodexFixture,
|
||||||
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
command_key = "commandWindows" if os.name == "nt" else "command"
|
||||||
|
return subprocess.run(
|
||||||
|
str(handler[command_key]),
|
||||||
|
shell=True,
|
||||||
|
input=payload,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
cwd=fixture.project_dir,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"HOME": str(fixture.home_dir),
|
||||||
|
"USERPROFILE": str(fixture.home_dir),
|
||||||
|
"CODEX_HOME": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_handler(script_path: Path, status_message: str) -> dict[str, Any]:
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
arguments = [sys.executable, str(script_path)]
|
||||||
|
return {
|
||||||
|
"type": "command",
|
||||||
|
"command": f"{shlex.quote(arguments[0])} {shlex.quote(arguments[1])}",
|
||||||
|
"commandWindows": subprocess.list2cmdline(arguments),
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": status_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def symlink_or_skip(target: Path, link: Path, *, directory: bool = False) -> None:
|
||||||
|
try:
|
||||||
|
link.symlink_to(target, target_is_directory=directory)
|
||||||
|
except OSError as error:
|
||||||
|
pytest.skip(f"Symbolic links unavailable: {error}")
|
||||||
+246
-334
@@ -1,125 +1,31 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from elydora.plugins import codex
|
from codex_support import (
|
||||||
from elydora.plugins.base import InstallConfig
|
AGENT_ID,
|
||||||
|
VALID_PRIVATE_KEY,
|
||||||
|
assert_native_handler,
|
||||||
|
legacy_handler,
|
||||||
|
managed_handler,
|
||||||
|
prepare_fixture,
|
||||||
|
symlink_or_skip,
|
||||||
|
write_json,
|
||||||
|
write_text,
|
||||||
|
)
|
||||||
|
from elydora.plugins.codex_contract import (
|
||||||
|
AUDIT_STATUS,
|
||||||
|
GUARD_STATUS,
|
||||||
|
build_handler,
|
||||||
|
)
|
||||||
from elydora.plugins.registry import SUPPORTED_AGENTS
|
from elydora.plugins.registry import SUPPORTED_AGENTS
|
||||||
|
|
||||||
|
|
||||||
AGENT_ID = "agent-1"
|
def test_codex_registry_points_at_native_user_hooks() -> None:
|
||||||
MISSING = object()
|
|
||||||
GUARD_STATUS = "Checking Elydora agent state"
|
|
||||||
AUDIT_STATUS = "Recording Elydora tool use"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CodexFixture:
|
|
||||||
plugin: codex.CodexPlugin
|
|
||||||
config: InstallConfig
|
|
||||||
home_dir: Path
|
|
||||||
agent_dir: Path
|
|
||||||
config_path: Path
|
|
||||||
guard_path: Path
|
|
||||||
hook_path: Path
|
|
||||||
runtime_config_path: Path
|
|
||||||
private_key_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def write_json_or_text(path: Path, value: Any) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
content = value if isinstance(value, str) else json.dumps(value, indent=2)
|
|
||||||
path.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_fixture(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
existing_settings: Any = MISSING,
|
|
||||||
create_guard: bool = True,
|
|
||||||
) -> CodexFixture:
|
|
||||||
home_dir = tmp_path / "home with spaces"
|
|
||||||
elydora_dir = home_dir / ".elydora"
|
|
||||||
agent_dir = elydora_dir / AGENT_ID
|
|
||||||
config_path = home_dir / ".codex" / "hooks.json"
|
|
||||||
agent_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
guard_path = agent_dir / "guard.py"
|
|
||||||
if create_guard:
|
|
||||||
guard_path.write_text(
|
|
||||||
"import sys\nsys.stderr.write('Agent is frozen by Elydora.')\n"
|
|
||||||
"raise SystemExit(2)\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
if existing_settings is not MISSING:
|
|
||||||
write_json_or_text(config_path, existing_settings)
|
|
||||||
|
|
||||||
monkeypatch.setattr(codex, "ELYDORA_DIR", str(elydora_dir))
|
|
||||||
monkeypatch.setattr(codex, "CONFIG_PATH", str(config_path))
|
|
||||||
config: InstallConfig = {
|
|
||||||
"agent_id": AGENT_ID,
|
|
||||||
"agent_name": "codex",
|
|
||||||
"org_id": "org-1",
|
|
||||||
"private_key": "test-key",
|
|
||||||
"kid": "kid-1",
|
|
||||||
"base_url": "https://api.elydora.test",
|
|
||||||
"guard_script_path": str(guard_path),
|
|
||||||
}
|
|
||||||
return CodexFixture(
|
|
||||||
plugin=codex.CodexPlugin(),
|
|
||||||
config=config,
|
|
||||||
home_dir=home_dir,
|
|
||||||
agent_dir=agent_dir,
|
|
||||||
config_path=config_path,
|
|
||||||
guard_path=guard_path,
|
|
||||||
hook_path=agent_dir / "hook.py",
|
|
||||||
runtime_config_path=agent_dir / "config.json",
|
|
||||||
private_key_path=agent_dir / "private.key",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def find_handler(
|
|
||||||
settings: dict[str, Any],
|
|
||||||
event: str,
|
|
||||||
status_message: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
for group in settings["hooks"][event]:
|
|
||||||
for handler in group["hooks"]:
|
|
||||||
if handler.get("statusMessage") == status_message:
|
|
||||||
return handler
|
|
||||||
raise AssertionError(f"handler {status_message!r} not found")
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(
|
|
||||||
command: str,
|
|
||||||
home_dir: Path,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
"HOME": str(home_dir),
|
|
||||||
"USERPROFILE": str(home_dir),
|
|
||||||
}
|
|
||||||
return subprocess.run(
|
|
||||||
command,
|
|
||||||
shell=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
env=env,
|
|
||||||
input=json.dumps(payload),
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_codex_registry_points_at_global_hooks_contract() -> None:
|
|
||||||
assert SUPPORTED_AGENTS["codex"] == {
|
assert SUPPORTED_AGENTS["codex"] == {
|
||||||
"name": "OpenAI Codex",
|
"name": "OpenAI Codex",
|
||||||
"hook_event": "PreToolUse/PostToolUse",
|
"hook_event": "PreToolUse/PostToolUse",
|
||||||
@@ -127,285 +33,291 @@ def test_codex_registry_points_at_global_hooks_contract() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_install_preserves_existing_hooks_and_is_idempotent(
|
def test_codex_follows_and_canonicalizes_codex_home(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
target = tmp_path / "custom Codex home"
|
||||||
|
configured = tmp_path / "codex-home-link"
|
||||||
|
target.mkdir()
|
||||||
|
try:
|
||||||
|
configured.symlink_to(target, target_is_directory=True)
|
||||||
|
except OSError:
|
||||||
|
configured = target
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(configured))
|
||||||
|
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
expected = Path(os.path.realpath(target)) / "hooks.json"
|
||||||
|
assert expected.is_file()
|
||||||
|
assert fixture.config_path.exists() is False
|
||||||
|
status = fixture.plugin.status()
|
||||||
|
assert status["installed"] is True
|
||||||
|
assert status["details"] == f"Config: {expected}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["missing", "file"])
|
||||||
|
def test_codex_rejects_invalid_codex_home_before_writes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
configured = tmp_path / f"{kind}-codex-home"
|
||||||
|
if kind == "file":
|
||||||
|
write_text(configured, "not a directory")
|
||||||
|
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(configured))
|
||||||
|
with pytest.raises(OSError, match="CODEX_HOME"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert list(fixture.agent_dir.iterdir()) == []
|
||||||
|
assert fixture.config_path.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_preserves_sources_migrates_legacy_and_is_idempotent(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
monkeypatch,
|
write_json(
|
||||||
tmp_path,
|
fixture.config_path,
|
||||||
existing_settings={
|
{
|
||||||
"description": "Workspace hooks",
|
"description": "User hooks",
|
||||||
|
"metadata": {"owner": "user"},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"SessionStart": [{
|
"SessionStart": [
|
||||||
"hooks": [{"type": "command", "command": "existing-command"}],
|
{"matcher": "*", "hooks": [{"type": "command", "command": "keep"}]}
|
||||||
}],
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [legacy_handler(fixture.guard_path, GUARD_STATUS)],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [legacy_handler(fixture.hook_path, AUDIT_STATUS)],
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fixture.plugin.install(fixture.config)
|
fixture.install()
|
||||||
fixture.plugin.install(fixture.config)
|
first = fixture.config_path.read_text(encoding="utf-8")
|
||||||
|
fixture.install()
|
||||||
|
second = fixture.config_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "run /hooks to review and trust" in capsys.readouterr().out
|
assert first == second
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
assert "approve both Elydora command hooks" in capsys.readouterr().out
|
||||||
assert settings["description"] == "Workspace hooks"
|
settings = fixture.settings()
|
||||||
assert settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "existing-command"
|
assert settings["description"] == "User hooks"
|
||||||
|
assert settings["metadata"] == {"owner": "user"}
|
||||||
|
assert settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "keep"
|
||||||
assert len(settings["hooks"]["PreToolUse"]) == 1
|
assert len(settings["hooks"]["PreToolUse"]) == 1
|
||||||
assert len(settings["hooks"]["PostToolUse"]) == 1
|
assert len(settings["hooks"]["PostToolUse"]) == 1
|
||||||
assert settings["hooks"]["PreToolUse"][0]["matcher"] == "*"
|
assert_native_handler(
|
||||||
guard = find_handler(settings, "PreToolUse", GUARD_STATUS)
|
managed_handler(settings, "PreToolUse", GUARD_STATUS),
|
||||||
assert guard["type"] == "command"
|
GUARD_STATUS,
|
||||||
assert guard["timeout"] == 10
|
)
|
||||||
assert isinstance(guard["command"], str)
|
assert_native_handler(
|
||||||
assert isinstance(guard["commandWindows"], str)
|
managed_handler(settings, "PostToolUse", AUDIT_STATUS),
|
||||||
|
AUDIT_STATUS,
|
||||||
|
)
|
||||||
|
runtime = json.loads(fixture.runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
assert runtime["agent_name"] == "codex"
|
||||||
|
assert runtime["token"] == "token-1"
|
||||||
|
assert fixture.private_key_path.read_text(encoding="utf-8") == VALID_PRIVATE_KEY
|
||||||
|
assert fixture.guard_path.is_file()
|
||||||
|
assert fixture.hook_path.is_file()
|
||||||
|
|
||||||
|
|
||||||
def test_commands_block_frozen_agents_and_forward_official_payload(
|
@pytest.mark.parametrize(
|
||||||
|
("source", "error_pattern"),
|
||||||
|
[
|
||||||
|
('{"hooks":{},"hooks":{}}', "duplicate field"),
|
||||||
|
('{"hooks":{},}', "parse Codex user hooks"),
|
||||||
|
('{"hooks":{/*comment*/}}', "parse Codex user hooks"),
|
||||||
|
("[]", "must contain a JSON object"),
|
||||||
|
('{"hooks":null}', 'field "hooks" must be an object'),
|
||||||
|
('{"hooks":{"PreToolUse":null}}', 'hooks.PreToolUse" must be an array'),
|
||||||
|
(
|
||||||
|
'{"hooks":{"PreToolUse":[{"hooks":null}]}}',
|
||||||
|
"must contain a hooks array",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'{"hooks":{"PreToolUse":[{"hooks":[null]}]}}',
|
||||||
|
"must contain a hooks array",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_install_preserves_malformed_and_ambiguous_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
source: str,
|
||||||
|
error_pattern: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path, existing_config=source)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=error_pattern):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert fixture.config_path.read_text(encoding="utf-8") == source
|
||||||
|
assert list(fixture.agent_dir.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_requires_exact_pair_identity_and_runtime_secrets(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
fixture.plugin.install(fixture.config)
|
fixture.install()
|
||||||
capture_path = tmp_path / "captured-event.json"
|
assert fixture.plugin.status()["installed"] is True
|
||||||
fixture.hook_path.write_text(
|
|
||||||
"import pathlib, sys\n"
|
|
||||||
f"pathlib.Path({str(capture_path)!r}).write_text(sys.stdin.read(), encoding='utf-8')\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
||||||
command_key = "commandWindows" if os.name == "nt" else "command"
|
|
||||||
payload = {
|
|
||||||
"hook_event_name": "PreToolUse",
|
|
||||||
"session_id": "session-1",
|
|
||||||
"turn_id": "turn-1",
|
|
||||||
"transcript_path": None,
|
|
||||||
"cwd": str(fixture.home_dir),
|
|
||||||
"model": "gpt-5",
|
|
||||||
"permission_mode": "default",
|
|
||||||
"tool_name": "Bash",
|
|
||||||
"tool_use_id": "call-1",
|
|
||||||
"tool_input": {"command": "echo test"},
|
|
||||||
}
|
|
||||||
|
|
||||||
guard = find_handler(settings, "PreToolUse", GUARD_STATUS)
|
fixture.private_key_path.unlink()
|
||||||
guard_result = run_command(guard[command_key], fixture.home_dir, payload)
|
status = fixture.plugin.status()
|
||||||
assert guard_result.returncode == 2
|
assert status["installed"] is False
|
||||||
assert "Agent is frozen by Elydora" in guard_result.stderr
|
assert "runtime files missing" in status["details"]
|
||||||
|
|
||||||
payload["hook_event_name"] = "PostToolUse"
|
fixture.install()
|
||||||
payload["tool_response"] = {"output": "test"}
|
settings = fixture.settings()
|
||||||
audit = find_handler(settings, "PostToolUse", AUDIT_STATUS)
|
guard = managed_handler(settings, "PreToolUse", GUARD_STATUS)
|
||||||
audit_result = run_command(audit[command_key], fixture.home_dir, payload)
|
guard["extra"] = True
|
||||||
assert audit_result.returncode == 0
|
write_json(fixture.config_path, settings)
|
||||||
assert json.loads(capture_path.read_text(encoding="utf-8")) == payload
|
assert fixture.plugin.status()["installed"] is False
|
||||||
|
|
||||||
|
fixture.install()
|
||||||
|
runtime = json.loads(fixture.runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
runtime["agent_id"] = "another-agent"
|
||||||
|
write_json(fixture.runtime_config_path, runtime)
|
||||||
|
assert fixture.plugin.status()["installed"] is False
|
||||||
|
|
||||||
def test_status_requires_both_runtimes_and_uninstall_preserves_other_hooks(
|
write_text(fixture.runtime_config_path, "{ malformed")
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
existing_handler = {"type": "command", "command": "existing-command"}
|
|
||||||
fixture = prepare_fixture(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path,
|
|
||||||
existing_settings={
|
|
||||||
"hooks": {"PreToolUse": [{"hooks": [existing_handler]}]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
fixture.plugin.install(fixture.config)
|
|
||||||
assert fixture.plugin.status() == {
|
|
||||||
"installed": True,
|
|
||||||
"agent": "codex",
|
|
||||||
"details": f"Config: {fixture.config_path}",
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture.guard_path.unlink()
|
|
||||||
assert fixture.plugin.status() == {
|
|
||||||
"installed": False,
|
|
||||||
"agent": "codex",
|
|
||||||
"details": f"Configured at {fixture.config_path}; runtime scripts missing",
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture.plugin.uninstall(AGENT_ID)
|
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
||||||
assert settings["hooks"]["PreToolUse"] == [{"hooks": [existing_handler]}]
|
|
||||||
assert settings["hooks"]["PostToolUse"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_preserves_user_handlers_with_matching_status_text(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
user_guard = {
|
|
||||||
"type": "command",
|
|
||||||
"command": "user-guard",
|
|
||||||
"statusMessage": GUARD_STATUS,
|
|
||||||
}
|
|
||||||
user_audit = {
|
|
||||||
"type": "command",
|
|
||||||
"command": "user-audit",
|
|
||||||
"statusMessage": AUDIT_STATUS,
|
|
||||||
}
|
|
||||||
fixture = prepare_fixture(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path,
|
|
||||||
existing_settings={
|
|
||||||
"hooks": {
|
|
||||||
"PreToolUse": [{"matcher": "Bash", "hooks": [user_guard]}],
|
|
||||||
"PostToolUse": [{"matcher": "Bash", "hooks": [user_audit]}],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fixture.plugin.install(fixture.config)
|
|
||||||
|
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
||||||
assert settings["hooks"]["PreToolUse"][0]["hooks"][0] == user_guard
|
|
||||||
assert settings["hooks"]["PostToolUse"][0]["hooks"][0] == user_audit
|
|
||||||
assert len(settings["hooks"]["PreToolUse"]) == 2
|
|
||||||
assert len(settings["hooks"]["PostToolUse"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_rejects_missing_guard_before_runtime_or_config_writes(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path, create_guard=False)
|
|
||||||
|
|
||||||
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
|
|
||||||
fixture.plugin.install(fixture.config)
|
|
||||||
|
|
||||||
assert fixture.config_path.exists() is False
|
|
||||||
assert fixture.hook_path.exists() is False
|
|
||||||
assert fixture.runtime_config_path.exists() is False
|
|
||||||
assert fixture.private_key_path.exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_surfaces_malformed_referenced_runtime_metadata(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
||||||
fixture.plugin.install(fixture.config)
|
|
||||||
fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="parse Elydora runtime config"):
|
with pytest.raises(ValueError, match="parse Elydora runtime config"):
|
||||||
fixture.plugin.status()
|
fixture.plugin.status()
|
||||||
|
|
||||||
|
|
||||||
def test_status_surfaces_malformed_matcher_groups(
|
def test_uninstall_removes_exact_ownership_and_preserves_lookalikes(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
fixture.plugin.install(fixture.config)
|
fixture.install()
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
settings = fixture.settings()
|
||||||
settings["hooks"]["PreToolUse"][0]["hooks"] = None
|
agent_ten = fixture.agent_dir.parent / "agent-10"
|
||||||
fixture.config_path.write_text(json.dumps(settings), encoding="utf-8")
|
other_guard = build_handler(str(agent_ten / "guard.py"), GUARD_STATUS)
|
||||||
|
other_audit = build_handler(str(agent_ten / "hook.py"), AUDIT_STATUS)
|
||||||
|
lookalike = {
|
||||||
|
"type": "command",
|
||||||
|
"command": f"inspect {fixture.guard_path}",
|
||||||
|
"commandWindows": f"inspect {fixture.guard_path}",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": GUARD_STATUS,
|
||||||
|
}
|
||||||
|
managed_guard = dict(managed_handler(settings, "PreToolUse", GUARD_STATUS))
|
||||||
|
settings["hooks"]["SessionStart"] = [
|
||||||
|
{"matcher": "*", "hooks": [{"type": "command", "command": "keep"}]}
|
||||||
|
]
|
||||||
|
settings["hooks"]["PreToolUse"].extend(
|
||||||
|
[
|
||||||
|
{"matcher": "*", "hooks": [other_guard]},
|
||||||
|
{"matcher": "*", "hooks": [lookalike]},
|
||||||
|
{"matcher": "Bash", "hooks": [managed_guard]},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
settings["hooks"]["PostToolUse"].append({"matcher": "*", "hooks": [other_audit]})
|
||||||
|
write_json(fixture.config_path, settings)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="matcher group must contain a hooks array"):
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
fixture.plugin.status()
|
|
||||||
|
remaining = fixture.settings()
|
||||||
|
assert remaining["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "keep"
|
||||||
|
assert len(remaining["hooks"]["PreToolUse"]) == 3
|
||||||
|
assert len(remaining["hooks"]["PostToolUse"]) == 1
|
||||||
|
assert remaining["hooks"]["PreToolUse"][1]["hooks"][0] == lookalike
|
||||||
|
assert remaining["hooks"]["PreToolUse"][2] == {"matcher": "Bash", "hooks": []}
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_removes_config_owned_entirely_by_elydora(
|
def test_uninstall_removes_an_entirely_managed_file(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
fixture.plugin.install(fixture.config)
|
fixture.install()
|
||||||
|
|
||||||
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
fixture.plugin.uninstall(AGENT_ID)
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
|
|
||||||
assert fixture.config_path.exists() is False
|
assert fixture.config_path.exists() is False
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_matches_the_exact_agent_runtime_directory(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
||||||
fixture.plugin.install(fixture.config)
|
|
||||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
||||||
other_agent_dir = fixture.agent_dir.parent / "agent-10"
|
|
||||||
|
|
||||||
for event, status in (
|
|
||||||
("PreToolUse", GUARD_STATUS),
|
|
||||||
("PostToolUse", AUDIT_STATUS),
|
|
||||||
):
|
|
||||||
handler = find_handler(settings, event, status)
|
|
||||||
other_handler = {
|
|
||||||
**handler,
|
|
||||||
"command": handler["command"].replace(
|
|
||||||
str(fixture.agent_dir), str(other_agent_dir)
|
|
||||||
),
|
|
||||||
"commandWindows": handler["commandWindows"].replace(
|
|
||||||
str(fixture.agent_dir), str(other_agent_dir)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
settings["hooks"][event].append(
|
|
||||||
{"matcher": "*", "hooks": [other_handler]}
|
|
||||||
)
|
|
||||||
fixture.config_path.write_text(json.dumps(settings), encoding="utf-8")
|
|
||||||
|
|
||||||
fixture.plugin.uninstall(AGENT_ID)
|
|
||||||
|
|
||||||
remaining = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
||||||
assert len(remaining["hooks"]["PreToolUse"]) == 1
|
|
||||||
assert len(remaining["hooks"]["PostToolUse"]) == 1
|
|
||||||
assert str(other_agent_dir) in find_handler(
|
|
||||||
remaining, "PreToolUse", GUARD_STATUS
|
|
||||||
)["command"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("existing_settings", "error_pattern"),
|
"overrides",
|
||||||
[
|
[
|
||||||
({"hooks": None}, 'field "hooks" must be an object'),
|
{"agent_name": "cursor"},
|
||||||
({"hooks": {"PreToolUse": None}}, 'field "hooks.PreToolUse" must be an array'),
|
{"org_id": ""},
|
||||||
(
|
{"agent_id": "../escape"},
|
||||||
{"hooks": {"PreToolUse": [{"hooks": None}]}},
|
{"private_key": "invalid"},
|
||||||
"matcher group must contain a hooks array",
|
{"token": ""},
|
||||||
),
|
{"base_url": "file:///tmp/elydora"},
|
||||||
|
{"base_url": "https://user:secret@api.elydora.com"},
|
||||||
|
{"base_url": "https://api.elydora.com?tenant=one"},
|
||||||
|
{"base_url": "https://api.elydora.com\\evil"},
|
||||||
|
{"base_url": "https://api.elydora.com/invalid path"},
|
||||||
|
{"base_url": "https://api.elydora.com:invalid"},
|
||||||
|
{"guard_script_path": "unmanaged.py"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_install_preserves_invalid_shapes_before_runtime_writes(
|
def test_install_validates_identity_credentials_and_api_origin(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
existing_settings: dict[str, Any],
|
overrides: dict[str, str],
|
||||||
error_pattern: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
monkeypatch,
|
fixture.config.update(overrides)
|
||||||
tmp_path,
|
|
||||||
existing_settings=existing_settings,
|
|
||||||
)
|
|
||||||
original = fixture.config_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=error_pattern):
|
with pytest.raises(ValueError):
|
||||||
fixture.plugin.install(fixture.config)
|
fixture.install()
|
||||||
|
|
||||||
assert fixture.config_path.read_text(encoding="utf-8") == original
|
assert fixture.config_path.exists() is False
|
||||||
assert fixture.hook_path.exists() is False
|
assert list(fixture.agent_dir.iterdir()) == []
|
||||||
assert fixture.runtime_config_path.exists() is False
|
|
||||||
assert fixture.private_key_path.exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_preserves_malformed_config_before_runtime_writes(
|
@pytest.mark.parametrize("kind", ["hooks", "config", "key", "guard", "audit"])
|
||||||
|
def test_linked_hook_and_runtime_files_are_rejected(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
kind: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
fixture = prepare_fixture(
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
monkeypatch,
|
if kind == "hooks":
|
||||||
tmp_path,
|
target = tmp_path / "hooks-target.json"
|
||||||
existing_settings="{ malformed",
|
original = '{"hooks":{}}\n'
|
||||||
)
|
write_text(target, original)
|
||||||
|
fixture.config_path.parent.mkdir(parents=True)
|
||||||
|
symlink_or_skip(target, fixture.config_path)
|
||||||
|
with pytest.raises(OSError, match="physical file"):
|
||||||
|
fixture.install()
|
||||||
|
assert target.read_text(encoding="utf-8") == original
|
||||||
|
return
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="parse Codex hooks config"):
|
fixture.install()
|
||||||
fixture.plugin.install(fixture.config)
|
file_path = {
|
||||||
|
"config": fixture.runtime_config_path,
|
||||||
|
"key": fixture.private_key_path,
|
||||||
|
"guard": fixture.guard_path,
|
||||||
|
"audit": fixture.hook_path,
|
||||||
|
}[kind]
|
||||||
|
target = tmp_path / f"{kind}-target"
|
||||||
|
target.write_bytes(file_path.read_bytes())
|
||||||
|
file_path.unlink()
|
||||||
|
symlink_or_skip(target, file_path)
|
||||||
|
|
||||||
assert fixture.config_path.read_text(encoding="utf-8") == "{ malformed"
|
with pytest.raises(OSError, match="physical file"):
|
||||||
assert fixture.hook_path.exists() is False
|
fixture.plugin.status()
|
||||||
assert fixture.runtime_config_path.exists() is False
|
|
||||||
assert fixture.private_key_path.exists() is False
|
assert target.read_bytes()
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex_support import (
|
||||||
|
ElydoraApiHandler,
|
||||||
|
managed_handler,
|
||||||
|
prepare_fixture,
|
||||||
|
run_handler,
|
||||||
|
server_base_url,
|
||||||
|
start_api_server,
|
||||||
|
symlink_or_skip,
|
||||||
|
write_json,
|
||||||
|
write_text,
|
||||||
|
)
|
||||||
|
from elydora.plugins.codex_contract import AUDIT_STATUS, GUARD_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
def payload(event: str, *, failed: bool = False) -> dict[str, object]:
|
||||||
|
value: dict[str, object] = {
|
||||||
|
"hook_event_name": event,
|
||||||
|
"session_id": "session-1",
|
||||||
|
"turn_id": "turn-1",
|
||||||
|
"transcript_path": None,
|
||||||
|
"cwd": "C:/workspace",
|
||||||
|
"model": "gpt-5",
|
||||||
|
"permission_mode": "default",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_use_id": "call-1",
|
||||||
|
"tool_input": {"command": "echo test"},
|
||||||
|
}
|
||||||
|
if event == "PostToolUse":
|
||||||
|
value["tool_response"] = (
|
||||||
|
{"success": False, "error": "command failed", "exit_code": 1}
|
||||||
|
if failed
|
||||||
|
else {"success": True, "output": "test"}
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: object) -> bytes:
|
||||||
|
return json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtimes_enforce_active_state_and_preserve_native_event_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
settings = fixture.settings()
|
||||||
|
guard = managed_handler(settings, "PreToolUse", GUARD_STATUS)
|
||||||
|
audit = managed_handler(settings, "PostToolUse", AUDIT_STATUS)
|
||||||
|
|
||||||
|
pre = payload("PreToolUse")
|
||||||
|
first = payload("PostToolUse")
|
||||||
|
failed = payload("PostToolUse", failed=True)
|
||||||
|
guard_result = run_handler(guard, encoded(pre), fixture)
|
||||||
|
first_result = run_handler(audit, encoded(first), fixture)
|
||||||
|
failed_result = run_handler(audit, encoded(failed), fixture)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert guard_result.returncode == 0
|
||||||
|
assert guard_result.stdout == b""
|
||||||
|
assert guard_result.stderr == b""
|
||||||
|
assert first_result.returncode == 0
|
||||||
|
assert failed_result.returncode == 0
|
||||||
|
requests = ElydoraApiHandler.requests
|
||||||
|
assert [request["method"] for request in requests] == ["GET", "POST", "POST"]
|
||||||
|
assert [request["authorization"] for request in requests] == [
|
||||||
|
"Bearer token-1",
|
||||||
|
"Bearer token-1",
|
||||||
|
"Bearer token-1",
|
||||||
|
]
|
||||||
|
assert requests[1]["json"]["payload"] == first
|
||||||
|
assert requests[2]["json"]["payload"] == failed
|
||||||
|
assert requests[2]["json"]["prev_chain_hash"] == requests[1]["json"]["chain_hash"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["frozen", "revoked"])
|
||||||
|
def test_guard_propagates_exit_two_for_blocked_agents(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
ElydoraApiHandler.agent_status = status
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
guard = managed_handler(fixture.settings(), "PreToolUse", GUARD_STATUS)
|
||||||
|
result = run_handler(guard, encoded(payload("PreToolUse")), fixture)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 2
|
||||||
|
assert status.encode() in result.stderr
|
||||||
|
assert b"Tool execution blocked" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_uses_a_protected_cached_blocking_status(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.install()
|
||||||
|
write_json(
|
||||||
|
fixture.agent_dir / "status-cache.json",
|
||||||
|
{"status": "frozen", "cached_at": time.time()},
|
||||||
|
)
|
||||||
|
guard = managed_handler(fixture.settings(), "PreToolUse", GUARD_STATUS)
|
||||||
|
|
||||||
|
result = run_handler(guard, encoded(payload("PreToolUse")), fixture)
|
||||||
|
|
||||||
|
assert result.returncode == 2
|
||||||
|
assert b"Tool execution blocked" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_open_guard_reports_input_config_and_status_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
guard = managed_handler(fixture.settings(), "PreToolUse", GUARD_STATUS)
|
||||||
|
runtime_config = json.loads(
|
||||||
|
fixture.runtime_config_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
malformed_input = run_handler(guard, b"{ malformed", fixture)
|
||||||
|
non_object_input = run_handler(guard, b"[]", fixture)
|
||||||
|
write_text(fixture.runtime_config_path, "{ malformed")
|
||||||
|
malformed_config = run_handler(
|
||||||
|
guard,
|
||||||
|
encoded(payload("PreToolUse")),
|
||||||
|
fixture,
|
||||||
|
)
|
||||||
|
write_json(fixture.runtime_config_path, runtime_config)
|
||||||
|
(fixture.agent_dir / "status-cache.json").unlink()
|
||||||
|
ElydoraApiHandler.agent_status = "unknown"
|
||||||
|
invalid_status = run_handler(
|
||||||
|
guard,
|
||||||
|
encoded(payload("PreToolUse")),
|
||||||
|
fixture,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert malformed_input.returncode == 0
|
||||||
|
assert b"invalid JSON" in malformed_input.stderr
|
||||||
|
assert b"fail-open" in malformed_input.stderr
|
||||||
|
assert non_object_input.returncode == 0
|
||||||
|
assert b"JSON object" in non_object_input.stderr
|
||||||
|
assert b"fail-open" in non_object_input.stderr
|
||||||
|
assert malformed_config.returncode == 0
|
||||||
|
assert b"Failed to read agent config" in malformed_config.stderr
|
||||||
|
assert invalid_status.returncode == 0
|
||||||
|
assert b"invalid agent status" in invalid_status.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_hooks_report_an_unsafe_runtime_origin(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.install()
|
||||||
|
config = json.loads(fixture.runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
config["base_url"] = "https://api.elydora.com\\evil"
|
||||||
|
write_json(fixture.runtime_config_path, config)
|
||||||
|
(fixture.agent_dir / "status-cache.json").unlink(missing_ok=True)
|
||||||
|
settings = fixture.settings()
|
||||||
|
|
||||||
|
guard_result = run_handler(
|
||||||
|
managed_handler(settings, "PreToolUse", GUARD_STATUS),
|
||||||
|
encoded(payload("PreToolUse")),
|
||||||
|
fixture,
|
||||||
|
)
|
||||||
|
audit_result = run_handler(
|
||||||
|
managed_handler(settings, "PostToolUse", AUDIT_STATUS),
|
||||||
|
encoded(payload("PostToolUse")),
|
||||||
|
fixture,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert guard_result.returncode == 0
|
||||||
|
assert b"absolute HTTP or HTTPS URL" in guard_result.stderr
|
||||||
|
assert audit_result.returncode == 0
|
||||||
|
assert "absolute HTTP or HTTPS URL" in (fixture.agent_dir / "error.log").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("target", "source", "message"),
|
||||||
|
[
|
||||||
|
("input", "{ malformed", "invalid JSON"),
|
||||||
|
("config.json", "{ malformed", "agent config"),
|
||||||
|
("private.key", "invalid", "Private key"),
|
||||||
|
("chain-state.json", "{ malformed", "Chain state"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fail_open_audit_records_runtime_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
target: str,
|
||||||
|
source: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
audit = managed_handler(fixture.settings(), "PostToolUse", AUDIT_STATUS)
|
||||||
|
if target != "input":
|
||||||
|
write_text(fixture.agent_dir / target, source)
|
||||||
|
runtime_input = (
|
||||||
|
source.encode() if target == "input" else encoded(payload("PostToolUse"))
|
||||||
|
)
|
||||||
|
result = run_handler(audit, runtime_input, fixture)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
error_log = fixture.agent_dir / "error.log"
|
||||||
|
assert error_log.is_file()
|
||||||
|
assert message.lower() in error_log.read_text(encoding="utf-8").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_open_audit_records_api_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
ElydoraApiHandler.post_status = 500
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
audit = managed_handler(fixture.settings(), "PostToolUse", AUDIT_STATUS)
|
||||||
|
result = run_handler(audit, encoded(payload("PostToolUse")), fixture)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "HTTP 500" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"target", ["status-cache.json", "chain-state.json", "error.log"]
|
||||||
|
)
|
||||||
|
def test_runtimes_reject_linked_cache_chain_and_error_state(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
target: str,
|
||||||
|
) -> None:
|
||||||
|
server = start_api_server()
|
||||||
|
try:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
base_url=server_base_url(server),
|
||||||
|
)
|
||||||
|
fixture.install()
|
||||||
|
target_path = tmp_path / f"{target}.target"
|
||||||
|
source = {
|
||||||
|
"status-cache.json": json.dumps(
|
||||||
|
{"status": "active", "cached_at": time.time()}
|
||||||
|
),
|
||||||
|
"chain-state.json": json.dumps({"prev_chain_hash": "A" * 43}),
|
||||||
|
"error.log": "preserve\n",
|
||||||
|
}[target]
|
||||||
|
write_text(target_path, source)
|
||||||
|
link = fixture.agent_dir / target
|
||||||
|
symlink_or_skip(target_path, link)
|
||||||
|
if target == "status-cache.json":
|
||||||
|
handler = managed_handler(fixture.settings(), "PreToolUse", GUARD_STATUS)
|
||||||
|
runtime_input = encoded(payload("PreToolUse"))
|
||||||
|
else:
|
||||||
|
handler = managed_handler(fixture.settings(), "PostToolUse", AUDIT_STATUS)
|
||||||
|
runtime_input = (
|
||||||
|
b"{ malformed"
|
||||||
|
if target == "error.log"
|
||||||
|
else encoded(payload("PostToolUse"))
|
||||||
|
)
|
||||||
|
result = run_handler(handler, runtime_input, fixture)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert target_path.read_text(encoding="utf-8") == source
|
||||||
|
if target == "error.log":
|
||||||
|
assert b"Failed to write error log" in result.stderr
|
||||||
|
else:
|
||||||
|
assert (
|
||||||
|
b"physical file" in result.stderr
|
||||||
|
or (fixture.agent_dir / "error.log").is_file()
|
||||||
|
)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
VALID_PRIVATE_KEY,
|
||||||
|
prepare_fixture,
|
||||||
|
symlink_or_skip,
|
||||||
|
write_json,
|
||||||
|
write_text,
|
||||||
|
)
|
||||||
|
from elydora.plugins import codex
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_transaction_files(root: Path) -> None:
|
||||||
|
assert not list(root.rglob("*.tmp"))
|
||||||
|
assert not list(root.rglob("*.rollback"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_rolls_back_all_five_files_when_hooks_commit_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
existing_config={"hooks": {"SessionStart": []}},
|
||||||
|
)
|
||||||
|
original = fixture.config_path.read_text(encoding="utf-8")
|
||||||
|
real_replace = os.replace
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def fail_hooks_once(source: str, destination: str) -> None:
|
||||||
|
nonlocal failed
|
||||||
|
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||||
|
failed = True
|
||||||
|
raise OSError("injected hooks commit failure")
|
||||||
|
real_replace(source, destination)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", fail_hooks_once)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected hooks commit failure"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert fixture.config_path.read_text(encoding="utf-8") == original
|
||||||
|
for path in (
|
||||||
|
fixture.guard_path,
|
||||||
|
fixture.hook_path,
|
||||||
|
fixture.runtime_config_path,
|
||||||
|
fixture.private_key_path,
|
||||||
|
):
|
||||||
|
assert path.exists() is False
|
||||||
|
assert_no_transaction_files(fixture.home_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_detects_a_concurrent_hooks_change_before_staging(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
existing_config={"hooks": {"SessionStart": []}},
|
||||||
|
)
|
||||||
|
concurrent = '{"hooks":{"SessionStart":[{"hooks":[]}]}}\n'
|
||||||
|
real_rendered_change = codex.rendered_change
|
||||||
|
|
||||||
|
def mutate_before_change(rendered: object):
|
||||||
|
write_text(fixture.config_path, concurrent)
|
||||||
|
return real_rendered_change(rendered) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
monkeypatch.setattr(codex, "rendered_change", mutate_before_change)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed before staging"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
|
||||||
|
assert list(fixture.agent_dir.iterdir()) == []
|
||||||
|
assert_no_transaction_files(fixture.home_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"runtime_name",
|
||||||
|
[
|
||||||
|
"private.key",
|
||||||
|
"guard.py",
|
||||||
|
"hook.py",
|
||||||
|
"chain-state.json",
|
||||||
|
"status-cache.json",
|
||||||
|
"error.log",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_orphan_runtime_artifacts_are_rejected_before_hooks_writes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
runtime_name: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
artifact = fixture.agent_dir / runtime_name
|
||||||
|
write_text(artifact, "orphan\n")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="identity cannot be verified"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert artifact.read_text(encoding="utf-8") == "orphan\n"
|
||||||
|
assert fixture.config_path.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_runtime_identity_is_rejected_before_writes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(
|
||||||
|
fixture.runtime_config_path,
|
||||||
|
{"agent_id": "another-agent", "agent_name": "codex"},
|
||||||
|
)
|
||||||
|
original = fixture.runtime_config_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="identity does not match"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert fixture.runtime_config_path.read_text(encoding="utf-8") == original
|
||||||
|
assert fixture.config_path.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["codex", "runtime"])
|
||||||
|
def test_linked_hooks_and_runtime_directories_are_rejected(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
kind: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
target = tmp_path / f"{kind}-target"
|
||||||
|
target.mkdir()
|
||||||
|
if kind == "codex":
|
||||||
|
link = fixture.config_path.parent
|
||||||
|
else:
|
||||||
|
link = fixture.agent_dir.parent
|
||||||
|
shutil.rmtree(link)
|
||||||
|
link.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
symlink_or_skip(target, link, directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="physical directory"):
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
assert fixture.config_path.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_rejects_a_linked_default_hooks_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.install()
|
||||||
|
codex_directory = fixture.config_path.parent
|
||||||
|
target = tmp_path / "codex-hooks-target"
|
||||||
|
codex_directory.rename(target)
|
||||||
|
symlink_or_skip(target, codex_directory, directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="physical directory"):
|
||||||
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
|
|
||||||
|
assert (target / "hooks.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_protects_credentials_and_cleans_transaction_files(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.install()
|
||||||
|
|
||||||
|
assert_no_transaction_files(fixture.home_dir)
|
||||||
|
if os.name != "nt":
|
||||||
|
for path in (
|
||||||
|
fixture.config_path,
|
||||||
|
fixture.runtime_config_path,
|
||||||
|
fixture.private_key_path,
|
||||||
|
):
|
||||||
|
assert path.stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_preflight_preserves_malformed_hooks_before_runtime_creation(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
home_dir = tmp_path / "cli-home"
|
||||||
|
hooks_path = home_dir / ".codex" / "hooks.json"
|
||||||
|
key_path = tmp_path / "private.key"
|
||||||
|
write_text(hooks_path, "{ malformed")
|
||||||
|
write_text(key_path, VALID_PRIVATE_KEY)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"elydora.cli",
|
||||||
|
"install",
|
||||||
|
"--agent",
|
||||||
|
"codex",
|
||||||
|
"--org_id",
|
||||||
|
"org-1",
|
||||||
|
"--agent_id",
|
||||||
|
AGENT_ID,
|
||||||
|
"--private_key_file",
|
||||||
|
str(key_path),
|
||||||
|
"--kid",
|
||||||
|
"key-1",
|
||||||
|
"--base_url",
|
||||||
|
"https://api.elydora.com",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
cwd=Path(__file__).parents[1],
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"HOME": str(home_dir),
|
||||||
|
"USERPROFILE": str(home_dir),
|
||||||
|
"CODEX_HOME": "",
|
||||||
|
},
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "parse Codex user hooks" in result.stderr
|
||||||
|
assert hooks_path.read_text(encoding="utf-8") == "{ malformed"
|
||||||
|
assert (home_dir / ".elydora").exists() is False
|
||||||
@@ -44,7 +44,9 @@ def create_guard(tmp_path: Path, status: str) -> tuple[Path, ThreadingHTTPServer
|
|||||||
"agent_id": AGENT_ID,
|
"agent_id": AGENT_ID,
|
||||||
"base_url": f"http://127.0.0.1:{server.server_port}",
|
"base_url": f"http://127.0.0.1:{server.server_port}",
|
||||||
}
|
}
|
||||||
(agent_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
config_path = agent_dir / "config.json"
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
os.chmod(config_path, 0o600)
|
||||||
|
|
||||||
script_path = tmp_path / "guard.py"
|
script_path = tmp_path / "guard.py"
|
||||||
script_path.write_text(
|
script_path.write_text(
|
||||||
@@ -54,7 +56,11 @@ def create_guard(tmp_path: Path, status: str) -> tuple[Path, ThreadingHTTPServer
|
|||||||
return script_path, server
|
return script_path, server
|
||||||
|
|
||||||
|
|
||||||
def run_guard(script_path: Path, home_dir: Path) -> subprocess.CompletedProcess[str]:
|
def run_guard(
|
||||||
|
script_path: Path,
|
||||||
|
home_dir: Path,
|
||||||
|
payload: str = "{}",
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
"HOME": str(home_dir),
|
"HOME": str(home_dir),
|
||||||
@@ -65,7 +71,7 @@ def run_guard(script_path: Path, home_dir: Path) -> subprocess.CompletedProcess[
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
env=env,
|
env=env,
|
||||||
input="",
|
input=payload,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +108,7 @@ def test_cached_frozen_status_uses_blocking_exit_code(tmp_path: Path) -> None:
|
|||||||
json.dumps({"status": "frozen", "cached_at": time.time()}),
|
json.dumps({"status": "frozen", "cached_at": time.time()}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
os.chmod(cache_path, 0o600)
|
||||||
try:
|
try:
|
||||||
result = run_guard(script_path, tmp_path)
|
result = run_guard(script_path, tmp_path)
|
||||||
finally:
|
finally:
|
||||||
@@ -110,3 +117,29 @@ def test_cached_frozen_status_uses_blocking_exit_code(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert result.returncode == 2
|
assert result.returncode == 2
|
||||||
assert "Tool execution blocked" in result.stderr
|
assert "Tool execution blocked" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_open_guard_reports_malformed_input(tmp_path: Path) -> None:
|
||||||
|
script_path, server = create_guard(tmp_path, "active")
|
||||||
|
try:
|
||||||
|
result = run_guard(script_path, tmp_path, "{ malformed")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "invalid JSON" in result.stderr
|
||||||
|
assert "fail-open" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_open_guard_reports_non_object_input(tmp_path: Path) -> None:
|
||||||
|
script_path, server = create_guard(tmp_path, "active")
|
||||||
|
try:
|
||||||
|
result = run_guard(script_path, tmp_path, "[]")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "JSON object" in result.stderr
|
||||||
|
assert "fail-open" in result.stderr
|
||||||
Reference in new issue
Block a user