fix(python): mirror Factory Droid 0.175 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 14:11:58 -04:00
1 parent 47ef2cc691
commit c59fcafebb
12 files changed
+2281 -743

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ This repository publishes the open-source server, console, integration catalog,
- Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Write Python Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, and own exact native `preToolUse`, `postToolUse`, and `postToolUseFailure` commands. Forward native success and failure payloads, retain PowerShell exit code `2`, set every handler to `failClosed` with a bounded timeout, and commit the guard, audit script, runtime identity, private key, and hook configuration as one rollback-capable transaction. - Write Python Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, and own exact native `preToolUse`, `postToolUse`, and `postToolUseFailure` commands. Forward native success and failure payloads, retain PowerShell exit code `2`, set every handler to `failClosed` with a bounded timeout, and commit the guard, audit script, runtime identity, private key, and hook configuration as one rollback-capable transaction.
- Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, and require `/hooks` review for the session snapshot and remote organization policy. - Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, require exact generated runtime sources during status checks, and require `/hooks` review for the session snapshot and remote organization policy.
- Qwen Code `0.20.0` resolves user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review. - Qwen Code `0.20.0` resolves user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. - Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files.
+1 -1
View File
@@ -96,7 +96,7 @@ Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `P
Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration.
Factory Droid installation follows its active user source per event: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json` fallback, or hooks nested in `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, leaves project and organization hooks unchanged, and runs both managed commands with absolute Python and script paths. Run `/hooks` after installation to review the changes. Factory Droid 0.175.0 installation resolves one active user hook source in this order: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the `hooks` field from user `settings.local.json` and `settings.json`. Current hook files use the documented top-level `hooks` container; legacy direct event maps remain readable until Factory migrates them. Elydora writes exact `PreToolUse` and `PostToolUse` groups to that source, removes exact Elydora ownership from inactive user sources, and keeps project, folder, plugin, and organization hook definitions read-only. System, project, folder, and user `hooksDisabled` values follow Factory's extension-only precedence; system-managed `allowManagedHooksOnly` blocks a user-hook installation. Hook sources, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and use Factory's native PowerShell call operator with explicit `$LASTEXITCODE` propagation on Windows. Run `/hooks` after installation to refresh Droid's session snapshot and verify remote organization policy.
Qwen Code installation resolves its user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`. Elydora preserves comments and unrelated settings, leaves workspace settings unchanged, and uses Qwen's explicit Bash or PowerShell command contract with millisecond timeouts. Run `/hooks` after installation to review the changes. Qwen Code installation resolves its user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`. Elydora preserves comments and unrelated settings, leaves workspace settings unchanged, and uses Qwen's explicit Bash or PowerShell command contract with millisecond timeouts. Run `/hooks` after installation to review the changes.
+73 -143
View File
@@ -2,182 +2,112 @@
from __future__ import annotations from __future__ import annotations
import json from typing import Dict, List
import os
from typing import List, Optional
from ._transaction import FileChange, file_change, require_runtime, write_changes
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
from .droid_config import ( from .droid_config import (
additions_for, DroidSources,
installation_targets, RenderedDocument,
active_document,
additions_for_target,
effective_hooks,
hook_block,
installation_documents,
render_document, render_document,
unique_documents, source_documents,
) )
from .droid_contract import ( from .droid_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT, JsonObject,
GUARD_SCRIPT,
TOOL_EVENTS,
build_group, build_group,
elydora_dir,
merge_hook_settings,
runtime_contracts, runtime_contracts,
validate_javascript_regexes,
) )
from .droid_io import ( from .droid_installation import (
display_config_path, commit_droid_installation,
read_sources, commit_droid_uninstall,
rendered_change, preflight_droid_installation,
runtime_files_exist, prepare_droid_installation,
prepare_droid_uninstall,
) )
from .hook_template import generate_hook_script from .droid_io import display_config_path, read_sources, runtime_files_exist
def _agent_directory(agent_id: str) -> str: def _render_installation(
if ( sources: DroidSources,
not agent_id guard_path: str,
or agent_id in {".", ".."} audit_path: str,
or os.path.basename(agent_id) != agent_id ) -> List[RenderedDocument]:
or os.path.isabs(agent_id) target = active_document(sources)
): groups: Dict[str, JsonObject] = {
raise ValueError("agent_id must be a single non-empty path segment") "PreToolUse": build_group(guard_path),
return os.path.join(elydora_dir(), agent_id) "PostToolUse": build_group(audit_path),
}
return [
def _same_path(left: str, right: str) -> bool: render_document(
return os.path.normcase(os.path.abspath(left)) == os.path.normcase( document,
os.path.abspath(right) None,
additions_for_target(document, target, groups),
) )
for document in installation_documents(sources)
]
def _append_change(changes: List[FileChange], change: Optional[FileChange]) -> None: def _render_uninstall(
if change is not None: sources: DroidSources,
changes.append(change) agent_id: str,
) -> List[RenderedDocument]:
return [
render_document(document, agent_id or None, {})
for document in source_documents(sources)
]
class DroidPlugin(AgentPlugin): class DroidPlugin(AgentPlugin):
"""Install Elydora into Factory Droid's user-level hook sources.""" """Install Elydora into Factory Droid's user-level hook sources."""
def install(self, config: InstallConfig) -> None: manages_guard_runtime = True
agent_id = config.get("agent_id", "")
agent_directory = _agent_directory(agent_id) def preflight_install(self, config: InstallConfig) -> None:
sources = read_sources() sources = read_sources()
validate_javascript_regexes([ preflight_droid_installation(config, sources)
sources.primary.hooks if sources.primary is not None else {},
sources.settings.hooks,
])
guard_path = config.get("guard_script_path", "") def install(self, config: InstallConfig) -> None:
expected_guard_path = os.path.join(agent_directory, GUARD_SCRIPT) sources = read_sources()
if not _same_path(guard_path, expected_guard_path): paths = preflight_droid_installation(config, sources)
raise ValueError( rendered = _render_installation(
"Elydora guard runtime must use the managed agent directory: " sources,
f"{expected_guard_path}" paths.guard_path,
paths.audit_path,
) )
require_runtime(guard_path, "Elydora guard runtime") prepared = prepare_droid_installation(config, sources, rendered)
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT) commit_droid_installation(prepared)
print(f"Factory Droid hooks: {active_document(sources).file_path}")
selected = installation_targets(sources)
groups = {
"PreToolUse": build_group(guard_path),
"PostToolUse": build_group(audit_path),
}
documents = unique_documents([
sources.primary,
sources.settings if sources.settings.has_hooks_container else None,
*(selected.targets[event] for event in TOOL_EVENTS),
])
rendered = [
render_document(
document,
None,
additions_for(document, selected.targets, groups),
)
for document in documents
]
runtime_config = {
"org_id": config.get("org_id", ""),
"agent_id": agent_id,
"kid": config.get("kid", ""),
"base_url": config.get("base_url", "https://api.elydora.com"),
"token": config.get("token", ""),
"agent_name": AGENT_KEY,
}
audit_script = generate_hook_script(
org_id=config.get("org_id", ""),
agent_id=agent_id,
kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"),
)
changes: List[FileChange] = []
_append_change(changes, file_change(
os.path.join(agent_directory, "config.json"),
"Elydora runtime config",
json.dumps(runtime_config, indent=2) + "\n",
0o600,
))
_append_change(changes, file_change(
os.path.join(agent_directory, "private.key"),
"Elydora private key",
config.get("private_key", ""),
0o600,
))
_append_change(changes, file_change(
audit_path,
"Elydora audit runtime",
audit_script,
0o700,
))
for item in rendered:
_append_change(changes, rendered_change(item))
write_changes(changes, "Write Factory Droid installation")
locations = ", ".join(
f"{event}: {selected.targets[event].file_path}"
for event in TOOL_EVENTS
)
print(f"Factory Droid: {locations}")
print("Factory Droid: run /hooks to review the Elydora hook changes.") print("Factory Droid: run /hooks to review the Elydora hook changes.")
def uninstall(self, agent_id: str = "") -> None: def uninstall(self, agent_id: str = "") -> None:
sources = read_sources() sources = read_sources()
documents = unique_documents([ rendered = _render_uninstall(sources, agent_id)
sources.primary, commit_droid_uninstall(prepare_droid_uninstall(rendered))
sources.settings if sources.settings.has_hooks_container else None,
])
changes: List[FileChange] = []
for document in documents:
rendered = render_document(document, agent_id or None, {})
_append_change(changes, rendered_change(rendered))
write_changes(changes, "Write Factory Droid hook sources")
def status(self) -> PluginStatus: def status(self) -> PluginStatus:
sources = read_sources() sources = read_sources()
effective = merge_hook_settings( contracts = runtime_contracts(effective_hooks(sources))
sources.primary.hooks if sources.primary is not None else None, blocked = hook_block(sources)
sources.settings.hooks, configured = blocked is None and bool(contracts)
) installed = configured and runtime_files_exist(contracts)
contracts = runtime_contracts(effective)
config_path = display_config_path(sources) config_path = display_config_path(sources)
if effective.get("hooksDisabled") is True: if blocked is not None:
return PluginStatus(
installed=False,
agent=AGENT_KEY,
details=f"Configured hooks are disabled: {config_path}",
)
if not contracts:
return PluginStatus(
installed=False,
agent=AGENT_KEY,
details="Not installed",
)
installed = runtime_files_exist(contracts)
details = ( details = (
f"Config: {config_path}" f"Configured hooks are disabled by {blocked.field}: "
if installed f"{blocked.file_path}"
else f"Configured at {config_path}; runtime scripts missing" )
elif not contracts:
details = "Not installed"
elif installed:
details = f"Config: {config_path}"
else:
details = (
f"Configured at {config_path}; managed contract incomplete"
) )
return PluginStatus( return PluginStatus(
installed=installed, installed=installed,
+246 -101
View File
@@ -3,17 +3,20 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple import os
from typing import Dict, List, Optional, Tuple
from ._jsonc import JsonPathPart, JsoncEditor
from ._managed_files import FileSnapshot
from .droid_contract import ( from .droid_contract import (
TOOL_EVENTS, TOOL_EVENTS,
DroidHookSettings, DroidHookMap,
JsonObject, JsonObject,
has_own,
managed_removals, managed_removals,
read_hook_settings, read_hook_map,
same_path,
) )
from ._jsonc import JsonPathPart, JsoncEditor from .droid_policy import DroidPolicyState
OWNED_FILE_MARKER = "// Managed by Elydora" OWNED_FILE_MARKER = "// Managed by Elydora"
@@ -25,24 +28,23 @@ class DroidDocument:
file_path: str file_path: str
exists: bool exists: bool
raw: str raw: str
snapshot: Optional[FileSnapshot]
root: JsonObject root: JsonObject
hooks: DroidHookSettings hooks: DroidHookMap
base_path: Tuple[JsonPathPart, ...] base_path: Tuple[JsonPathPart, ...]
has_hooks_container: bool has_hooks_container: bool
hooks_disabled: Optional[bool]
show_hook_output: Optional[bool]
owned_file: bool owned_file: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class DroidSources: class DroidSources:
root_path: str root: DroidDocument
primary: Optional[DroidDocument] legacy: DroidDocument
settings: DroidDocument settings: DroidDocument
local_settings: DroidDocument
policy: DroidPolicyState
@dataclass(frozen=True)
class InstallationTargets:
targets: Dict[str, DroidDocument]
created_root: Optional[DroidDocument]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -52,30 +54,65 @@ class RenderedDocument:
next_source: Optional[str] next_source: Optional[str]
@dataclass(frozen=True)
class DroidHookBlock:
field: str
file_path: str
label: str
def _label(kind: str, file_path: str) -> str: def _label(kind: str, file_path: str) -> str:
if kind == "settings": if kind == "settings":
return f"Factory Droid settings at {file_path}" return f"Factory Droid settings at {file_path}"
if kind == "local-settings":
return f"Factory Droid local settings at {file_path}"
if kind == "legacy": if kind == "legacy":
return f"Factory Droid legacy hooks at {file_path}" return f"Factory Droid legacy hooks at {file_path}"
return f"Factory Droid hooks at {file_path}" return f"Factory Droid hooks at {file_path}"
def _optional_boolean(root: JsonObject, field: str, label: str) -> Optional[bool]:
value = root.get(field)
if field in root and not isinstance(value, bool):
raise ValueError(f'{label} field "{field}" must be a boolean')
return value
def _legacy_direct_hooks(
root: JsonObject,
label: str,
) -> Tuple[DroidHookMap, Optional[bool], Optional[bool]]:
hooks_disabled = _optional_boolean(root, "hooksDisabled", label)
show_hook_output = _optional_boolean(root, "showHookOutput", label)
entries = {
key: value
for key, value in root.items()
if key not in {"hooksDisabled", "showHookOutput"}
}
return (
read_hook_map(entries, label),
hooks_disabled,
show_hook_output,
)
def parse_document( def parse_document(
*, *,
exists: bool, exists: bool,
file_path: str, file_path: str,
kind: str, kind: str,
raw: str, raw: str,
snapshot: Optional[FileSnapshot] = None,
) -> DroidDocument: ) -> DroidDocument:
label = _label(kind, file_path) label = _label(kind, file_path)
editor = JsoncEditor(raw, label) editor = JsoncEditor(raw, label)
if not isinstance(editor.value, dict): if not isinstance(editor.value, dict):
raise ValueError(f"{label} must contain a JSON object") raise ValueError(f"{label} must contain a JSON object")
root = editor.value root = editor.value
if kind == "settings": if kind in {"settings", "local-settings"}:
has_container = has_own(root, "hooks") has_container = "hooks" in root
hooks = ( hooks = (
read_hook_settings(root["hooks"], f'{label} field "hooks"') read_hook_map(root["hooks"], f'{label} field "hooks"')
if has_container if has_container
else {} else {}
) )
@@ -84,30 +121,65 @@ def parse_document(
file_path, file_path,
exists, exists,
raw, raw,
snapshot,
root, root,
hooks, hooks,
("hooks",), ("hooks",),
has_container, has_container,
_optional_boolean(root, "hooksDisabled", label),
_optional_boolean(root, "showHookOutput", label),
False, False,
) )
if "hooks" in root:
hooks = read_hook_map(root["hooks"], f'{label} field "hooks"')
return DroidDocument( return DroidDocument(
kind, kind,
file_path, file_path,
exists, exists,
raw, raw,
snapshot,
root, root,
read_hook_settings(root, label), hooks,
(), ("hooks",),
True, True,
None,
None,
raw.startswith(OWNED_FILE_MARKER),
)
hooks, hooks_disabled, show_hook_output = _legacy_direct_hooks(root, label)
return DroidDocument(
kind,
file_path,
exists,
raw,
snapshot,
root,
hooks,
(),
False,
hooks_disabled,
show_hook_output,
raw.startswith(OWNED_FILE_MARKER), raw.startswith(OWNED_FILE_MARKER),
) )
def create_settings_document(file_path: str) -> DroidDocument: def create_settings_document(
file_path: str,
kind: str = "settings",
) -> DroidDocument:
return parse_document( return parse_document(
exists=False, exists=False,
file_path=file_path, file_path=file_path,
kind="settings", kind=kind,
raw="{}\n",
)
def create_legacy_hook_document(file_path: str) -> DroidDocument:
return parse_document(
exists=False,
file_path=file_path,
kind="legacy",
raw="{}\n", raw="{}\n",
) )
@@ -117,30 +189,40 @@ def create_owned_hook_document(file_path: str) -> DroidDocument:
exists=False, exists=False,
file_path=file_path, file_path=file_path,
kind="hooks", kind="hooks",
raw=f"{OWNED_FILE_MARKER}\n{{}}\n", raw=f'{OWNED_FILE_MARKER}\n{{\n "hooks": {{}}\n}}\n',
) )
def _hooks_from_editor( def _hooks_from_editor(
editor: JsoncEditor, editor: JsoncEditor,
document: DroidDocument, document: DroidDocument,
) -> DroidHookSettings: ) -> DroidHookMap:
root = editor.value root = editor.value
label = _label(document.kind, document.file_path)
if not isinstance(root, dict): if not isinstance(root, dict):
raise ValueError(f"{_label(document.kind, document.file_path)} must be an object") raise ValueError(f"{label} must contain a JSON object")
if document.kind == "settings": if document.kind in {"settings", "local-settings"} or "hooks" in root:
hooks = root.get("hooks") return read_hook_map(root.get("hooks"), f'{label} field "hooks"')
return read_hook_settings( return _legacy_direct_hooks(root, label)[0]
hooks,
f'{_label(document.kind, document.file_path)} field "hooks"',
)
return read_hook_settings(root, _label(document.kind, document.file_path))
def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]: def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]:
return (*document.base_path, event) return (*document.base_path, event)
def _current_document(
document: DroidDocument,
raw: str,
) -> DroidDocument:
return parse_document(
exists=document.exists,
file_path=document.file_path,
kind=document.kind,
raw=raw,
snapshot=document.snapshot,
)
def _remove_managed( def _remove_managed(
editor: JsoncEditor, editor: JsoncEditor,
document: DroidDocument, document: DroidDocument,
@@ -160,103 +242,166 @@ def _remove_managed(
continue continue
for handler_index in sorted(removal.handler_indexes, reverse=True): for handler_index in sorted(removal.handler_indexes, reverse=True):
editor.delete((*group_path, "hooks", handler_index)) editor.delete((*group_path, "hooks", handler_index))
if event_removals:
current = _hooks_from_editor(editor, document) current = _hooks_from_editor(editor, document)
if document.owned_file and event in current and not current[event]: if not current.get(event):
editor.delete(_event_path(document, event)) editor.delete(_event_path(document, event))
def _append_group(
editor: JsoncEditor,
document: DroidDocument,
event: str,
group: JsonObject,
) -> None:
current = _hooks_from_editor(editor, document)
if event in current:
editor.append(_event_path(document, event), group)
else:
editor.add_property(document.base_path, event, [group])
def _hook_file_is_empty(
document: DroidDocument,
raw: str,
) -> bool:
if document.kind in {"settings", "local-settings"}:
return False
current = _current_document(document, raw)
remaining_root_fields = [key for key in current.root if key != "hooks"]
return not current.hooks and not remaining_root_fields
def _has_exact_installation(
document: DroidDocument,
additions: Dict[str, JsonObject],
) -> bool:
if set(additions) != set(TOOL_EVENTS):
return False
removals = managed_removals(document.hooks)
if len(removals) != len(TOOL_EVENTS):
return False
for event in TOOL_EVENTS:
matches = [item for item in removals if item.event == event]
if len(matches) != 1 or not matches[0].remove_group:
return False
group = document.hooks[event][matches[0].group_index]
if group != additions[event]:
return False
return True
def render_document( def render_document(
document: DroidDocument, document: DroidDocument,
agent_id: Optional[str], agent_id: Optional[str],
additions: Dict[str, JsonObject], additions: Dict[str, JsonObject],
) -> RenderedDocument: ) -> RenderedDocument:
editor = JsoncEditor( if additions and _has_exact_installation(document, additions):
document.raw, return RenderedDocument(document, False, document.raw)
_label(document.kind, document.file_path), editor = JsoncEditor(document.raw, _label(document.kind, document.file_path))
)
_remove_managed(editor, document, agent_id) _remove_managed(editor, document, agent_id)
for event in TOOL_EVENTS: for event in TOOL_EVENTS:
group = additions.get(event) group = additions.get(event)
if group is None: if group is not None:
continue _append_group(editor, document, event, group)
current = _hooks_from_editor(editor, document) _current_document(document, editor.raw)
if event in current:
editor.append(_event_path(document, event), group)
else:
editor.add_property(document.base_path, event, [group])
next_document = parse_document(
exists=document.exists,
file_path=document.file_path,
kind=document.kind,
raw=editor.raw,
)
if ( if (
not additions not additions
and document.exists
and document.owned_file and document.owned_file
and document.kind != "settings" and _hook_file_is_empty(document, editor.raw)
and not next_document.hooks
): ):
return RenderedDocument(document, True, None) return RenderedDocument(document, True, None)
return RenderedDocument( return RenderedDocument(document, editor.raw != document.raw, editor.raw)
document,
editor.raw != document.raw,
editor.raw,
)
def _event_target( def active_document(sources: DroidSources) -> DroidDocument:
event: str, if sources.root.exists:
sources: DroidSources, return sources.root
create_root: "_RootFactory", if sources.legacy.exists:
) -> DroidDocument: return sources.legacy
if sources.primary is not None and event in sources.primary.hooks: if sources.local_settings.has_hooks_container:
return sources.primary return sources.local_settings
if sources.settings.has_hooks_container and event in sources.settings.hooks:
return sources.settings
if sources.primary is not None:
return sources.primary
if sources.settings.has_hooks_container: if sources.settings.has_hooks_container:
return sources.settings return sources.settings
return create_root.get() return sources.root
class _RootFactory: def effective_hooks(sources: DroidSources) -> DroidHookMap:
def __init__(self, file_path: str) -> None: return active_document(sources).hooks
self.file_path = file_path
self.document: Optional[DroidDocument] = None
def get(self) -> DroidDocument:
if self.document is None:
self.document = create_owned_hook_document(self.file_path)
return self.document
def installation_targets(sources: DroidSources) -> InstallationTargets: def hook_block(sources: DroidSources) -> Optional[DroidHookBlock]:
root = _RootFactory(sources.root_path) managed = sources.policy.allow_managed_hooks_only_by
targets = { if managed is not None:
event: _event_target(event, sources, root) return DroidHookBlock(
for event in TOOL_EVENTS "allowManagedHooksOnly",
} managed.file_path,
return InstallationTargets(targets, root.document) managed.label,
)
if sources.policy.hooks_disabled is not None:
if sources.policy.hooks_disabled:
origin = sources.policy.hooks_disabled_by
if origin is None:
raise RuntimeError("Factory Droid policy origin is missing")
return DroidHookBlock(
"hooksDisabled",
origin.file_path,
origin.label,
)
return None
selected = (
sources.local_settings
if sources.local_settings.hooks_disabled is not None
else sources.settings
)
if selected.hooks_disabled is True:
return DroidHookBlock(
"hooksDisabled",
selected.file_path,
_label(selected.kind, selected.file_path),
)
active = active_document(sources)
if active.hooks_disabled is True:
return DroidHookBlock(
"hooksDisabled",
active.file_path,
_label(active.kind, active.file_path),
)
return None
def additions_for( def source_documents(sources: DroidSources) -> List[DroidDocument]:
return [
sources.root,
sources.legacy,
sources.settings,
sources.local_settings,
]
def installation_documents(sources: DroidSources) -> List[DroidDocument]:
target = active_document(sources)
candidates = [
sources.root if sources.root.exists or target is sources.root else None,
sources.legacy if sources.legacy.exists else None,
sources.settings if sources.settings.has_hooks_container else None,
(
sources.local_settings
if sources.local_settings.has_hooks_container
else None
),
]
unique: Dict[str, DroidDocument] = {}
for document in candidates:
if document is not None:
unique[os.path.normcase(os.path.abspath(document.file_path))] = document
return list(unique.values())
def additions_for_target(
document: DroidDocument, document: DroidDocument,
targets: Dict[str, DroidDocument], target: DroidDocument,
groups: Dict[str, JsonObject], groups: Dict[str, JsonObject],
) -> Dict[str, JsonObject]: ) -> Dict[str, JsonObject]:
return { return groups if same_path(document.file_path, target.file_path) else {}
event: groups[event]
for event in TOOL_EVENTS
if targets[event].file_path == document.file_path
}
def unique_documents(
documents: Sequence[Optional[DroidDocument]],
) -> List[DroidDocument]:
unique: Dict[str, DroidDocument] = {}
for document in documents:
if document is not None:
unique[document.file_path] = document
return list(unique.values())
+122 -89
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass
import json import json
import math import math
import os import os
import re
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -18,21 +19,10 @@ AUDIT_SCRIPT = "hook.py"
HOOK_TIMEOUT_SECONDS = 10 HOOK_TIMEOUT_SECONDS = 10
TOOL_EVENTS = ("PreToolUse", "PostToolUse") TOOL_EVENTS = ("PreToolUse", "PostToolUse")
_EVENT_NAMES = {
"PreToolUse",
"PostToolUse",
"Notification",
"UserPromptSubmit",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
}
_FLAG_NAMES = {"hooksDisabled", "showHookOutput"}
_HANDLER_KEYS = {"command", "timeout", "type"} _HANDLER_KEYS = {"command", "timeout", "type"}
_GROUP_KEYS = {"hooks", "matcher"} _GROUP_KEYS = {"hooks", "matcher"}
_REGEX_TIMEOUT_SECONDS = 10 _REGEX_TIMEOUT_SECONDS = 10
_WINDOWS_EXIT_SUFFIX = "; exit $LASTEXITCODE"
_REGEX_VALIDATOR = """import fs from 'node:fs'; _REGEX_VALIDATOR = """import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8')); const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) { for (const entry of entries) {
@@ -47,7 +37,7 @@ for (const entry of entries) {
""" """
JsonObject = Dict[str, Any] JsonObject = Dict[str, Any]
DroidHookSettings = Dict[str, Any] DroidHookMap = Dict[str, Any]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -65,20 +55,22 @@ class ManagedRemoval:
remove_group: bool remove_group: bool
def is_object(value: Any) -> bool:
return isinstance(value, dict)
def has_own(value: JsonObject, key: str) -> bool:
return key in value
def elydora_dir() -> str: def elydora_dir() -> str:
return os.path.join(os.path.expanduser("~"), ".elydora") return os.path.join(os.path.expanduser("~"), ".elydora")
def _quote_windows(value: str) -> str: def same_path(left: str, right: str) -> bool:
return '"' + value.replace('"', '\\"') + '"' 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_power_shell(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _quote_posix(value: str) -> str: def _quote_posix(value: str) -> str:
@@ -86,8 +78,16 @@ def _quote_posix(value: str) -> str:
def build_command(script_path: str) -> str: def build_command(script_path: str) -> str:
quote = _quote_windows if os.name == "nt" else _quote_posix if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
return f"{quote(sys.executable)} {quote(script_path)}" raise ValueError(
"Factory Droid hook commands require absolute executable and script paths"
)
if os.name == "nt":
return (
f"& {_quote_power_shell(sys.executable)} "
f"{_quote_power_shell(script_path)}{_WINDOWS_EXIT_SUFFIX}"
)
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
def build_group(script_path: str) -> JsonObject: def build_group(script_path: str) -> JsonObject:
@@ -102,25 +102,27 @@ def build_group(script_path: str) -> JsonObject:
def _validate_handler(value: Any, label: str) -> JsonObject: def _validate_handler(value: Any, label: str) -> JsonObject:
if not is_object(value): if not isinstance(value, dict):
raise ValueError(f"{label} must be an object") raise ValueError(f"{label} must be an object")
handler = dict(value) handler = dict(value)
if handler.get("type") != "command": if handler.get("type") != "command":
raise ValueError(f'{label} type must be "command"') raise ValueError(f'{label} type must be "command"')
if not isinstance(handler.get("command"), str): command = handler.get("command")
raise ValueError(f"{label} command must be a string") if not isinstance(command, str) or not command.strip():
raise ValueError(f"{label} command must be a non-empty string")
timeout = handler.get("timeout") timeout = handler.get("timeout")
if timeout is not None and ( if timeout is not None and (
isinstance(timeout, bool) isinstance(timeout, bool)
or not isinstance(timeout, (int, float)) or not isinstance(timeout, (int, float))
or not math.isfinite(timeout) or not math.isfinite(timeout)
or timeout <= 0
): ):
raise ValueError(f"{label} timeout must be a finite number") raise ValueError(f"{label} timeout must be a positive finite number")
return handler return handler
def _validate_group(value: Any, label: str) -> JsonObject: def _validate_group(value: Any, label: str) -> JsonObject:
if not is_object(value): if not isinstance(value, dict):
raise ValueError(f"{label} must be an object") raise ValueError(f"{label} must be an object")
group = dict(value) group = dict(value)
matcher = group.get("matcher") matcher = group.get("matcher")
@@ -139,31 +141,23 @@ def _validate_group(value: Any, label: str) -> JsonObject:
return group return group
def read_hook_settings(value: Any, label: str) -> DroidHookSettings: def read_hook_map(value: Any, label: str) -> DroidHookMap:
if not is_object(value): if not isinstance(value, dict):
raise ValueError(f"{label} must contain a JSON object") raise ValueError(f"{label} must contain a JSON object")
settings: DroidHookSettings = dict(value) hooks: DroidHookMap = {}
for key, item in value.items(): for event, groups in value.items():
if key in _FLAG_NAMES: if not isinstance(groups, list):
if not isinstance(item, bool): raise ValueError(f'{label} field "{event}" must be an array')
raise ValueError(f'{label} field "{key}" must be a boolean') hooks[event] = [
continue _validate_group(group, f'{label} field "{event}"[{index}]')
if key not in _EVENT_NAMES: for index, group in enumerate(groups)
raise ValueError(f'{label} contains unsupported field "{key}"')
if not isinstance(item, list):
raise ValueError(f'{label} field "{key}" must be an array')
settings[key] = [
_validate_group(group, f'{label} field "{key}"[{index}]')
for index, group in enumerate(item)
] ]
return settings return hooks
def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]: def _regex_entries(hooks: DroidHookMap) -> List[JsonObject]:
entries: List[JsonObject] = [] entries: List[JsonObject] = []
for event, groups in settings.items(): for event, groups in hooks.items():
if event in _FLAG_NAMES:
continue
for index, group in enumerate(groups): for index, group in enumerate(groups):
matcher = group.get("matcher") matcher = group.get("matcher")
if isinstance(matcher, str) and matcher not in {"", "*"}: if isinstance(matcher, str) and matcher not in {"", "*"}:
@@ -182,8 +176,8 @@ def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
return entries return entries
def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None: def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
entries = [entry for source in settings for entry in _regex_entries(source)] entries = [entry for source in sources for entry in _regex_entries(source)]
if not entries: if not entries:
return return
node_path = shutil.which("node") node_path = shutil.which("node")
@@ -220,20 +214,24 @@ def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
) )
def _read_windows_argument(command: str, start: int) -> Optional[Tuple[str, int]]: def _read_power_shell_argument(
if start >= len(command) or command[start] != '"': command: str,
start: int,
) -> Optional[Tuple[str, int]]:
if start >= len(command) or command[start] != "'":
return None return None
value = "" value = ""
index = start + 1 index = start + 1
while index < len(command): while index < len(command):
if command[index:index + 2] == '\\"': if command[index] != "'":
value += '"'
index += 2
continue
if command[index] == '"':
return value, index + 1
value += command[index] value += command[index]
index += 1 index += 1
continue
if index + 1 < len(command) and command[index + 1] == "'":
value += "'"
index += 2
continue
return value, index + 1
return None return None
@@ -257,14 +255,16 @@ def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
return None return None
def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]: def _parse_two_arguments(
reader = _read_windows_argument if os.name == "nt" else _read_posix_argument command: str,
executable = reader(command, 0) start: int,
) -> Optional[Tuple[str, str]]:
executable = _read_posix_argument(command, start)
if executable is None or executable[1] >= len(command): if executable is None or executable[1] >= len(command):
return None return None
if command[executable[1]] != " ": if command[executable[1]] != " ":
return None return None
script = reader(command, executable[1] + 1) script = _read_posix_argument(command, executable[1] + 1)
if script is None or script[1] != len(command): if script is None or script[1] != len(command):
return None return None
if not executable[0] or not script[0]: if not executable[0] or not script[0]:
@@ -272,32 +272,65 @@ def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]:
return executable[0], script[0] return executable[0], script[0]
def _same_path(left: str, right: str) -> bool: def _parse_power_shell_command(command: str) -> Optional[Tuple[str, str]]:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase( if not command.startswith("& "):
os.path.abspath(right) return None
executable = _read_power_shell_argument(command, 2)
if executable is None or executable[1] >= len(command):
return None
if command[executable[1]] != " ":
return None
script = _read_power_shell_argument(command, executable[1] + 1)
if (
script is None
or command[script[1]:] != _WINDOWS_EXIT_SUFFIX
or not executable[0]
or not script[0]
):
return None
return executable[0], script[0]
def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command)
return (match.group(1), match.group(2)) if match else None
def _parse_generated_command(
command: str,
include_legacy: bool,
) -> Optional[Tuple[str, str]]:
if os.name != "nt":
return _parse_two_arguments(command, 0)
current = _parse_power_shell_command(command)
return current or (
_parse_legacy_windows_command(command) if include_legacy else None
) )
def same_agent_id(left: str, right: str) -> bool: def managed_agent_id(
return os.path.normcase(left) == os.path.normcase(right) handler: JsonObject,
script_name: str,
include_legacy: bool = False,
def managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]: ) -> Optional[str]:
if set(handler) != _HANDLER_KEYS: if set(handler) != _HANDLER_KEYS:
return None return None
if handler.get("type") != "command" or handler.get("timeout") != 10: if (
handler.get("type") != "command"
or handler.get("timeout") != HOOK_TIMEOUT_SECONDS
):
return None return None
command = handler.get("command") command = handler.get("command")
if not isinstance(command, str): if not isinstance(command, str):
return None return None
parsed = _parse_generated_command(command) parsed = _parse_generated_command(command, include_legacy)
if parsed is None or not _same_path(parsed[0], sys.executable): if parsed is None or not same_path(parsed[0], sys.executable):
return None return None
script_path = parsed[1] script_path = parsed[1]
if os.path.basename(script_path) != script_name: if os.path.basename(script_path) != script_name:
return None return None
agent_directory = os.path.dirname(script_path) agent_directory = os.path.dirname(script_path)
if not _same_path(os.path.dirname(agent_directory), elydora_dir()): if not same_path(os.path.dirname(agent_directory), elydora_dir()):
return None return None
agent_id = os.path.basename(agent_directory) agent_id = os.path.basename(agent_directory)
return agent_id if agent_id not in {"", ".", ".."} else None return agent_id if agent_id not in {"", ".", ".."} else None
@@ -314,7 +347,7 @@ def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
def managed_removals( def managed_removals(
settings: DroidHookSettings, hooks: DroidHookMap,
agent_id: Optional[str] = None, agent_id: Optional[str] = None,
) -> List[ManagedRemoval]: ) -> List[ManagedRemoval]:
removals: List[ManagedRemoval] = [] removals: List[ManagedRemoval] = []
@@ -322,12 +355,19 @@ def managed_removals(
("PreToolUse", GUARD_SCRIPT), ("PreToolUse", GUARD_SCRIPT),
("PostToolUse", AUDIT_SCRIPT), ("PostToolUse", AUDIT_SCRIPT),
): ):
for group_index, group in enumerate(settings.get(event, [])): for group_index, group in enumerate(hooks.get(event, [])):
indexes = tuple( indexes = tuple(
index index
for index, handler in enumerate(group["hooks"]) for index, handler in enumerate(group["hooks"])
if ( if (
(managed_id := managed_agent_id(handler, script_name)) is not None (
managed_id := managed_agent_id(
handler,
script_name,
include_legacy=True,
)
)
is not None
and (agent_id is None or same_agent_id(managed_id, agent_id)) and (agent_id is None or same_agent_id(managed_id, agent_id))
) )
) )
@@ -350,9 +390,9 @@ def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
} }
def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]: def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
guards = _managed_ids(settings.get("PreToolUse", []), GUARD_SCRIPT) guards = _managed_ids(hooks.get("PreToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(settings.get("PostToolUse", []), AUDIT_SCRIPT) audits = _managed_ids(hooks.get("PostToolUse", []), AUDIT_SCRIPT)
contracts = [] contracts = []
for agent_id in sorted(guards): for agent_id in sorted(guards):
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits): if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
@@ -364,10 +404,3 @@ def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
os.path.join(root, AUDIT_SCRIPT), os.path.join(root, AUDIT_SCRIPT),
)) ))
return contracts return contracts
def merge_hook_settings(
primary: Optional[DroidHookSettings],
fallback: Optional[DroidHookSettings],
) -> DroidHookSettings:
return {**(fallback or {}), **(primary or {})}
@@ -0,0 +1,312 @@
"""Transactional Factory Droid hook installation."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
import sys
from typing import Optional, Sequence, Tuple
from elydora._runtime_paths import resolve_agent_directory
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
from ._transaction import (
FileChange,
FilePrecondition,
file_change,
source_change,
write_changes,
)
from .base import InstallConfig
from .droid_config import (
DroidDocument,
DroidSources,
RenderedDocument,
installation_documents,
source_documents,
)
from .droid_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
elydora_dir,
same_path,
validate_javascript_regexes,
)
from .droid_io import (
require_hooks_enabled,
validate_api_origin,
validate_private_key,
validate_runtime_tree,
)
from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
@dataclass(frozen=True)
class DroidRuntimePaths:
agent_id: str
agent_directory: str
guard_path: str
audit_path: str
@dataclass(frozen=True)
class PreparedDroidInstallation:
changes: Tuple[FileChange, ...]
preconditions: Tuple[FilePrecondition, ...]
@dataclass(frozen=True)
class PreparedDroidUninstall:
changes: Tuple[FileChange, ...]
preconditions: Tuple[FilePrecondition, ...]
def _source_label(document: DroidDocument) -> str:
if document.kind == "settings":
return "Factory Droid user settings"
if document.kind == "local-settings":
return "Factory Droid local settings"
if document.kind == "legacy":
return "Factory Droid legacy hooks"
return "Factory Droid user hooks"
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
value = {
"org_id": config.get("org_id", ""),
"agent_id": agent_id,
"kid": config.get("kid", ""),
"base_url": config.get("base_url", "https://api.elydora.com"),
"agent_name": AGENT_KEY,
}
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 _agent_paths(config: InstallConfig) -> DroidRuntimePaths:
agent_id = config.get("agent_id", "")
if not agent_id:
raise ValueError("agent_id is required")
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
if not same_path(config.get("guard_script_path", ""), guard_path):
raise ValueError(
"Elydora guard runtime must use the managed agent directory: "
f"{guard_path}"
)
return DroidRuntimePaths(
agent_id,
agent_directory,
guard_path,
audit_path,
)
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"Factory Droid installation requires agent_name {AGENT_KEY}"
)
if not os.path.isabs(sys.executable):
raise RuntimeError(
"Factory Droid requires an absolute Python executable path"
)
validate_private_key(config["private_key"])
validate_api_origin(config["base_url"])
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)
def preflight_droid_installation(
config: InstallConfig,
sources: DroidSources,
) -> DroidRuntimePaths:
require_hooks_enabled(sources)
validate_javascript_regexes([
document.hooks for document in source_documents(sources)
])
_validate_install_config(config)
paths = _agent_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id)
return paths
def _runtime_change(
file_path: str,
label: str,
next_source: str,
mode: int,
maximum_bytes: int = MAX_SOURCE_BYTES,
) -> Optional[FileChange]:
return file_change(file_path, label, next_source, mode, maximum_bytes)
def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed:
return None
document = rendered.document
return source_change(
document.file_path,
_source_label(document),
document.raw if document.exists else None,
rendered.next_source,
0o600,
MAX_SOURCE_BYTES,
document.snapshot,
)
def _present(
changes: Sequence[Optional[FileChange]],
) -> Tuple[FileChange, ...]:
return tuple(change for change in changes if change is not None)
def _validate_rendered_documents(
sources: DroidSources,
rendered: Sequence[RenderedDocument],
) -> None:
expected = installation_documents(sources)
if len(rendered) != len(expected):
raise ValueError("Factory Droid rendered source set is incomplete")
for document in expected:
matches = [
item
for item in rendered
if same_path(item.document.file_path, document.file_path)
]
if len(matches) != 1:
raise ValueError(
"Factory Droid rendered source set contains unexpected paths"
)
def _source_preconditions(
sources: DroidSources,
changed_paths: Sequence[str],
) -> Tuple[FilePrecondition, ...]:
return tuple(
FilePrecondition(
document.file_path,
_source_label(document),
document.snapshot,
MAX_SOURCE_BYTES,
)
for document in source_documents(sources)
if not any(
same_path(document.file_path, changed)
for changed in changed_paths
)
)
def prepare_droid_installation(
config: InstallConfig,
sources: DroidSources,
rendered: Sequence[RenderedDocument],
) -> PreparedDroidInstallation:
paths = preflight_droid_installation(config, sources)
_validate_rendered_documents(sources, rendered)
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
audit_script = generate_hook_script(
org_id=config.get("org_id", ""),
agent_id=paths.agent_id,
kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"),
native_payload=True,
agent_name=AGENT_KEY,
)
document_changes = _present(tuple(
_document_change(item) for item in rendered
))
changes = _present((
_runtime_change(
paths.guard_path,
"Elydora guard runtime",
guard_script,
0o700,
),
_runtime_change(
os.path.join(paths.agent_directory, "config.json"),
"Elydora runtime config",
_json_source(_runtime_config(config, paths.agent_id)),
0o600,
MAX_CONFIG_BYTES,
),
_runtime_change(
os.path.join(paths.agent_directory, "private.key"),
"Elydora private key",
config.get("private_key", ""),
0o600,
MAX_SECRET_BYTES,
),
_runtime_change(
paths.audit_path,
"Elydora audit runtime",
audit_script,
0o700,
),
*document_changes,
))
changed_paths = [change.file_path for change in document_changes]
preconditions = (
*_source_preconditions(sources, changed_paths),
*sources.policy.preconditions,
)
return PreparedDroidInstallation(changes, tuple(preconditions))
def commit_droid_installation(
prepared: PreparedDroidInstallation,
) -> None:
write_changes(
prepared.changes,
"Install Factory Droid hooks",
prepared.preconditions,
)
def prepare_droid_uninstall(
rendered: Sequence[RenderedDocument],
) -> PreparedDroidUninstall:
changes = _present(tuple(_document_change(item) for item in rendered))
changed_paths = [change.file_path for change in changes]
preconditions = tuple(
FilePrecondition(
item.document.file_path,
_source_label(item.document),
item.document.snapshot,
MAX_SOURCE_BYTES,
)
for item in rendered
if not any(
same_path(item.document.file_path, changed)
for changed in changed_paths
)
)
return PreparedDroidUninstall(changes, preconditions)
def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None:
write_changes(
prepared.changes,
"Uninstall Factory Droid hooks",
prepared.preconditions,
)
+282 -84
View File
@@ -1,126 +1,324 @@
"""Factory Droid source discovery and runtime inspection.""" """Factory Droid source discovery and managed runtime validation."""
from __future__ import annotations from __future__ import annotations
import json import base64
from dataclasses import dataclass
import os import os
from typing import Optional from typing import Any, Optional
import urllib.parse
from ._transaction import ( from ._managed_files import (
FileChange, MAX_CONFIG_BYTES,
read_optional, MAX_SECRET_BYTES,
regular_file_exists, MAX_SOURCE_BYTES,
source_change, physical_directory_exists,
physical_file_exists,
read_physical_file,
) )
from ._strict_json import JsonObject, parse_json_object
from .droid_config import ( from .droid_config import (
DroidDocument,
DroidSources, DroidSources,
RenderedDocument, active_document,
create_legacy_hook_document,
create_owned_hook_document,
create_settings_document, create_settings_document,
hook_block,
parse_document, parse_document,
) )
from .droid_contract import AGENT_KEY, JsonObject, RuntimeContract, same_agent_id from .droid_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
RuntimeContract,
elydora_dir,
same_agent_id,
same_path,
)
from .droid_policy import read_droid_policy
from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
def _factory_paths() -> tuple[str, str, str]: @dataclass(frozen=True)
class FactoryPaths:
directory: str
root: str
legacy_directory: str
legacy: str
settings: str
local_settings: str
def _factory_paths() -> FactoryPaths:
directory = os.path.join(os.path.expanduser("~"), ".factory") directory = os.path.join(os.path.expanduser("~"), ".factory")
return ( legacy_directory = os.path.join(directory, "hooks")
return FactoryPaths(
directory,
os.path.join(directory, "hooks.json"), os.path.join(directory, "hooks.json"),
os.path.join(directory, "hooks", "hooks.json"), legacy_directory,
os.path.join(legacy_directory, "hooks.json"),
os.path.join(directory, "settings.json"), os.path.join(directory, "settings.json"),
os.path.join(directory, "settings.local.json"),
)
def _read_document(
file_path: str,
kind: str,
label: str,
) -> Optional[DroidDocument]:
snapshot = read_physical_file(file_path, label, MAX_SOURCE_BYTES)
if snapshot is None:
return None
return parse_document(
exists=True,
file_path=file_path,
kind=kind,
raw=snapshot.contents,
snapshot=snapshot,
) )
def read_sources() -> DroidSources: def read_sources() -> DroidSources:
root_path, legacy_path, settings_path = _factory_paths() paths = _factory_paths()
root_raw = read_optional(root_path, "Factory Droid hooks") physical_directory_exists(
settings_raw = read_optional(settings_path, "Factory Droid settings") paths.directory,
primary = None "Factory Droid user configuration directory",
if root_raw is not None:
primary = parse_document(
exists=True,
file_path=root_path,
kind="hooks",
raw=root_raw,
) )
else: physical_directory_exists(
legacy_raw = read_optional(legacy_path, "Factory Droid legacy hooks") paths.legacy_directory,
if legacy_raw is not None: "Factory Droid legacy hooks directory",
primary = parse_document(
exists=True,
file_path=legacy_path,
kind="legacy",
raw=legacy_raw,
) )
settings = ( root = _read_document(paths.root, "hooks", "Factory Droid user hooks")
create_settings_document(settings_path) legacy = _read_document(
if settings_raw is None paths.legacy,
else parse_document( "legacy",
exists=True, "Factory Droid legacy hooks",
file_path=settings_path,
kind="settings",
raw=settings_raw,
) )
settings = _read_document(
paths.settings,
"settings",
"Factory Droid user settings",
) )
return DroidSources(root_path, primary, settings) local_settings = _read_document(
paths.local_settings,
"local-settings",
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]: "Factory Droid local settings",
if not rendered.changed: )
return None return DroidSources(
if rendered.document.kind == "settings": root or create_owned_hook_document(paths.root),
label = "Factory Droid settings" legacy or create_legacy_hook_document(paths.legacy),
elif rendered.document.kind == "legacy": settings or create_settings_document(paths.settings),
label = "Factory Droid legacy hooks" local_settings or create_settings_document(
else: paths.local_settings,
label = "Factory Droid hooks" "local-settings",
return source_change( ),
rendered.document.file_path, read_droid_policy(),
label,
rendered.document.raw if rendered.document.exists else None,
rendered.next_source,
0o600,
) )
def _read_runtime_config(file_path: str) -> Optional[JsonObject]: def require_hooks_enabled(sources: DroidSources) -> None:
raw = read_optional(file_path, "Elydora runtime config") blocked = hook_block(sources)
if raw is None: if blocked is not None:
return None raise ValueError(
"Factory Droid user hooks are disabled by "
f"{blocked.field} in {blocked.label} at {blocked.file_path}"
)
def validate_api_origin(value: str, label: str = "base_url") -> None:
try: try:
value = json.loads(raw) parsed = urllib.parse.urlsplit(value)
except json.JSONDecodeError as error: hostname = parsed.hostname
parsed.port
except ValueError as error:
raise ValueError( raise ValueError(
f"Failed to parse Elydora runtime config at {file_path}: {error}" f"{label} must be an absolute HTTP or HTTPS URL"
) from error ) from error
if not isinstance(value, dict): invalid_character = "\\" in value or any(
character.isspace() or ord(character) < 32 for character in value
)
if (
parsed.scheme not in ("http", "https")
or not parsed.netloc
or hostname is None
or invalid_character
):
raise ValueError(f"{label} 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( raise ValueError(
f"Elydora runtime config at {file_path} must contain a JSON object" f"{label} must exclude credentials, query parameters, and fragments"
)
def validate_private_key(value: str, label: str = "private_key") -> None:
try:
padded = value + "=" * ((4 - len(value) % 4) % 4)
seed = base64.b64decode(
padded.replace("-", "+").replace("_", "/"),
validate=True,
)
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
except (ValueError, UnicodeEncodeError) as error:
raise ValueError(
f"{label} must be a canonical 32-byte base64url value"
) from error
if len(seed) != 32 or canonical != value:
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
def _require_string(value: Any, field: str, config_path: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"Elydora runtime config {field} is invalid: {config_path}"
) )
return value return value
def runtime_files_exist(contracts: list[RuntimeContract]) -> bool: def _validate_runtime_config(
for contract in contracts: config: JsonObject,
agent_directory = os.path.dirname(contract.guard_path) contract: RuntimeContract,
config = _read_runtime_config(os.path.join(agent_directory, "config.json")) config_path: str,
config_agent_id = config.get("agent_id") if config else None ) -> None:
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
extra = next((key for key in config if key not in supported), None)
if extra is not None:
raise ValueError(
f'Elydora runtime config has unsupported field "{extra}": '
f"{config_path}"
)
_require_string(config.get("org_id"), "org_id", config_path)
_require_string(config.get("kid"), "kid", config_path)
agent_id = _require_string(config.get("agent_id"), "agent_id", config_path)
if ( if (
config is None not same_agent_id(agent_id, contract.agent_id)
or config.get("agent_name") != AGENT_KEY or config.get("agent_name") != AGENT_KEY
or not isinstance(config_agent_id, str)
or not same_agent_id(config_agent_id, contract.agent_id)
): ):
continue raise ValueError(
if regular_file_exists( "Elydora runtime identity does not match Factory Droid hooks: "
contract.guard_path, "Elydora guard runtime" f"{config_path}"
) and regular_file_exists(contract.audit_path, "Elydora audit runtime"): )
return True if "token" in config:
_require_string(config.get("token"), "token", config_path)
base_url = _require_string(config.get("base_url"), "base_url", config_path)
validate_api_origin(base_url, "Elydora runtime config base_url")
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_tree(agent_directory: str, agent_id: str) -> None:
root = elydora_dir()
if not physical_directory_exists(root, "Elydora runtime directory"):
return
if not physical_directory_exists(
agent_directory,
"Elydora agent runtime directory",
):
return
config_path = os.path.join(agent_directory, "config.json")
config = _read_runtime_config(config_path)
artifacts = (
("private.key", "Elydora private key"),
(GUARD_SCRIPT, "Elydora guard runtime"),
(AUDIT_SCRIPT, "Elydora audit runtime"),
("chain-state.json", "Elydora chain state"),
("status-cache.json", "Elydora status cache"),
("error.log", "Elydora error log"),
)
artifact_exists = any(
physical_file_exists(os.path.join(agent_directory, name), label)
for name, label in artifacts
)
if config is None:
if artifact_exists:
raise ValueError(
"Elydora runtime identity cannot be verified without config.json: "
f"{agent_directory}"
)
return
configured_id = config.get("agent_id")
if (
not isinstance(configured_id, str)
or not same_agent_id(configured_id, agent_id)
or config.get("agent_name") != AGENT_KEY
):
raise ValueError(
"Elydora runtime config identity does not match Factory Droid agent "
f"{agent_id}: {config_path}"
)
def _valid_contract_paths(contract: RuntimeContract) -> bool:
agent_directory = os.path.dirname(contract.guard_path)
return (
same_path(os.path.dirname(agent_directory), elydora_dir())
and same_path(
contract.guard_path,
os.path.join(agent_directory, GUARD_SCRIPT),
)
and same_path(
contract.audit_path,
os.path.join(agent_directory, AUDIT_SCRIPT),
)
)
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
if not _valid_contract_paths(contract):
return False return False
root = elydora_dir()
agent_directory = os.path.dirname(contract.guard_path)
if not physical_directory_exists(root, "Elydora runtime directory"):
return False
if not physical_directory_exists(
agent_directory,
"Elydora agent runtime directory",
):
return False
config_path = os.path.join(agent_directory, "config.json")
key_path = os.path.join(agent_directory, "private.key")
config = _read_runtime_config(config_path)
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
if config is None or key is None or guard is None or audit is None:
return False
_validate_runtime_config(config, contract, config_path)
validate_private_key(key.contents, "Elydora private key")
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
expected_audit = generate_hook_script(
org_id="",
agent_id=contract.agent_id,
kid="",
base_url="",
native_payload=True,
agent_name=AGENT_KEY,
)
return guard.contents == expected_guard and audit.contents == expected_audit
def runtime_files_exist(contracts: list[RuntimeContract]) -> bool:
return any(_runtime_contract_exists(contract) for contract in contracts)
def display_config_path(sources: DroidSources) -> str: def display_config_path(sources: DroidSources) -> str:
if sources.primary is not None: return active_document(sources).file_path
return sources.primary.file_path
if sources.settings.exists and sources.settings.has_hooks_container:
return sources.settings.file_path
return sources.root_path
+225
View File
@@ -0,0 +1,225 @@
"""Factory Droid read-only hierarchy and organization policy resolution."""
from __future__ import annotations
from dataclasses import dataclass
import os
import stat
import sys
from typing import List, Optional, Tuple
from ._jsonc import JsoncEditor
from ._managed_files import (
FileSnapshot,
MAX_SOURCE_BYTES,
physical_directory_exists,
read_physical_file,
)
from ._transaction import FilePrecondition
@dataclass(frozen=True)
class PolicyLocation:
file_path: str
label: str
@dataclass(frozen=True)
class PolicyLayer:
file_path: str
label: str
snapshot: Optional[FileSnapshot]
hooks_disabled: Optional[bool]
allow_managed_hooks_only: Optional[bool]
show_hook_output: Optional[bool]
@dataclass(frozen=True)
class PolicyOrigin:
file_path: str
label: str
@dataclass(frozen=True)
class DroidPolicyState:
allow_managed_hooks_only_by: Optional[PolicyOrigin]
hooks_disabled: Optional[bool]
hooks_disabled_by: Optional[PolicyOrigin]
preconditions: Tuple[FilePrecondition, ...]
def _optional_boolean(root: dict, field: str, label: str) -> Optional[bool]:
value = root.get(field)
if field in root and not isinstance(value, bool):
raise ValueError(f'{label} field "{field}" must be a boolean')
return value
def _managed_settings_path() -> str:
if sys.platform == "darwin":
return "/Library/Application Support/Factory/settings.json"
if os.name == "nt":
program_files = os.environ.get("ProgramFiles") or r"C:\Program Files"
return os.path.join(program_files, "Factory", "settings.json")
return "/etc/factory/settings.json"
def _read_layer(location: PolicyLocation) -> PolicyLayer:
physical_directory_exists(
os.path.dirname(location.file_path),
f"{location.label} directory",
)
snapshot = read_physical_file(
location.file_path,
location.label,
MAX_SOURCE_BYTES,
)
if snapshot is None:
return PolicyLayer(
location.file_path,
location.label,
None,
None,
None,
None,
)
editor = JsoncEditor(
snapshot.contents,
f"{location.label} at {location.file_path}",
)
root = editor.value
if not isinstance(root, dict):
raise ValueError(
f"{location.label} at {location.file_path} "
"must contain a JSON object"
)
return PolicyLayer(
location.file_path,
location.label,
snapshot,
_optional_boolean(root, "hooksDisabled", location.label),
_optional_boolean(root, "allowManagedHooksOnly", location.label),
_optional_boolean(root, "showHookOutput", location.label),
)
def _git_root(start: str) -> Optional[str]:
current = os.path.abspath(start)
while True:
marker = os.path.join(current, ".git")
try:
metadata = os.lstat(marker)
except (FileNotFoundError, NotADirectoryError):
pass
except OSError as error:
raise OSError(
f"Inspect Factory Droid project marker at {marker}: {error}"
) from error
else:
if stat.S_ISLNK(metadata.st_mode) or not (
stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)
):
raise OSError(
f"Factory Droid project marker is not physical: {marker}"
)
return current
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
def _project_directories(root: str, current: str) -> List[str]:
directories = [root]
try:
relative = os.path.relpath(current, root)
except ValueError:
return directories
if relative in {"", "."} or relative == os.pardir:
return directories
if relative.startswith(os.pardir + os.sep) or os.path.isabs(relative):
return directories
directory = root
for segment in relative.split(os.sep):
if not segment:
continue
directory = os.path.join(directory, segment)
directories.append(directory)
return directories
def _project_locations() -> List[Tuple[PolicyLocation, PolicyLocation]]:
current = os.path.abspath(os.getcwd())
root = _git_root(current) or current
locations = []
for index, directory in enumerate(_project_directories(root, current)):
factory = os.path.join(directory, ".factory")
scope = "project" if index == 0 else f"folder {directory}"
locations.append((
PolicyLocation(
os.path.join(factory, "settings.json"),
f"Factory Droid {scope} settings",
),
PolicyLocation(
os.path.join(factory, "settings.local.json"),
f"Factory Droid {scope} local settings",
),
))
return locations
def _scope_value(layers: Tuple[PolicyLayer, PolicyLayer]) -> Optional[PolicyLayer]:
settings, local = layers
if local.hooks_disabled is not None:
return local
if settings.hooks_disabled is not None:
return settings
return None
def read_droid_policy() -> DroidPolicyState:
managed = _read_layer(PolicyLocation(
_managed_settings_path(),
"Factory Droid system-managed settings",
))
project_layers = [
(_read_layer(scope[0]), _read_layer(scope[1]))
for scope in _project_locations()
]
allow_managed = (
PolicyOrigin(managed.file_path, managed.label)
if managed.allow_managed_hooks_only is True
else None
)
selected = next(
(
layer
for layer in (
managed,
*(_scope_value(scope) for scope in project_layers),
)
if layer is not None and layer.hooks_disabled is not None
),
None,
)
flattened = [managed]
for scope in project_layers:
flattened.extend(scope)
return DroidPolicyState(
allow_managed,
selected.hooks_disabled if selected is not None else None,
(
PolicyOrigin(selected.file_path, selected.label)
if selected is not None
else None
),
tuple(
FilePrecondition(
layer.file_path,
layer.label,
layer.snapshot,
MAX_SOURCE_BYTES,
)
for layer in flattened
),
)
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import base64
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess # nosec B404
import threading
from typing import Any, Dict, Iterable, Optional
import pytest
from elydora.plugins import _jsonc, droid, droid_policy
from elydora.plugins.base import InstallConfig
AGENT_ID = "agent-1"
VALID_PRIVATE_KEY = base64.urlsafe_b64encode(bytes([13]) * 32).rstrip(b"=").decode()
JsonObject = Dict[str, Any]
class DroidFixture:
def __init__(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
root_config: Any = None,
legacy_config: Any = None,
settings: Any = None,
local_settings: Any = None,
project_settings: Any = None,
project_local_settings: Any = None,
base_url: str = "http://127.0.0.1:9",
agent_id: str = AGENT_ID,
) -> None:
self.root_dir = tmp_path
self.home_dir = tmp_path / "home with spaces and 'quote %DROID%"
self.workspace_dir = tmp_path / "workspace with spaces"
self.factory_dir = self.home_dir / ".factory"
self.root_path = self.factory_dir / "hooks.json"
self.legacy_path = self.factory_dir / "hooks" / "hooks.json"
self.settings_path = self.factory_dir / "settings.json"
self.local_settings_path = self.factory_dir / "settings.local.json"
self.project_factory_dir = self.workspace_dir / ".factory"
self.project_settings_path = self.project_factory_dir / "settings.json"
self.project_local_settings_path = (
self.project_factory_dir / "settings.local.json"
)
self.system_settings_path = (
tmp_path / "managed factory" / "settings.json"
)
self.agent_id = agent_id
self.agent_dir = self.home_dir / ".elydora" / agent_id
self.guard_path = self.agent_dir / "guard.py"
self.audit_path = self.agent_dir / "hook.py"
self.runtime_config = self.agent_dir / "config.json"
self.private_key_path = self.agent_dir / "private.key"
self.workspace_dir.mkdir(parents=True)
(self.workspace_dir / ".git").mkdir()
self._write(self.root_path, root_config)
self._write(self.legacy_path, legacy_config)
self._write(self.settings_path, settings)
self._write(self.local_settings_path, local_settings)
self._write(self.project_settings_path, project_settings)
self._write(self.project_local_settings_path, project_local_settings)
monkeypatch.setenv("HOME", str(self.home_dir))
monkeypatch.setenv("USERPROFILE", str(self.home_dir))
monkeypatch.chdir(self.workspace_dir)
monkeypatch.setattr(
droid_policy,
"_managed_settings_path",
lambda: str(self.system_settings_path),
)
self.config: InstallConfig = {
"agent_id": agent_id,
"agent_name": "droid",
"org_id": "org-1",
"private_key": VALID_PRIVATE_KEY,
"kid": "kid-1",
"token": "token-1",
"base_url": base_url,
"guard_script_path": str(self.guard_path),
}
self.plugin = droid.DroidPlugin()
@staticmethod
def _write(path: Path, value: Any) -> None:
if value is None:
return
path.parent.mkdir(parents=True, exist_ok=True)
raw = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
with open(path, "w", encoding="utf-8", newline="") as file:
file.write(raw)
def install(self, **overrides: str) -> None:
config = InstallConfig(**self.config)
config.update(overrides)
self.plugin.install(config)
def write_system_settings(self, value: Any) -> None:
self._write(self.system_settings_path, value)
def load_jsonc(path: Path) -> JsonObject:
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path))
assert isinstance(value, dict)
return value
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
source = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
with open(path, "w", encoding="utf-8", newline="") as file:
file.write(source)
def read_raw(path: Path) -> str:
with open(path, "r", encoding="utf-8", newline="") as file:
return file.read()
def managed_group(
hooks: JsonObject,
event: str,
script_name: str,
) -> Optional[JsonObject]:
for group in hooks.get(event, []):
if any(
script_name in handler.get("command", "")
for handler in group.get("hooks", [])
):
return group
return None
def managed_handler(
hooks: JsonObject,
event: str,
script_name: str,
) -> Optional[JsonObject]:
group = managed_group(hooks, event, script_name)
if group is None:
return None
return next(
handler
for handler in group["hooks"]
if script_name in handler.get("command", "")
)
def assert_native_group(group: Optional[JsonObject]) -> None:
assert group is not None
assert sorted(group) == ["hooks", "matcher"]
assert group["matcher"] == "*"
assert len(group["hooks"]) == 1
handler = group["hooks"][0]
assert sorted(handler) == ["command", "timeout", "type"]
assert handler["type"] == "command"
assert handler["timeout"] == 10
def run_hook(
command: str,
payload: str,
) -> subprocess.CompletedProcess[str]:
argv = (
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
command,
]
if os.name == "nt"
else ["/bin/sh", "-c", command]
)
return subprocess.run( # nosec B603
argv,
input=payload,
text=True,
capture_output=True,
check=False,
timeout=10,
)
def assert_no_transaction_files(root: Path) -> None:
leaked = [
path
for path in root.rglob("*")
if path.suffix in {".tmp", ".rollback"}
]
assert leaked == []
def snapshot(paths: Iterable[Path]) -> Dict[Path, str]:
return {
path: read_raw(path)
for path in paths
}
def assert_snapshot(expected: Dict[Path, str]) -> None:
for path, source in expected.items():
assert read_raw(path) == source
class _ApiHandler(BaseHTTPRequestHandler):
def _respond(self, status: int, payload: str) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload.encode("utf-8"))
def do_GET(self) -> None:
self.server.requests.append(("GET", self.path, b"")) # type: ignore[attr-defined]
self._respond(200, '{"agent":{"status":"active"}}')
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
self.server.requests.append( # type: ignore[attr-defined]
("POST", self.path, body)
)
self._respond(201, '{"operation":{"accepted":true}}')
def log_message(self, _format: str, *args: object) -> None:
del args
class ApiServer:
def __init__(self) -> None:
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
self.server.requests = [] # type: ignore[attr-defined]
self.thread = threading.Thread(
target=self.server.serve_forever,
daemon=True,
)
self.thread.start()
host, port = self.server.server_address
self.base_url = f"http://{host}:{port}"
@property
def requests(self) -> list[tuple[str, str, bytes]]:
return self.server.requests # type: ignore[attr-defined,no-any-return]
def close(self) -> None:
self.server.shutdown()
self.thread.join(timeout=5)
self.server.server_close()
+365 -324
View File
@@ -1,109 +1,37 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess # nosec B404 import sys
from typing import Any, Dict, Optional import time
from typing import Any
import pytest import pytest
from elydora import cli from elydora import cli
from elydora.plugins import _jsonc, droid, droid_io from elydora.plugins import droid
from elydora.plugins.base import InstallConfig
from elydora.plugins.registry import SUPPORTED_AGENTS from elydora.plugins.registry import SUPPORTED_AGENTS
from droid_support import (
AGENT_ID = "agent-1" AGENT_ID,
JsonObject = Dict[str, Any] VALID_PRIVATE_KEY,
ApiServer,
DroidFixture,
class DroidFixture: assert_native_group,
def __init__( assert_no_transaction_files,
self, load_jsonc,
monkeypatch: pytest.MonkeyPatch, managed_group,
tmp_path: Path, managed_handler,
*, read_raw,
hooks: Any = None, run_hook,
legacy_hooks: Any = None, snapshot,
settings: Any = None, write_json,
create_guard: bool = True,
) -> None:
self.home_dir = tmp_path / "home with spaces and 'quote"
self.workspace_dir = self.home_dir / "workspace"
self.factory_dir = self.home_dir / ".factory"
self.config_path = self.factory_dir / "hooks.json"
self.legacy_path = self.factory_dir / "hooks" / "hooks.json"
self.settings_path = self.factory_dir / "settings.json"
self.agent_dir = self.home_dir / ".elydora" / AGENT_ID
self.guard_path = self.agent_dir / "guard.py"
self.audit_path = self.agent_dir / "hook.py"
self.runtime_config = self.agent_dir / "config.json"
self.private_key_path = self.agent_dir / "private.key"
self.workspace_dir.mkdir(parents=True)
self.agent_dir.mkdir(parents=True)
if create_guard:
self.guard_path.write_text(
"import sys\nsys.stdin.read()\n"
"sys.stderr.write('Agent is frozen by Elydora.\\n')\n"
"raise SystemExit(2)\n",
encoding="utf-8",
) )
self._write(self.config_path, hooks)
self._write(self.legacy_path, legacy_hooks)
self._write(self.settings_path, settings)
monkeypatch.setenv("HOME", str(self.home_dir))
monkeypatch.setenv("USERPROFILE", str(self.home_dir))
private_key = base64.urlsafe_b64encode(bytes([1]) * 32).rstrip(b"=").decode()
self.config: InstallConfig = {
"agent_id": AGENT_ID,
"agent_name": "droid",
"org_id": "org-1",
"private_key": private_key,
"kid": "kid-1",
"base_url": "https://api.elydora.test",
"guard_script_path": str(self.guard_path),
}
self.plugin = droid.DroidPlugin()
@staticmethod
def _write(path: Path, value: Any) -> None:
if value is None:
return
path.parent.mkdir(parents=True, exist_ok=True)
raw = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
with open(path, "w", encoding="utf-8", newline="") as file:
file.write(raw)
def install(self) -> None:
self.plugin.install(self.config)
def _load(path: Path) -> JsonObject: def _current_hooks(path: Path) -> dict[str, Any]:
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path)) return load_jsonc(path)["hooks"]
assert isinstance(value, dict)
return value
def _managed_handler(groups: Any, script_path: Path) -> Optional[JsonObject]:
for group in groups or []:
for handler in group["hooks"]:
if str(script_path) in handler.get("command", ""):
return handler
return None
def _run_command(command: str, payload: str) -> subprocess.CompletedProcess[str]:
return subprocess.run( # nosec B602
command,
shell=True,
input=payload,
text=True,
capture_output=True,
check=False,
timeout=10,
)
def test_droid_is_registered_in_the_sdk_and_cli() -> None: def test_droid_is_registered_in_the_sdk_and_cli() -> None:
@@ -115,213 +43,379 @@ def test_droid_is_registered_in_the_sdk_and_cli() -> None:
assert cli.PLUGIN_MAP["droid"] is droid.DroidPlugin assert cli.PLUGIN_MAP["droid"] is droid.DroidPlugin
def test_install_preserves_jsonc_and_uses_per_event_precedence( def test_install_writes_current_container_and_complete_runtime(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
) -> None: ) -> None:
hooks = """{ fixture = DroidFixture(monkeypatch, tmp_path)
// root hook source fixture.install()
output = capsys.readouterr().out
source = fixture.root_path.read_text(encoding="utf-8")
assert source.startswith("// Managed by Elydora\n")
hooks = _current_hooks(fixture.root_path)
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
guard = managed_handler(hooks, "PreToolUse", "guard.py")
assert guard is not None
if os.name == "nt":
assert guard["command"].startswith("& '")
assert guard["command"].endswith("; exit $LASTEXITCODE")
runtime = json.loads(fixture.runtime_config.read_text(encoding="utf-8"))
assert runtime == {
"org_id": "org-1",
"agent_id": AGENT_ID,
"kid": "kid-1",
"base_url": "http://127.0.0.1:9",
"agent_name": "droid",
"token": "token-1",
}
assert fixture.private_key_path.read_text(encoding="utf-8") == VALID_PRIVATE_KEY
assert "AGENT_NAME = 'droid'" in fixture.guard_path.read_text(encoding="utf-8")
assert "NATIVE_PAYLOAD = True" in fixture.audit_path.read_text(encoding="utf-8")
assert "run /hooks" in output
paths = (
fixture.root_path,
fixture.guard_path,
fixture.audit_path,
fixture.runtime_config,
fixture.private_key_path,
)
before = snapshot(paths)
fixture.install()
assert snapshot(paths) == before
assert_no_transaction_files(tmp_path)
@pytest.mark.skipif(os.name != "nt", reason="Windows migration contract")
def test_install_migrates_legacy_windows_commands(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path, root_config={"hooks": {}})
def group(script: Path) -> dict[str, Any]:
return {
"matcher": "*",
"hooks": [{
"type": "command",
"command": f'"{sys.executable}" "{script}"',
"timeout": 10,
}],
}
write_json(fixture.root_path, {
"hooks": {
"PreToolUse": [group(fixture.guard_path)],
"PostToolUse": [group(fixture.audit_path)],
},
})
fixture.install()
hooks = _current_hooks(fixture.root_path)
assert len(hooks["PreToolUse"]) == 1
assert len(hooks["PostToolUse"]) == 1
assert hooks["PreToolUse"][0]["hooks"][0]["command"].startswith("& '")
def test_root_hook_file_has_whole_source_precedence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root = """{
// active root source
"hooks": {
"PreToolUse": [ "PreToolUse": [
// keep root group comment
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] } { "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
], ]
"Notification": [] }
} }
""" """
settings = """{ settings = """{
// general setting // inactive settings source
"theme": "dark", "theme": "dark",
"hooks": { "hooks": {
// settings fallback event
"PostToolUse": [ "PostToolUse": [
// keep settings group comment
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] } { "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
], ]
"showHookOutput": true, }
},
} }
""" """
fixture = DroidFixture(monkeypatch, tmp_path, hooks=hooks, settings=settings) fixture = DroidFixture(
monkeypatch,
tmp_path,
root_config=root,
settings=settings,
)
fixture.install() fixture.install()
fixture.install() root_source = fixture.root_path.read_text(encoding="utf-8")
output = capsys.readouterr().out hooks = _current_hooks(fixture.root_path)
root_raw = fixture.config_path.read_text(encoding="utf-8") assert "active root source" in root_source
settings_raw = fixture.settings_path.read_text(encoding="utf-8") assert hooks["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
root = _load(fixture.config_path) assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
user_settings = _load(fixture.settings_path) assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
assert "root hook source" in root_raw assert fixture.settings_path.read_text(encoding="utf-8") == settings
assert "keep root group comment" in root_raw
assert "general setting" in settings_raw
assert "settings fallback event" in settings_raw
assert "keep settings group comment" in settings_raw
assert len(root["PreToolUse"]) == 2
assert root["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
assert "PostToolUse" not in root
assert user_settings["theme"] == "dark"
assert len(user_settings["hooks"]["PostToolUse"]) == 2
assert "PreToolUse" not in user_settings["hooks"]
for groups, path in (
(root["PreToolUse"], fixture.guard_path),
(user_settings["hooks"]["PostToolUse"], fixture.audit_path),
):
handler = _managed_handler(groups, path)
assert handler is not None
assert sorted(handler) == ["command", "timeout", "type"]
assert handler["type"] == "command"
assert handler["timeout"] == 10
assert "run /hooks" in output
assert not (fixture.workspace_dir / ".factory" / "hooks.json").exists()
def test_install_keeps_active_legacy_source( def test_settings_and_local_settings_follow_source_precedence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
settings = '{\r\n\t"theme": "dark",\r\n\t"hooks": {}\r\n}\r\n'
fixture = DroidFixture(monkeypatch, tmp_path / "base", settings=settings)
fixture.install()
assert not fixture.root_path.exists()
source = read_raw(fixture.settings_path)
hooks = load_jsonc(fixture.settings_path)["hooks"]
assert '\r\n\t\t"PreToolUse"' in source
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
local_fixture = DroidFixture(
monkeypatch,
tmp_path / "local",
settings={"hooks": {"Notification": []}},
local_settings={"hooks": {"SessionStart": []}},
)
base_before = local_fixture.settings_path.read_text(encoding="utf-8")
local_fixture.install()
assert local_fixture.settings_path.read_text(encoding="utf-8") == base_before
local_hooks = load_jsonc(local_fixture.local_settings_path)["hooks"]
assert_native_group(managed_group(local_hooks, "PreToolUse", "guard.py"))
assert_native_group(managed_group(local_hooks, "PostToolUse", "hook.py"))
def test_legacy_hook_file_stays_active_until_factory_migrates_it(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = DroidFixture( fixture = DroidFixture(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
legacy_hooks={"PreToolUse": []}, legacy_config={
"PreToolUse": [{
"matcher": "Read",
"hooks": [{"type": "command", "command": "legacy-user"}],
}],
},
settings={"hooks": {"PostToolUse": []}}, settings={"hooks": {"PostToolUse": []}},
) )
settings_before = fixture.settings_path.read_text(encoding="utf-8")
fixture.install() fixture.install()
assert not fixture.config_path.exists() assert not fixture.root_path.exists()
assert _managed_handler(_load(fixture.legacy_path)["PreToolUse"], fixture.guard_path) legacy = load_jsonc(fixture.legacy_path)
assert _managed_handler( assert_native_group(managed_group(legacy, "PreToolUse", "guard.py"))
_load(fixture.settings_path)["hooks"]["PostToolUse"], assert_native_group(managed_group(legacy, "PostToolUse", "hook.py"))
fixture.audit_path, assert fixture.settings_path.read_text(encoding="utf-8") == settings_before
)
def test_install_reuses_settings_container_and_formatting( def test_guard_blocks_and_audit_preserves_native_payload(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
source = '{\r\n\t"owner": "user",\r\n\t"hooks": {}\r\n}\r\n' api = ApiServer()
fixture = DroidFixture(monkeypatch, tmp_path, settings=source) try:
fixture.install() fixture = DroidFixture(
assert not fixture.config_path.exists() monkeypatch,
with open(fixture.settings_path, "r", encoding="utf-8", newline="") as file: tmp_path,
raw = file.read() base_url=api.base_url,
settings = _load(fixture.settings_path)
assert "\r\n\t\t\"PreToolUse\"" in raw
assert "\r\n\t\t\"PostToolUse\"" in raw
assert settings["owner"] == "user"
def test_commands_block_and_forward_official_input_byte_for_byte(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
capture_path = tmp_path / "captured-event.json"
fixture.audit_path.write_text(
"import pathlib, sys\n"
f"pathlib.Path({str(capture_path)!r}).write_bytes(sys.stdin.buffer.read())\n",
encoding="utf-8",
) )
root = _load(fixture.config_path) fixture.install()
guard = _managed_handler(root["PreToolUse"], fixture.guard_path) hooks = _current_hooks(fixture.root_path)
audit = _managed_handler(root["PostToolUse"], fixture.audit_path) guard = managed_handler(hooks, "PreToolUse", "guard.py")
audit = managed_handler(hooks, "PostToolUse", "hook.py")
assert guard is not None and audit is not None assert guard is not None and audit is not None
pre_payload = json.dumps({ write_json(fixture.agent_dir / "status-cache.json", {
"status": "frozen",
"cached_at": time.time(),
})
pre_payload = {
"session_id": "session-1", "session_id": "session-1",
"transcript_path": str(tmp_path / "transcript.jsonl"), "transcript_path": str(fixture.home_dir / "transcript.jsonl"),
"cwd": str(fixture.workspace_dir), "cwd": str(fixture.workspace_dir),
"permission_mode": "auto-high", "permission_mode": "auto-high",
"hook_event_name": "PreToolUse", "hook_event_name": "PreToolUse",
"tool_name": "Execute", "tool_name": "Execute",
"tool_input": {"command": "echo test"}, "tool_input": {"command": "echo test"},
}) }
guard_result = _run_command(guard["command"], pre_payload) guard_result = run_hook(guard["command"], json.dumps(pre_payload))
assert guard_result.returncode == 2 assert guard_result.returncode == 2
assert "Agent is frozen by Elydora" in guard_result.stderr assert 'Agent "droid" is frozen' in guard_result.stderr
post_payload = json.dumps({
**json.loads(pre_payload), post_payload = {
**pre_payload,
"hook_event_name": "PostToolUse", "hook_event_name": "PostToolUse",
"tool_response": {"output": "test", "success": True}, "tool_response": {"output": "test", "success": True},
}) }
audit_result = _run_command(audit["command"], post_payload) audit_result = run_hook(audit["command"], json.dumps(post_payload))
assert audit_result.returncode == 0, audit_result.stderr assert audit_result.returncode == 0, audit_result.stderr
assert capture_path.read_text(encoding="utf-8") == post_payload request = next(item for item in api.requests if item[0] == "POST")
operation = json.loads(request[2])
assert operation["payload"] == post_payload
assert operation["subject"] == {"session_id": "session-1"}
assert operation["action"] == {"tool": "Execute"}
finally:
api.close()
def test_status_requires_enabled_pair_and_runtime_files( def test_status_requires_exact_hooks_and_runtime_sources(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
assert fixture.plugin.status() == {
"installed": True,
"agent": "droid",
"details": f"Config: {fixture.root_path}",
}
fixture.audit_path.write_text("tampered\n", encoding="utf-8")
status = fixture.plugin.status()
assert status["installed"] is False
assert "incomplete" in status["details"]
@pytest.mark.parametrize(
("mutation", "pattern"),
[
("config", "parse Elydora runtime config"),
("key", "canonical 32-byte"),
],
)
def test_status_surfaces_malformed_runtime_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
mutation: str,
pattern: str,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
target = fixture.runtime_config if mutation == "config" else fixture.private_key_path
target.write_text("{ malformed" if mutation == "config" else "invalid", encoding="utf-8")
with pytest.raises(ValueError, match=pattern):
fixture.plugin.status()
@pytest.mark.parametrize(
("root_config", "legacy_config", "settings", "local_settings", "pattern"),
[
("{ malformed", None, None, None, "parse Factory Droid hooks"),
([], None, None, None, "JSON object"),
('{"hooks":{"PreToolUse":[],"PreToolUse":[]}}', None, None, None, "duplicate"),
({"hooks": {"PreToolUse": None}}, None, None, None, "must be an array"),
({"hooks": {"PreToolUse": [None]}}, None, None, None, "must be an object"),
(
{"hooks": {"PreToolUse": [{"matcher": "[", "hooks": []}]}},
None,
None,
None,
"regular expression",
),
(
{"hooks": {"PreToolUse": [{"hooks": [{
"type": "command",
"command": "",
}]}]}},
None,
None,
None,
"non-empty",
),
(
{"hooks": {"PreToolUse": [{"hooks": [{
"type": "command",
"command": "user",
"timeout": 0,
}]}]}},
None,
None,
None,
"positive",
),
(None, "{ malformed", None, None, "legacy hooks"),
(None, None, {"hooks": None}, None, "JSON object"),
(None, None, None, {"hooksDisabled": "yes"}, "boolean"),
],
)
def test_install_preserves_malformed_sources_before_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
root_config: Any,
legacy_config: Any,
settings: Any,
local_settings: Any,
pattern: str,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
root_config=root_config,
legacy_config=legacy_config,
settings=settings,
local_settings=local_settings,
)
originals = {
path: path.read_text(encoding="utf-8")
for path in (
fixture.root_path,
fixture.legacy_path,
fixture.settings_path,
fixture.local_settings_path,
)
if path.exists()
}
with pytest.raises((ValueError, OSError), match=pattern):
fixture.install()
for path, source in originals.items():
assert path.read_text(encoding="utf-8") == source
assert not fixture.runtime_config.exists()
assert not fixture.root_path.exists() or fixture.root_path in originals
def test_unknown_events_and_extension_fields_remain_valid(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = DroidFixture( fixture = DroidFixture(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
hooks={"PreToolUse": []}, root_config={
settings={"hooks": {"PostToolUse": []}}, "hooks": {
"FutureFactoryEvent": [{
"matcher": ".*",
"commandRegex": "^factory",
"owner": "user",
"hooks": [{
"type": "command",
"command": "user-command",
"timeout": 1,
"future": True,
}],
}],
},
},
) )
fixture.install() fixture.install()
assert fixture.plugin.status()["installed"] is True current = load_jsonc(fixture.root_path)
fixture.audit_path.unlink() assert current["hooks"]["FutureFactoryEvent"][0]["owner"] == "user"
assert fixture.plugin.status()["installed"] is False
root = _load(fixture.config_path)
root["hooksDisabled"] = True
fixture.config_path.write_text(json.dumps(root), encoding="utf-8")
status = fixture.plugin.status()
assert status["installed"] is False
assert "disabled" in status["details"].lower()
def test_uninstall_preserves_user_sources_and_exact_ownership( def test_uninstall_preserves_users_and_exact_ownership(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
hooks = '{\n // keep root comment\n "PreToolUse": []\n}\n' fixture = DroidFixture(
settings = ( monkeypatch,
'{\n "theme": "dark",\n "hooks": {\n' tmp_path,
' // keep settings comment\n "PostToolUse": []\n }\n}\n' root_config={"hooks": {"Notification": []}},
) )
fixture = DroidFixture(monkeypatch, tmp_path, hooks=hooks, settings=settings)
fixture.install() fixture.install()
fixture.plugin.uninstall("agent-10") root = load_jsonc(fixture.root_path)
assert _managed_handler(_load(fixture.config_path)["PreToolUse"], fixture.guard_path) group = managed_group(root["hooks"], "PreToolUse", "guard.py")
uninstall_id = "AGENT-1" if os.name == "nt" else AGENT_ID assert group is not None
fixture.plugin.uninstall(uninstall_id)
root_raw = fixture.config_path.read_text(encoding="utf-8")
settings_raw = fixture.settings_path.read_text(encoding="utf-8")
assert "keep root comment" in root_raw
assert "keep settings comment" in settings_raw
assert _load(fixture.config_path)["PreToolUse"] == []
current_settings = _load(fixture.settings_path)
assert current_settings["hooks"]["PostToolUse"] == []
assert current_settings["theme"] == "dark"
def test_uninstall_deletes_only_an_owned_empty_hook_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
assert fixture.config_path.read_text(encoding="utf-8").startswith(
"// Managed by Elydora"
)
fixture.plugin.uninstall(AGENT_ID)
assert not fixture.config_path.exists()
def test_uninstall_preserves_mixed_groups_and_lookalikes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
root = _load(fixture.config_path)
group = next(
item for item in root["PreToolUse"]
if _managed_handler([item], fixture.guard_path)
)
command = group["hooks"][0]["command"] command = group["hooks"][0]["command"]
group["hooks"].append({"type": "command", "command": "user-command"}) group["hooks"].append({"type": "command", "command": "user-command"})
group["owner"] = "user" root["hooks"]["PreToolUse"].extend([
root["PreToolUse"].extend([
{ {
"matcher": "*", "matcher": "*",
"hooks": [{ "hooks": [{
@@ -339,108 +433,55 @@ def test_uninstall_preserves_mixed_groups_and_lookalikes(
}], }],
}, },
]) ])
fixture.config_path.write_text(json.dumps(root, indent=2) + "\n", encoding="utf-8") write_json(fixture.root_path, root)
fixture.plugin.uninstall(AGENT_ID) fixture.plugin.uninstall("AGENT-1" if os.name == "nt" else AGENT_ID)
remaining = _load(fixture.config_path) remaining = load_jsonc(fixture.root_path)
raw = json.dumps(remaining) raw = json.dumps(remaining)
assert "user-command" in raw assert "user-command" in raw
assert "guard.py.backup" in raw assert "guard.py.backup" in raw
assert "agent-10" in raw assert "agent-10" in raw
assert remaining["PostToolUse"] == [] assert "PostToolUse" not in remaining["hooks"]
@pytest.mark.parametrize( def test_uninstall_removes_owned_empty_file_and_keeps_absence(
("hooks", "legacy", "settings", "pattern"),
[
("{ malformed", None, None, "parse Factory Droid hooks"),
("[]", None, None, "JSON object"),
('{ "PreToolUse": [], "PreToolUse": [] }', None, None, "duplicate"),
({"PreToolUse": None}, None, None, "must be an array"),
({"PreToolUse": [None]}, None, None, "must be an object"),
({"PreToolUse": [{"matcher": "[", "hooks": []}]}, None, None, "regular expression"),
({"PreToolUse": [{"hooks": [{"type": "command", "command": 1}]}]}, None, None, "string"),
(None, "{ malformed", None, "legacy hooks"),
({"PreToolUse": []}, None, "{ malformed", "settings"),
(None, None, {"hooks": None}, "must contain a JSON object"),
],
)
def test_install_preserves_every_malformed_source_before_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
hooks: Any,
legacy: Any,
settings: Any,
pattern: str,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
hooks=hooks,
legacy_hooks=legacy,
settings=settings,
)
targets = [
(fixture.config_path, hooks),
(fixture.legacy_path, legacy),
(fixture.settings_path, settings),
]
originals = {
path: path.read_text(encoding="utf-8") for path, value in targets if value is not None
}
with pytest.raises((ValueError, RuntimeError), match=pattern):
fixture.install()
for path, raw in originals.items():
assert path.read_text(encoding="utf-8") == raw
assert not fixture.audit_path.exists()
assert not fixture.runtime_config.exists()
assert not fixture.private_key_path.exists()
def test_install_rejects_missing_guard_before_creating_files(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = DroidFixture(monkeypatch, tmp_path, create_guard=False) fixture = DroidFixture(monkeypatch, tmp_path / "installed")
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
fixture.install() fixture.install()
assert not fixture.config_path.exists() fixture.plugin.uninstall(AGENT_ID)
assert not fixture.audit_path.exists() assert not fixture.root_path.exists()
assert not fixture.runtime_config.exists()
empty = DroidFixture(monkeypatch, tmp_path / "empty")
empty.plugin.uninstall(AGENT_ID)
assert not empty.root_path.exists()
def test_transaction_rolls_back_all_files_and_cleans_staging( @pytest.mark.parametrize("kind", ["factory", "hook", "runtime"])
def test_install_rejects_linked_paths_before_writes(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
kind: str,
) -> None: ) -> None:
fixture = DroidFixture( fixture = DroidFixture(monkeypatch, tmp_path)
monkeypatch, target = tmp_path / f"{kind}-target"
tmp_path, target.mkdir()
hooks={"PreToolUse": []}, try:
settings={"hooks": {"PostToolUse": []}}, if kind == "factory":
fixture.home_dir.mkdir(parents=True)
fixture.factory_dir.symlink_to(target, target_is_directory=True)
elif kind == "hook":
fixture.factory_dir.mkdir(parents=True)
target_file = target / "hooks.json"
write_json(target_file, {"hooks": {}})
fixture.root_path.symlink_to(target_file)
else:
fixture.home_dir.mkdir(parents=True)
(fixture.home_dir / ".elydora").symlink_to(
target,
target_is_directory=True,
) )
original_root = fixture.config_path.read_text(encoding="utf-8") except OSError as error:
original_settings = fixture.settings_path.read_text(encoding="utf-8") pytest.skip(f"Symbolic links unavailable: {error}")
real_replace = os.replace with pytest.raises(OSError, match="physical (directory|file)"):
failed = False
def fail_settings_commit(source: Any, destination: Any) -> None:
nonlocal failed
if not failed and Path(destination) == fixture.settings_path:
failed = True
raise OSError("simulated settings commit failure")
real_replace(source, destination)
monkeypatch.setattr(droid_io.os, "replace", fail_settings_commit)
with pytest.raises(OSError, match="Write Factory Droid installation"):
fixture.install() fixture.install()
assert failed is True
assert fixture.config_path.read_text(encoding="utf-8") == original_root
assert fixture.settings_path.read_text(encoding="utf-8") == original_settings
assert not fixture.audit_path.exists()
assert not fixture.runtime_config.exists()
assert not fixture.private_key_path.exists()
staging = [
path for path in fixture.home_dir.rglob("*")
if path.suffix in {".tmp", ".rollback"}
]
assert staging == []
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from pathlib import Path
import pytest
from droid_support import DroidFixture, write_json
def test_user_settings_disable_before_runtime_creation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
settings={"hooksDisabled": True},
)
with pytest.raises(ValueError, match="hooksDisabled"):
fixture.install()
assert not fixture.runtime_config.exists()
assert not fixture.root_path.exists()
def test_user_local_settings_override_base_settings(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
settings={"hooksDisabled": True},
local_settings={"hooksDisabled": False},
)
fixture.install()
assert fixture.runtime_config.exists()
def test_legacy_direct_flags_remain_safety_compatible(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
legacy_config={"hooksDisabled": True, "PreToolUse": []},
settings={"hooksDisabled": False},
)
with pytest.raises(ValueError, match="legacy hooks"):
fixture.install()
assert not fixture.runtime_config.exists()
def test_project_policy_has_extension_only_precedence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
blocked = DroidFixture(
monkeypatch,
tmp_path / "blocked",
settings={"hooksDisabled": False},
project_settings={"hooksDisabled": True},
)
with pytest.raises(ValueError, match="project settings"):
blocked.install()
assert not blocked.runtime_config.exists()
allowed = DroidFixture(
monkeypatch,
tmp_path / "allowed",
settings={"hooksDisabled": True},
project_settings={"hooksDisabled": False},
)
allowed.install()
assert allowed.runtime_config.exists()
def test_project_local_settings_override_matching_base(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
project_settings={"hooksDisabled": False},
project_local_settings={"hooksDisabled": True},
)
with pytest.raises(ValueError, match="project local settings"):
fixture.install()
def test_project_value_precedes_deeper_folder_value(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
project_settings={"hooksDisabled": False},
)
child = fixture.workspace_dir / "packages" / "console"
child.mkdir(parents=True)
write_json(
child / ".factory" / "settings.json",
{"hooksDisabled": True},
)
monkeypatch.chdir(child)
fixture.install()
assert fixture.runtime_config.exists()
def test_system_managed_policy_blocks_user_hooks(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.write_system_settings({"allowManagedHooksOnly": True})
with pytest.raises(ValueError, match="allowManagedHooksOnly"):
fixture.install()
assert not fixture.runtime_config.exists()
def test_malformed_read_only_project_policy_is_preserved(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
project_settings="{ malformed",
)
before = fixture.project_settings_path.read_text(encoding="utf-8")
with pytest.raises(ValueError, match="project settings"):
fixture.install()
assert fixture.project_settings_path.read_text(encoding="utf-8") == before
assert not fixture.runtime_config.exists()
assert not fixture.root_path.exists()
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess # nosec B404
import sys
from typing import Any, Dict
import pytest
from elydora.plugins import _transaction, droid
from elydora.plugins.droid_config import render_document, source_documents
from elydora.plugins.droid_installation import (
commit_droid_installation,
commit_droid_uninstall,
preflight_droid_installation,
prepare_droid_installation,
prepare_droid_uninstall,
)
from elydora.plugins.droid_io import read_sources
from droid_support import (
AGENT_ID,
VALID_PRIVATE_KEY,
DroidFixture,
assert_no_transaction_files,
assert_snapshot,
load_jsonc,
snapshot,
write_json,
)
def _prepare_installation(
fixture: DroidFixture,
**overrides: str,
):
config: Dict[str, Any] = dict(fixture.config)
config.update(overrides)
sources = read_sources()
paths = preflight_droid_installation(config, sources)
rendered = droid._render_installation( # noqa: SLF001
sources,
paths.guard_path,
paths.audit_path,
)
return prepare_droid_installation(config, sources, rendered)
def _managed_paths(fixture: DroidFixture) -> tuple[Path, ...]:
return (
fixture.guard_path,
fixture.runtime_config,
fixture.private_key_path,
fixture.audit_path,
fixture.root_path,
)
def test_install_rolls_back_runtime_after_late_hook_commit_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
prepared = _prepare_installation(fixture)
real_replace = _transaction.os.replace
failed = False
def fail_hook_commit(source: Any, destination: Any) -> None:
nonlocal failed
if not failed and Path(destination) == fixture.root_path:
failed = True
raise OSError("injected Droid hook commit failure")
real_replace(source, destination)
monkeypatch.setattr(_transaction.os, "replace", fail_hook_commit)
with pytest.raises(OSError, match="injected Droid hook commit failure"):
commit_droid_installation(prepared)
assert failed is True
for path in _managed_paths(fixture):
assert not path.exists()
assert_no_transaction_files(tmp_path)
def test_concurrent_active_hook_replacement_prevents_runtime_commit(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
before = snapshot(_managed_paths(fixture))
prepared = _prepare_installation(fixture, org_id="org-updated")
concurrent = '{"hooks":{"PreToolUse":[]},"owner":"concurrent"}\n'
fixture.root_path.write_text(concurrent, encoding="utf-8")
with pytest.raises(OSError, match="hooks changed during Install Factory Droid"):
commit_droid_installation(prepared)
for path, source in before.items():
if path != fixture.root_path:
assert path.read_text(encoding="utf-8") == source
assert fixture.root_path.read_text(encoding="utf-8") == concurrent
assert_no_transaction_files(tmp_path)
def test_concurrent_inactive_local_source_creation_is_detected(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
before = snapshot(_managed_paths(fixture))
prepared = _prepare_installation(fixture, org_id="org-updated")
concurrent = '{"hooksDisabled":true}\n'
fixture.local_settings_path.write_text(concurrent, encoding="utf-8")
with pytest.raises(OSError, match="local settings changed during Install"):
commit_droid_installation(prepared)
assert_snapshot(before)
assert fixture.local_settings_path.read_text(encoding="utf-8") == concurrent
assert_no_transaction_files(tmp_path)
def test_concurrent_project_policy_change_is_detected(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
project_settings={"hooksDisabled": False},
)
fixture.install()
before = snapshot(_managed_paths(fixture))
prepared = _prepare_installation(fixture, org_id="org-updated")
concurrent = '{"hooksDisabled":true}\n'
fixture.project_settings_path.write_text(concurrent, encoding="utf-8")
with pytest.raises(OSError, match="project settings changed during Install"):
commit_droid_installation(prepared)
assert_snapshot(before)
assert fixture.project_settings_path.read_text(encoding="utf-8") == concurrent
assert_no_transaction_files(tmp_path)
def test_same_content_replacement_is_rejected_by_physical_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
before = snapshot(_managed_paths(fixture))
sources = read_sources()
original = fixture.root_path.read_text(encoding="utf-8")
fixture.root_path.unlink()
fixture.root_path.write_text(original, encoding="utf-8")
config: Dict[str, Any] = dict(fixture.config)
config["org_id"] = "org-updated"
paths = preflight_droid_installation(config, sources)
rendered = droid._render_installation( # noqa: SLF001
sources,
paths.guard_path,
paths.audit_path,
)
prepared = prepare_droid_installation(config, sources, rendered)
with pytest.raises(OSError, match="hooks changed during Install"):
commit_droid_installation(prepared)
assert fixture.root_path.read_text(encoding="utf-8") == original
for path, source in before.items():
if path != fixture.root_path:
assert path.read_text(encoding="utf-8") == source
assert_no_transaction_files(tmp_path)
def test_orphaned_runtime_is_preserved_without_verifiable_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.agent_dir.mkdir(parents=True)
fixture.guard_path.write_text("orphaned guard\n", encoding="utf-8")
with pytest.raises(ValueError, match="identity cannot be verified"):
fixture.install()
assert fixture.guard_path.read_text(encoding="utf-8") == "orphaned guard\n"
assert not fixture.root_path.exists()
def test_uninstall_rolls_back_all_hook_documents(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path)
fixture.install()
installed = load_jsonc(fixture.root_path)
write_json(
fixture.settings_path,
{"hooks": installed["hooks"], "owner": "user"},
)
paths = (*_managed_paths(fixture), fixture.settings_path)
before = snapshot(paths)
sources = read_sources()
rendered = [
render_document(document, AGENT_ID, {})
for document in source_documents(sources)
]
prepared = prepare_droid_uninstall(rendered)
real_replace = _transaction.os.replace
failed = False
def fail_second_source(source: Any, destination: Any) -> None:
nonlocal failed
if not failed and Path(destination) == fixture.settings_path:
failed = True
raise OSError("injected Droid uninstall failure")
real_replace(source, destination)
monkeypatch.setattr(_transaction.os, "replace", fail_second_source)
with pytest.raises(OSError, match="injected Droid uninstall failure"):
commit_droid_uninstall(prepared)
assert failed is True
assert_snapshot(before)
assert_no_transaction_files(tmp_path)
def test_cli_preflight_blocks_disabled_hooks_before_runtime_creation(
tmp_path: Path,
) -> None:
home_dir = tmp_path / "cli-home"
settings_path = home_dir / ".factory" / "settings.json"
key_path = tmp_path / "private.key"
write_json(settings_path, {"hooksDisabled": True})
key_path.write_text(
VALID_PRIVATE_KEY,
encoding="utf-8",
)
environment = {
**os.environ,
"HOME": str(home_dir),
"USERPROFILE": str(home_dir),
}
result = subprocess.run( # nosec B603
[
sys.executable,
"-m",
"elydora.cli",
"install",
"--agent",
"droid",
"--org_id",
"org-1",
"--agent_id",
AGENT_ID,
"--private_key_file",
str(key_path),
"--kid",
"kid-1",
"--base_url",
"https://api.elydora.com",
],
capture_output=True,
check=False,
cwd=Path(__file__).parents[1],
env=environment,
text=True,
)
assert result.returncode == 1
assert "hooksDisabled" in result.stderr
assert not (home_dir / ".elydora").exists()