fix(droid): align hooks with Factory 0.175

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 14:07:56 -04:00
1 parent 16ceac81c4
commit ad315c3655
12 files changed
+2281 -743

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
- Treat Cline 3 CLI file hooks as the adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload, resolve generated Python runtimes from absolute shebangs, and emit one-line JSON stdout with `cancel: true` for guard exit code `2`. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, exact generated sources, and exact wrapper sources. Hook errors, timeouts, and invalid controls fail open in Cline while remaining observable.
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Preserve additive user TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups with ten-second handlers, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
- Resolve GitHub Copilot CLI user hooks through `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default, and treat an empty override as the default. Keep policy, repository, inline settings, cross-tool Claude, and plugin hook sources read-only. Evaluate `disableAllHooks` through legacy user config, user settings, Claude repository and local settings, then GitHub repository and local settings; the managed hook file flag remains independently authoritative. Register exact `preToolUse`, `postToolUse`, and `postToolUseFailure` command handlers with ten-second timeouts, preserve the complete native camelCase payload, and propagate frozen or revoked state through exit code `2`. Commit the user hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical source paths, strict hook schemas, exact runtime sources, canonical private keys, and matching runtime identity during status checks, then verify discovery with the current official Copilot native hook loader and restart active CLI sessions.
- Write Factory Droid hooks only to user-level `~/.factory/hooks.json`, its active legacy fallback, or the `hooks` object in `~/.factory/settings.json`; keep project and organization sources read-only. Preserve JSONC syntax, select sources per event, store direct event maps in hook files, keep settings hooks nested, validate JavaScript matchers before installation, use exact command ownership, and instruct users to review changes through `/hooks`.
- Resolve 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.
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`; keep workspace settings read-only. Preserve comments while rejecting trailing commas and duplicate keys, store hooks under the nested `hooks` object, treat `disableAllHooks` as unhealthy, validate JavaScript matchers before installation, use millisecond timeouts and explicit host shells, preserve PowerShell native exit codes, and instruct users to review changes through `/hooks`.
## Code Quality
+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.
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.
+73 -143
View File
@@ -2,182 +2,112 @@
from __future__ import annotations
import json
import os
from typing import List, Optional
from typing import Dict, List
from ._transaction import FileChange, file_change, require_runtime, write_changes
from .base import AgentPlugin, InstallConfig, PluginStatus
from .droid_config import (
additions_for,
installation_targets,
DroidSources,
RenderedDocument,
active_document,
additions_for_target,
effective_hooks,
hook_block,
installation_documents,
render_document,
unique_documents,
source_documents,
)
from .droid_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
TOOL_EVENTS,
JsonObject,
build_group,
elydora_dir,
merge_hook_settings,
runtime_contracts,
validate_javascript_regexes,
)
from .droid_io import (
display_config_path,
read_sources,
rendered_change,
runtime_files_exist,
from .droid_installation import (
commit_droid_installation,
commit_droid_uninstall,
preflight_droid_installation,
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:
if (
not agent_id
or agent_id in {".", ".."}
or os.path.basename(agent_id) != agent_id
or os.path.isabs(agent_id)
):
raise ValueError("agent_id must be a single non-empty path segment")
return os.path.join(elydora_dir(), agent_id)
def _same_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
os.path.abspath(right)
def _render_installation(
sources: DroidSources,
guard_path: str,
audit_path: str,
) -> List[RenderedDocument]:
target = active_document(sources)
groups: Dict[str, JsonObject] = {
"PreToolUse": build_group(guard_path),
"PostToolUse": build_group(audit_path),
}
return [
render_document(
document,
None,
additions_for_target(document, target, groups),
)
for document in installation_documents(sources)
]
def _append_change(changes: List[FileChange], change: Optional[FileChange]) -> None:
if change is not None:
changes.append(change)
def _render_uninstall(
sources: DroidSources,
agent_id: str,
) -> List[RenderedDocument]:
return [
render_document(document, agent_id or None, {})
for document in source_documents(sources)
]
class DroidPlugin(AgentPlugin):
"""Install Elydora into Factory Droid's user-level hook sources."""
def install(self, config: InstallConfig) -> None:
agent_id = config.get("agent_id", "")
agent_directory = _agent_directory(agent_id)
manages_guard_runtime = True
def preflight_install(self, config: InstallConfig) -> None:
sources = read_sources()
validate_javascript_regexes([
sources.primary.hooks if sources.primary is not None else {},
sources.settings.hooks,
])
preflight_droid_installation(config, sources)
guard_path = config.get("guard_script_path", "")
expected_guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
if not _same_path(guard_path, expected_guard_path):
raise ValueError(
"Elydora guard runtime must use the managed agent directory: "
f"{expected_guard_path}"
def install(self, config: InstallConfig) -> None:
sources = read_sources()
paths = preflight_droid_installation(config, sources)
rendered = _render_installation(
sources,
paths.guard_path,
paths.audit_path,
)
require_runtime(guard_path, "Elydora guard runtime")
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
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}")
prepared = prepare_droid_installation(config, sources, rendered)
commit_droid_installation(prepared)
print(f"Factory Droid hooks: {active_document(sources).file_path}")
print("Factory Droid: run /hooks to review the Elydora hook changes.")
def uninstall(self, agent_id: str = "") -> None:
sources = read_sources()
documents = unique_documents([
sources.primary,
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")
rendered = _render_uninstall(sources, agent_id)
commit_droid_uninstall(prepare_droid_uninstall(rendered))
def status(self) -> PluginStatus:
sources = read_sources()
effective = merge_hook_settings(
sources.primary.hooks if sources.primary is not None else None,
sources.settings.hooks,
)
contracts = runtime_contracts(effective)
contracts = runtime_contracts(effective_hooks(sources))
blocked = hook_block(sources)
configured = blocked is None and bool(contracts)
installed = configured and runtime_files_exist(contracts)
config_path = display_config_path(sources)
if effective.get("hooksDisabled") is True:
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)
if blocked is not None:
details = (
f"Config: {config_path}"
if installed
else f"Configured at {config_path}; runtime scripts missing"
f"Configured hooks are disabled by {blocked.field}: "
f"{blocked.file_path}"
)
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(
installed=installed,
+246 -101
View File
@@ -3,17 +3,20 @@
from __future__ import annotations
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 (
TOOL_EVENTS,
DroidHookSettings,
DroidHookMap,
JsonObject,
has_own,
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"
@@ -25,24 +28,23 @@ class DroidDocument:
file_path: str
exists: bool
raw: str
snapshot: Optional[FileSnapshot]
root: JsonObject
hooks: DroidHookSettings
hooks: DroidHookMap
base_path: Tuple[JsonPathPart, ...]
has_hooks_container: bool
hooks_disabled: Optional[bool]
show_hook_output: Optional[bool]
owned_file: bool
@dataclass(frozen=True)
class DroidSources:
root_path: str
primary: Optional[DroidDocument]
root: DroidDocument
legacy: DroidDocument
settings: DroidDocument
@dataclass(frozen=True)
class InstallationTargets:
targets: Dict[str, DroidDocument]
created_root: Optional[DroidDocument]
local_settings: DroidDocument
policy: DroidPolicyState
@dataclass(frozen=True)
@@ -52,30 +54,65 @@ class RenderedDocument:
next_source: Optional[str]
@dataclass(frozen=True)
class DroidHookBlock:
field: str
file_path: str
label: str
def _label(kind: str, file_path: str) -> str:
if kind == "settings":
return f"Factory Droid settings at {file_path}"
if kind == "local-settings":
return f"Factory Droid local settings at {file_path}"
if kind == "legacy":
return f"Factory Droid legacy 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(
*,
exists: bool,
file_path: str,
kind: str,
raw: str,
snapshot: Optional[FileSnapshot] = None,
) -> DroidDocument:
label = _label(kind, file_path)
editor = JsoncEditor(raw, label)
if not isinstance(editor.value, dict):
raise ValueError(f"{label} must contain a JSON object")
root = editor.value
if kind == "settings":
has_container = has_own(root, "hooks")
if kind in {"settings", "local-settings"}:
has_container = "hooks" in root
hooks = (
read_hook_settings(root["hooks"], f'{label} field "hooks"')
read_hook_map(root["hooks"], f'{label} field "hooks"')
if has_container
else {}
)
@@ -84,30 +121,65 @@ def parse_document(
file_path,
exists,
raw,
snapshot,
root,
hooks,
("hooks",),
has_container,
_optional_boolean(root, "hooksDisabled", label),
_optional_boolean(root, "showHookOutput", label),
False,
)
if "hooks" in root:
hooks = read_hook_map(root["hooks"], f'{label} field "hooks"')
return DroidDocument(
kind,
file_path,
exists,
raw,
snapshot,
root,
read_hook_settings(root, label),
(),
hooks,
("hooks",),
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),
)
def create_settings_document(file_path: str) -> DroidDocument:
def create_settings_document(
file_path: str,
kind: str = "settings",
) -> DroidDocument:
return parse_document(
exists=False,
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",
)
@@ -117,30 +189,40 @@ def create_owned_hook_document(file_path: str) -> DroidDocument:
exists=False,
file_path=file_path,
kind="hooks",
raw=f"{OWNED_FILE_MARKER}\n{{}}\n",
raw=f'{OWNED_FILE_MARKER}\n{{\n "hooks": {{}}\n}}\n',
)
def _hooks_from_editor(
editor: JsoncEditor,
document: DroidDocument,
) -> DroidHookSettings:
) -> DroidHookMap:
root = editor.value
label = _label(document.kind, document.file_path)
if not isinstance(root, dict):
raise ValueError(f"{_label(document.kind, document.file_path)} must be an object")
if document.kind == "settings":
hooks = root.get("hooks")
return read_hook_settings(
hooks,
f'{_label(document.kind, document.file_path)} field "hooks"',
)
return read_hook_settings(root, _label(document.kind, document.file_path))
raise ValueError(f"{label} must contain a JSON object")
if document.kind in {"settings", "local-settings"} or "hooks" in root:
return read_hook_map(root.get("hooks"), f'{label} field "hooks"')
return _legacy_direct_hooks(root, label)[0]
def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]:
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(
editor: JsoncEditor,
document: DroidDocument,
@@ -160,103 +242,166 @@ def _remove_managed(
continue
for handler_index in sorted(removal.handler_indexes, reverse=True):
editor.delete((*group_path, "hooks", handler_index))
if event_removals:
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))
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(
document: DroidDocument,
agent_id: Optional[str],
additions: Dict[str, JsonObject],
) -> RenderedDocument:
editor = JsoncEditor(
document.raw,
_label(document.kind, document.file_path),
)
if additions and _has_exact_installation(document, additions):
return RenderedDocument(document, False, document.raw)
editor = JsoncEditor(document.raw, _label(document.kind, document.file_path))
_remove_managed(editor, document, agent_id)
for event in TOOL_EVENTS:
group = additions.get(event)
if group is None:
continue
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])
next_document = parse_document(
exists=document.exists,
file_path=document.file_path,
kind=document.kind,
raw=editor.raw,
)
if group is not None:
_append_group(editor, document, event, group)
_current_document(document, editor.raw)
if (
not additions
and document.exists
and document.owned_file
and document.kind != "settings"
and not next_document.hooks
and _hook_file_is_empty(document, editor.raw)
):
return RenderedDocument(document, True, None)
return RenderedDocument(
document,
editor.raw != document.raw,
editor.raw,
)
return RenderedDocument(document, editor.raw != document.raw, editor.raw)
def _event_target(
event: str,
sources: DroidSources,
create_root: "_RootFactory",
) -> DroidDocument:
if sources.primary is not None and event in sources.primary.hooks:
return sources.primary
if sources.settings.has_hooks_container and event in sources.settings.hooks:
return sources.settings
if sources.primary is not None:
return sources.primary
def active_document(sources: DroidSources) -> DroidDocument:
if sources.root.exists:
return sources.root
if sources.legacy.exists:
return sources.legacy
if sources.local_settings.has_hooks_container:
return sources.local_settings
if sources.settings.has_hooks_container:
return sources.settings
return create_root.get()
return sources.root
class _RootFactory:
def __init__(self, file_path: str) -> None:
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 effective_hooks(sources: DroidSources) -> DroidHookMap:
return active_document(sources).hooks
def installation_targets(sources: DroidSources) -> InstallationTargets:
root = _RootFactory(sources.root_path)
targets = {
event: _event_target(event, sources, root)
for event in TOOL_EVENTS
}
return InstallationTargets(targets, root.document)
def hook_block(sources: DroidSources) -> Optional[DroidHookBlock]:
managed = sources.policy.allow_managed_hooks_only_by
if managed is not None:
return DroidHookBlock(
"allowManagedHooksOnly",
managed.file_path,
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,
targets: Dict[str, DroidDocument],
target: DroidDocument,
groups: Dict[str, JsonObject],
) -> Dict[str, JsonObject]:
return {
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())
return groups if same_path(document.file_path, target.file_path) else {}
+122 -89
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass
import json
import math
import os
import re
import shutil
import subprocess # nosec B404
import sys
@@ -18,21 +19,10 @@ AUDIT_SCRIPT = "hook.py"
HOOK_TIMEOUT_SECONDS = 10
TOOL_EVENTS = ("PreToolUse", "PostToolUse")
_EVENT_NAMES = {
"PreToolUse",
"PostToolUse",
"Notification",
"UserPromptSubmit",
"Stop",
"SubagentStop",
"PreCompact",
"SessionStart",
"SessionEnd",
}
_FLAG_NAMES = {"hooksDisabled", "showHookOutput"}
_HANDLER_KEYS = {"command", "timeout", "type"}
_GROUP_KEYS = {"hooks", "matcher"}
_REGEX_TIMEOUT_SECONDS = 10
_WINDOWS_EXIT_SUFFIX = "; exit $LASTEXITCODE"
_REGEX_VALIDATOR = """import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) {
@@ -47,7 +37,7 @@ for (const entry of entries) {
"""
JsonObject = Dict[str, Any]
DroidHookSettings = Dict[str, Any]
DroidHookMap = Dict[str, Any]
@dataclass(frozen=True)
@@ -65,20 +55,22 @@ class ManagedRemoval:
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:
return os.path.join(os.path.expanduser("~"), ".elydora")
def _quote_windows(value: str) -> str:
return '"' + value.replace('"', '\\"') + '"'
def same_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
os.path.abspath(right)
)
def same_agent_id(left: str, right: str) -> bool:
return os.path.normcase(left) == os.path.normcase(right)
def _quote_power_shell(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _quote_posix(value: str) -> str:
@@ -86,8 +78,16 @@ def _quote_posix(value: str) -> str:
def build_command(script_path: str) -> str:
quote = _quote_windows if os.name == "nt" else _quote_posix
return f"{quote(sys.executable)} {quote(script_path)}"
if not os.path.isabs(sys.executable) or not os.path.isabs(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:
@@ -102,25 +102,27 @@ def build_group(script_path: 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")
handler = dict(value)
if handler.get("type") != "command":
raise ValueError(f'{label} type must be "command"')
if not isinstance(handler.get("command"), str):
raise ValueError(f"{label} command must be a string")
command = handler.get("command")
if not isinstance(command, str) or not command.strip():
raise ValueError(f"{label} command must be a non-empty string")
timeout = handler.get("timeout")
if timeout is not None and (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
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
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")
group = dict(value)
matcher = group.get("matcher")
@@ -139,31 +141,23 @@ def _validate_group(value: Any, label: str) -> JsonObject:
return group
def read_hook_settings(value: Any, label: str) -> DroidHookSettings:
if not is_object(value):
def read_hook_map(value: Any, label: str) -> DroidHookMap:
if not isinstance(value, dict):
raise ValueError(f"{label} must contain a JSON object")
settings: DroidHookSettings = dict(value)
for key, item in value.items():
if key in _FLAG_NAMES:
if not isinstance(item, bool):
raise ValueError(f'{label} field "{key}" must be a boolean')
continue
if key not in _EVENT_NAMES:
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)
hooks: DroidHookMap = {}
for event, groups in value.items():
if not isinstance(groups, list):
raise ValueError(f'{label} field "{event}" must be an array')
hooks[event] = [
_validate_group(group, f'{label} field "{event}"[{index}]')
for index, group in enumerate(groups)
]
return settings
return hooks
def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
def _regex_entries(hooks: DroidHookMap) -> List[JsonObject]:
entries: List[JsonObject] = []
for event, groups in settings.items():
if event in _FLAG_NAMES:
continue
for event, groups in hooks.items():
for index, group in enumerate(groups):
matcher = group.get("matcher")
if isinstance(matcher, str) and matcher not in {"", "*"}:
@@ -182,8 +176,8 @@ def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
return entries
def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
entries = [entry for source in settings for entry in _regex_entries(source)]
def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
entries = [entry for source in sources for entry in _regex_entries(source)]
if not entries:
return
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]]:
if start >= len(command) or command[start] != '"':
def _read_power_shell_argument(
command: str,
start: int,
) -> Optional[Tuple[str, int]]:
if start >= len(command) or command[start] != "'":
return None
value = ""
index = start + 1
while index < len(command):
if command[index:index + 2] == '\\"':
value += '"'
index += 2
continue
if command[index] == '"':
return value, index + 1
if command[index] != "'":
value += command[index]
index += 1
continue
if index + 1 < len(command) and command[index + 1] == "'":
value += "'"
index += 2
continue
return value, index + 1
return None
@@ -257,14 +255,16 @@ def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
return None
def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]:
reader = _read_windows_argument if os.name == "nt" else _read_posix_argument
executable = reader(command, 0)
def _parse_two_arguments(
command: str,
start: int,
) -> Optional[Tuple[str, str]]:
executable = _read_posix_argument(command, start)
if executable is None or executable[1] >= len(command):
return None
if command[executable[1]] != " ":
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):
return None
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]
def _same_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
os.path.abspath(right)
def _parse_power_shell_command(command: str) -> Optional[Tuple[str, str]]:
if not command.startswith("& "):
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:
return os.path.normcase(left) == os.path.normcase(right)
def managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]:
def managed_agent_id(
handler: JsonObject,
script_name: str,
include_legacy: bool = False,
) -> Optional[str]:
if set(handler) != _HANDLER_KEYS:
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
command = handler.get("command")
if not isinstance(command, str):
return None
parsed = _parse_generated_command(command)
if parsed is None or not _same_path(parsed[0], sys.executable):
parsed = _parse_generated_command(command, include_legacy)
if parsed is None or not same_path(parsed[0], sys.executable):
return None
script_path = parsed[1]
if os.path.basename(script_path) != script_name:
return None
agent_directory = os.path.dirname(script_path)
if not _same_path(os.path.dirname(agent_directory), elydora_dir()):
if not same_path(os.path.dirname(agent_directory), elydora_dir()):
return None
agent_id = os.path.basename(agent_directory)
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(
settings: DroidHookSettings,
hooks: DroidHookMap,
agent_id: Optional[str] = None,
) -> List[ManagedRemoval]:
removals: List[ManagedRemoval] = []
@@ -322,12 +355,19 @@ def managed_removals(
("PreToolUse", GUARD_SCRIPT),
("PostToolUse", AUDIT_SCRIPT),
):
for group_index, group in enumerate(settings.get(event, [])):
for group_index, group in enumerate(hooks.get(event, [])):
indexes = tuple(
index
for index, handler in enumerate(group["hooks"])
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))
)
)
@@ -350,9 +390,9 @@ def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
}
def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
guards = _managed_ids(settings.get("PreToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(settings.get("PostToolUse", []), AUDIT_SCRIPT)
def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
guards = _managed_ids(hooks.get("PreToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(hooks.get("PostToolUse", []), AUDIT_SCRIPT)
contracts = []
for agent_id in sorted(guards):
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),
))
return contracts
def merge_hook_settings(
primary: Optional[DroidHookSettings],
fallback: Optional[DroidHookSettings],
) -> DroidHookSettings:
return {**(fallback or {}), **(primary or {})}
+312
View File
@@ -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
import json
import base64
from dataclasses import dataclass
import os
from typing import Optional
from typing import Any, Optional
import urllib.parse
from ._transaction import (
FileChange,
read_optional,
regular_file_exists,
source_change,
from ._managed_files import (
MAX_CONFIG_BYTES,
MAX_SECRET_BYTES,
MAX_SOURCE_BYTES,
physical_directory_exists,
physical_file_exists,
read_physical_file,
)
from ._strict_json import JsonObject, parse_json_object
from .droid_config import (
DroidDocument,
DroidSources,
RenderedDocument,
active_document,
create_legacy_hook_document,
create_owned_hook_document,
create_settings_document,
hook_block,
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")
return (
legacy_directory = os.path.join(directory, "hooks")
return FactoryPaths(
directory,
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.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:
root_path, legacy_path, settings_path = _factory_paths()
root_raw = read_optional(root_path, "Factory Droid hooks")
settings_raw = read_optional(settings_path, "Factory Droid settings")
primary = None
if root_raw is not None:
primary = parse_document(
exists=True,
file_path=root_path,
kind="hooks",
raw=root_raw,
paths = _factory_paths()
physical_directory_exists(
paths.directory,
"Factory Droid user configuration directory",
)
else:
legacy_raw = read_optional(legacy_path, "Factory Droid legacy hooks")
if legacy_raw is not None:
primary = parse_document(
exists=True,
file_path=legacy_path,
kind="legacy",
raw=legacy_raw,
physical_directory_exists(
paths.legacy_directory,
"Factory Droid legacy hooks directory",
)
settings = (
create_settings_document(settings_path)
if settings_raw is None
else parse_document(
exists=True,
file_path=settings_path,
kind="settings",
raw=settings_raw,
root = _read_document(paths.root, "hooks", "Factory Droid user hooks")
legacy = _read_document(
paths.legacy,
"legacy",
"Factory Droid legacy hooks",
)
settings = _read_document(
paths.settings,
"settings",
"Factory Droid user settings",
)
return DroidSources(root_path, primary, settings)
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed:
return None
if rendered.document.kind == "settings":
label = "Factory Droid settings"
elif rendered.document.kind == "legacy":
label = "Factory Droid legacy hooks"
else:
label = "Factory Droid hooks"
return source_change(
rendered.document.file_path,
label,
rendered.document.raw if rendered.document.exists else None,
rendered.next_source,
0o600,
local_settings = _read_document(
paths.local_settings,
"local-settings",
"Factory Droid local settings",
)
return DroidSources(
root or create_owned_hook_document(paths.root),
legacy or create_legacy_hook_document(paths.legacy),
settings or create_settings_document(paths.settings),
local_settings or create_settings_document(
paths.local_settings,
"local-settings",
),
read_droid_policy(),
)
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
raw = read_optional(file_path, "Elydora runtime config")
if raw is None:
return None
def require_hooks_enabled(sources: DroidSources) -> None:
blocked = hook_block(sources)
if blocked is not 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:
value = json.loads(raw)
except json.JSONDecodeError as error:
parsed = urllib.parse.urlsplit(value)
hostname = parsed.hostname
parsed.port
except ValueError as error:
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
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(
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
def runtime_files_exist(contracts: list[RuntimeContract]) -> bool:
for contract in contracts:
agent_directory = os.path.dirname(contract.guard_path)
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
config_agent_id = config.get("agent_id") if config else None
def _validate_runtime_config(
config: JsonObject,
contract: RuntimeContract,
config_path: str,
) -> 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 (
config is None
not same_agent_id(agent_id, contract.agent_id)
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
if regular_file_exists(
contract.guard_path, "Elydora guard runtime"
) and regular_file_exists(contract.audit_path, "Elydora audit runtime"):
return True
raise ValueError(
"Elydora runtime identity does not match Factory Droid hooks: "
f"{config_path}"
)
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
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:
if sources.primary is not None:
return sources.primary.file_path
if sources.settings.exists and sources.settings.has_hooks_container:
return sources.settings.file_path
return sources.root_path
return active_document(sources).file_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
import base64
import json
import os
from pathlib import Path
import subprocess # nosec B404
from typing import Any, Dict, Optional
import sys
import time
from typing import Any
import pytest
from elydora import cli
from elydora.plugins import _jsonc, droid, droid_io
from elydora.plugins.base import InstallConfig
from elydora.plugins import droid
from elydora.plugins.registry import SUPPORTED_AGENTS
AGENT_ID = "agent-1"
JsonObject = Dict[str, Any]
class DroidFixture:
def __init__(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
hooks: Any = None,
legacy_hooks: Any = None,
settings: Any = None,
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",
from droid_support import (
AGENT_ID,
VALID_PRIVATE_KEY,
ApiServer,
DroidFixture,
assert_native_group,
assert_no_transaction_files,
load_jsonc,
managed_group,
managed_handler,
read_raw,
run_hook,
snapshot,
write_json,
)
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:
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path))
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 _current_hooks(path: Path) -> dict[str, Any]:
return load_jsonc(path)["hooks"]
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
def test_install_preserves_jsonc_and_uses_per_event_precedence(
def test_install_writes_current_container_and_complete_runtime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
hooks = """{
// root hook source
fixture = DroidFixture(monkeypatch, tmp_path)
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": [
// keep root group comment
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
],
"Notification": []
]
}
}
"""
settings = """{
// general setting
// inactive settings source
"theme": "dark",
"hooks": {
// settings fallback event
"PostToolUse": [
// keep settings group comment
{ "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()
output = capsys.readouterr().out
root_raw = fixture.config_path.read_text(encoding="utf-8")
settings_raw = fixture.settings_path.read_text(encoding="utf-8")
root = _load(fixture.config_path)
user_settings = _load(fixture.settings_path)
assert "root hook source" in root_raw
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()
root_source = fixture.root_path.read_text(encoding="utf-8")
hooks = _current_hooks(fixture.root_path)
assert "active root source" in root_source
assert hooks["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
assert fixture.settings_path.read_text(encoding="utf-8") == settings
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,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
legacy_hooks={"PreToolUse": []},
legacy_config={
"PreToolUse": [{
"matcher": "Read",
"hooks": [{"type": "command", "command": "legacy-user"}],
}],
},
settings={"hooks": {"PostToolUse": []}},
)
settings_before = fixture.settings_path.read_text(encoding="utf-8")
fixture.install()
assert not fixture.config_path.exists()
assert _managed_handler(_load(fixture.legacy_path)["PreToolUse"], fixture.guard_path)
assert _managed_handler(
_load(fixture.settings_path)["hooks"]["PostToolUse"],
fixture.audit_path,
)
assert not fixture.root_path.exists()
legacy = load_jsonc(fixture.legacy_path)
assert_native_group(managed_group(legacy, "PreToolUse", "guard.py"))
assert_native_group(managed_group(legacy, "PostToolUse", "hook.py"))
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,
tmp_path: Path,
) -> None:
source = '{\r\n\t"owner": "user",\r\n\t"hooks": {}\r\n}\r\n'
fixture = DroidFixture(monkeypatch, tmp_path, settings=source)
fixture.install()
assert not fixture.config_path.exists()
with open(fixture.settings_path, "r", encoding="utf-8", newline="") as file:
raw = file.read()
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",
api = ApiServer()
try:
fixture = DroidFixture(
monkeypatch,
tmp_path,
base_url=api.base_url,
)
root = _load(fixture.config_path)
guard = _managed_handler(root["PreToolUse"], fixture.guard_path)
audit = _managed_handler(root["PostToolUse"], fixture.audit_path)
fixture.install()
hooks = _current_hooks(fixture.root_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
pre_payload = json.dumps({
write_json(fixture.agent_dir / "status-cache.json", {
"status": "frozen",
"cached_at": time.time(),
})
pre_payload = {
"session_id": "session-1",
"transcript_path": str(tmp_path / "transcript.jsonl"),
"transcript_path": str(fixture.home_dir / "transcript.jsonl"),
"cwd": str(fixture.workspace_dir),
"permission_mode": "auto-high",
"hook_event_name": "PreToolUse",
"tool_name": "Execute",
"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 "Agent is frozen by Elydora" in guard_result.stderr
post_payload = json.dumps({
**json.loads(pre_payload),
assert 'Agent "droid" is frozen' in guard_result.stderr
post_payload = {
**pre_payload,
"hook_event_name": "PostToolUse",
"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 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,
tmp_path: Path,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
hooks={"PreToolUse": []},
settings={"hooks": {"PostToolUse": []}},
root_config={
"hooks": {
"FutureFactoryEvent": [{
"matcher": ".*",
"commandRegex": "^factory",
"owner": "user",
"hooks": [{
"type": "command",
"command": "user-command",
"timeout": 1,
"future": True,
}],
}],
},
},
)
fixture.install()
assert fixture.plugin.status()["installed"] is True
fixture.audit_path.unlink()
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()
current = load_jsonc(fixture.root_path)
assert current["hooks"]["FutureFactoryEvent"][0]["owner"] == "user"
def test_uninstall_preserves_user_sources_and_exact_ownership(
def test_uninstall_preserves_users_and_exact_ownership(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
hooks = '{\n // keep root comment\n "PreToolUse": []\n}\n'
settings = (
'{\n "theme": "dark",\n "hooks": {\n'
' // keep settings comment\n "PostToolUse": []\n }\n}\n'
fixture = DroidFixture(
monkeypatch,
tmp_path,
root_config={"hooks": {"Notification": []}},
)
fixture = DroidFixture(monkeypatch, tmp_path, hooks=hooks, settings=settings)
fixture.install()
fixture.plugin.uninstall("agent-10")
assert _managed_handler(_load(fixture.config_path)["PreToolUse"], fixture.guard_path)
uninstall_id = "AGENT-1" if os.name == "nt" else AGENT_ID
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)
)
root = load_jsonc(fixture.root_path)
group = managed_group(root["hooks"], "PreToolUse", "guard.py")
assert group is not None
command = group["hooks"][0]["command"]
group["hooks"].append({"type": "command", "command": "user-command"})
group["owner"] = "user"
root["PreToolUse"].extend([
root["hooks"]["PreToolUse"].extend([
{
"matcher": "*",
"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")
fixture.plugin.uninstall(AGENT_ID)
remaining = _load(fixture.config_path)
write_json(fixture.root_path, root)
fixture.plugin.uninstall("AGENT-1" if os.name == "nt" else AGENT_ID)
remaining = load_jsonc(fixture.root_path)
raw = json.dumps(remaining)
assert "user-command" in raw
assert "guard.py.backup" in raw
assert "agent-10" in raw
assert remaining["PostToolUse"] == []
assert "PostToolUse" not in remaining["hooks"]
@pytest.mark.parametrize(
("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(
def test_uninstall_removes_owned_empty_file_and_keeps_absence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = DroidFixture(monkeypatch, tmp_path, create_guard=False)
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
fixture = DroidFixture(monkeypatch, tmp_path / "installed")
fixture.install()
assert not fixture.config_path.exists()
assert not fixture.audit_path.exists()
assert not fixture.runtime_config.exists()
fixture.plugin.uninstall(AGENT_ID)
assert not fixture.root_path.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,
tmp_path: Path,
kind: str,
) -> None:
fixture = DroidFixture(
monkeypatch,
tmp_path,
hooks={"PreToolUse": []},
settings={"hooks": {"PostToolUse": []}},
fixture = DroidFixture(monkeypatch, tmp_path)
target = tmp_path / f"{kind}-target"
target.mkdir()
try:
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")
original_settings = fixture.settings_path.read_text(encoding="utf-8")
real_replace = os.replace
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"):
except OSError as error:
pytest.skip(f"Symbolic links unavailable: {error}")
with pytest.raises(OSError, match="physical (directory|file)"):
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()