feat: add native Factory Droid hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 11:17:52 -04:00
1 parent e839c77a4b
commit b1c73e2754
10 files changed
+2040

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
"""Factory Droid native global user-hook integration."""
from __future__ import annotations
import json
import os
from typing import List, Optional
from .base import AgentPlugin, InstallConfig, PluginStatus
from .droid_config import (
additions_for,
installation_targets,
render_document,
unique_documents,
)
from .droid_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
TOOL_EVENTS,
build_group,
elydora_dir,
merge_hook_settings,
runtime_contracts,
validate_javascript_regexes,
)
from .droid_io import (
FileChange,
display_config_path,
file_change,
read_sources,
rendered_change,
require_runtime,
runtime_files_exist,
write_changes,
)
from .hook_template import generate_hook_script
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 _append_change(changes: List[FileChange], change: Optional[FileChange]) -> None:
if change is not None:
changes.append(change)
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)
sources = read_sources()
validate_javascript_regexes([
sources.primary.hooks if sources.primary is not None else {},
sources.settings.hooks,
])
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}"
)
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,
private_key=config.get("private_key", ""),
kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"),
)
changes: List[FileChange] = []
_append_change(changes, file_change(
os.path.join(agent_directory, "config.json"),
"Elydora runtime config",
json.dumps(runtime_config, indent=2) + "\n",
0o600,
))
_append_change(changes, file_change(
os.path.join(agent_directory, "private.key"),
"Elydora private key",
config.get("private_key", ""),
0o600,
))
_append_change(changes, file_change(
audit_path,
"Elydora audit runtime",
audit_script,
0o700,
))
for item in rendered:
_append_change(changes, rendered_change(item))
write_changes(changes, "Write Factory Droid installation")
locations = ", ".join(
f"{event}: {selected.targets[event].file_path}"
for event in TOOL_EVENTS
)
print(f"Factory Droid: {locations}")
print("Factory Droid: run /hooks to review the Elydora hook changes.")
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")
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)
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)
details = (
f"Config: {config_path}"
if installed
else f"Configured at {config_path}; runtime scripts missing"
)
return PluginStatus(
installed=installed,
agent=AGENT_KEY,
details=details,
)
+262
View File
@@ -0,0 +1,262 @@
"""Factory Droid source selection and source-preserving rendering."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from .droid_contract import (
TOOL_EVENTS,
DroidHookSettings,
JsonObject,
has_own,
managed_removals,
read_hook_settings,
)
from .droid_jsonc import JsonPathPart, JsoncEditor
OWNED_FILE_MARKER = "// Managed by Elydora"
@dataclass(frozen=True)
class DroidDocument:
kind: str
file_path: str
exists: bool
raw: str
root: JsonObject
hooks: DroidHookSettings
base_path: Tuple[JsonPathPart, ...]
has_hooks_container: bool
owned_file: bool
@dataclass(frozen=True)
class DroidSources:
root_path: str
primary: Optional[DroidDocument]
settings: DroidDocument
@dataclass(frozen=True)
class InstallationTargets:
targets: Dict[str, DroidDocument]
created_root: Optional[DroidDocument]
@dataclass(frozen=True)
class RenderedDocument:
document: DroidDocument
changed: bool
next_source: Optional[str]
def _label(kind: str, file_path: str) -> str:
if kind == "settings":
return f"Factory Droid 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 parse_document(
*,
exists: bool,
file_path: str,
kind: str,
raw: str,
) -> 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")
hooks = (
read_hook_settings(root["hooks"], f'{label} field "hooks"')
if has_container
else {}
)
return DroidDocument(
kind,
file_path,
exists,
raw,
root,
hooks,
("hooks",),
has_container,
False,
)
return DroidDocument(
kind,
file_path,
exists,
raw,
root,
read_hook_settings(root, label),
(),
True,
raw.startswith(OWNED_FILE_MARKER),
)
def create_settings_document(file_path: str) -> DroidDocument:
return parse_document(
exists=False,
file_path=file_path,
kind="settings",
raw="{}\n",
)
def create_owned_hook_document(file_path: str) -> DroidDocument:
return parse_document(
exists=False,
file_path=file_path,
kind="hooks",
raw=f"{OWNED_FILE_MARKER}\n{{}}\n",
)
def _hooks_from_editor(
editor: JsoncEditor,
document: DroidDocument,
) -> DroidHookSettings:
root = editor.value
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))
def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]:
return (*document.base_path, event)
def _remove_managed(
editor: JsoncEditor,
document: DroidDocument,
agent_id: Optional[str],
) -> None:
removals = managed_removals(document.hooks, agent_id)
for event in TOOL_EVENTS:
event_removals = sorted(
(item for item in removals if item.event == event),
key=lambda item: item.group_index,
reverse=True,
)
for removal in event_removals:
group_path = (*_event_path(document, event), removal.group_index)
if removal.remove_group:
editor.delete(group_path)
continue
for handler_index in sorted(removal.handler_indexes, reverse=True):
editor.delete((*group_path, "hooks", handler_index))
current = _hooks_from_editor(editor, document)
if document.owned_file and event in current and not current[event]:
editor.delete(_event_path(document, event))
def render_document(
document: DroidDocument,
agent_id: Optional[str],
additions: Dict[str, JsonObject],
) -> RenderedDocument:
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 (
not additions
and document.owned_file
and document.kind != "settings"
and not next_document.hooks
):
return RenderedDocument(document, True, None)
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
if sources.settings.has_hooks_container:
return sources.settings
return create_root.get()
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 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 additions_for(
document: DroidDocument,
targets: Dict[str, 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())
+373
View File
@@ -0,0 +1,373 @@
"""Factory Droid hook schema, command construction, and ownership rules."""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
import os
import shutil
import subprocess # nosec B404
import sys
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
AGENT_KEY = "droid"
GUARD_SCRIPT = "guard.py"
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
_REGEX_VALIDATOR = """import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) {
try {
new RegExp(entry.pattern);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(entry.label + ': ' + message);
process.exit(1);
}
}
"""
JsonObject = Dict[str, Any]
DroidHookSettings = Dict[str, Any]
@dataclass(frozen=True)
class RuntimeContract:
agent_id: str
guard_path: str
audit_path: str
@dataclass(frozen=True)
class ManagedRemoval:
event: str
group_index: int
handler_indexes: Tuple[int, ...]
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 _quote_posix(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
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)}"
def build_group(script_path: str) -> JsonObject:
return {
"matcher": "*",
"hooks": [{
"type": "command",
"command": build_command(script_path),
"timeout": HOOK_TIMEOUT_SECONDS,
}],
}
def _validate_handler(value: Any, label: str) -> JsonObject:
if not is_object(value):
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")
timeout = handler.get("timeout")
if timeout is not None and (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
):
raise ValueError(f"{label} timeout must be a finite number")
return handler
def _validate_group(value: Any, label: str) -> JsonObject:
if not is_object(value):
raise ValueError(f"{label} must be an object")
group = dict(value)
matcher = group.get("matcher")
if "matcher" in group and not isinstance(matcher, str):
raise ValueError(f"{label} matcher must be a string")
command_regex = group.get("commandRegex")
if "commandRegex" in group and not isinstance(command_regex, str):
raise ValueError(f"{label} commandRegex must be a string")
handlers = group.get("hooks")
if not isinstance(handlers, list):
raise ValueError(f"{label} must contain a hooks array")
group["hooks"] = [
_validate_handler(handler, f"{label}.hooks[{index}]")
for index, handler in enumerate(handlers)
]
return group
def read_hook_settings(value: Any, label: str) -> DroidHookSettings:
if not is_object(value):
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)
]
return settings
def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
entries: List[JsonObject] = []
for event, groups in settings.items():
if event in _FLAG_NAMES:
continue
for index, group in enumerate(groups):
matcher = group.get("matcher")
if isinstance(matcher, str) and matcher not in {"", "*"}:
entries.append({
"label": f'Factory Droid hooks field "{event}"[{index}] matcher',
"pattern": matcher,
})
command_regex = group.get("commandRegex")
if isinstance(command_regex, str):
entries.append({
"label": (
f'Factory Droid hooks field "{event}"[{index}] commandRegex'
),
"pattern": command_regex,
})
return entries
def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
entries = [entry for source in settings for entry in _regex_entries(source)]
if not entries:
return
node_path = shutil.which("node")
if node_path is None:
raise FileNotFoundError(
"Node.js runtime is required to validate Factory Droid hook expressions"
)
try:
result = subprocess.run( # nosec B603
[node_path, "--input-type=module", "--eval", _REGEX_VALIDATOR],
input=json.dumps(entries),
text=True,
capture_output=True,
check=False,
timeout=_REGEX_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as error:
raise TimeoutError(
"Factory Droid hook expression validation timed out after "
f"{_REGEX_TIMEOUT_SECONDS} seconds"
) from error
except OSError as error:
raise OSError(f"Run Node.js Factory Droid matcher validator: {error}") from error
if result.returncode == 0:
return
message = (
result.stderr.strip()
or result.stdout.strip()
or f"Node.js exited with code {result.returncode}"
)
raise ValueError(
"Factory Droid matcher must be a valid JavaScript regular expression: "
f"{message}"
)
def _read_windows_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
value += command[index]
index += 1
return None
_POSIX_APOSTROPHE = "'\"'\"'"
def _read_posix_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.startswith(_POSIX_APOSTROPHE, index):
value += "'"
index += len(_POSIX_APOSTROPHE)
continue
if command[index] == "'":
return value, index + 1
value += command[index]
index += 1
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)
if executable is None or executable[1] >= len(command):
return None
if command[executable[1]] != " ":
return None
script = reader(command, executable[1] + 1)
if script is None or script[1] != len(command):
return None
if not executable[0] or not script[0]:
return None
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 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]:
if set(handler) != _HANDLER_KEYS:
return None
if handler.get("type") != "command" or handler.get("timeout") != 10:
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):
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()):
return None
agent_id = os.path.basename(agent_directory)
return agent_id if agent_id not in {"", ".", ".."} else None
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
hooks = group["hooks"]
return (
set(group) == _GROUP_KEYS
and group.get("matcher") == "*"
and bool(indexes)
and len(indexes) == len(hooks)
)
def managed_removals(
settings: DroidHookSettings,
agent_id: Optional[str] = None,
) -> List[ManagedRemoval]:
removals: List[ManagedRemoval] = []
for event, script_name in (
("PreToolUse", GUARD_SCRIPT),
("PostToolUse", AUDIT_SCRIPT),
):
for group_index, group in enumerate(settings.get(event, [])):
indexes = tuple(
index
for index, handler in enumerate(group["hooks"])
if (
(managed_id := managed_agent_id(handler, script_name)) is not None
and (agent_id is None or same_agent_id(managed_id, agent_id))
)
)
if indexes:
removals.append(ManagedRemoval(
event,
group_index,
indexes,
_exact_owned_group(group, indexes),
))
return removals
def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
return {
agent_id
for group in groups
for handler in group["hooks"]
if (agent_id := managed_agent_id(handler, script_name)) is not None
}
def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
guards = _managed_ids(settings.get("PreToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(settings.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):
continue
root = os.path.join(elydora_dir(), agent_id)
contracts.append(RuntimeContract(
agent_id,
os.path.join(root, GUARD_SCRIPT),
os.path.join(root, AUDIT_SCRIPT),
))
return contracts
def merge_hook_settings(
primary: Optional[DroidHookSettings],
fallback: Optional[DroidHookSettings],
) -> DroidHookSettings:
return {**(fallback or {}), **(primary or {})}
+371
View File
@@ -0,0 +1,371 @@
"""Factory Droid source discovery and transactional multi-file I/O."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
import stat
import tempfile
from typing import List, Optional, Sequence
from uuid import uuid4
from .droid_config import (
DroidSources,
RenderedDocument,
create_settings_document,
parse_document,
)
from .droid_contract import AGENT_KEY, JsonObject, RuntimeContract, same_agent_id
@dataclass(frozen=True)
class FileChange:
file_path: str
label: str
original: Optional[str]
next_source: Optional[str]
mode: int
original_mode: Optional[int]
@dataclass
class StagedChange:
change: FileChange
temporary_path: Optional[str]
rollback_path: Optional[str]
committed: bool = False
def _factory_paths() -> tuple[str, str, str]:
directory = os.path.join(os.path.expanduser("~"), ".factory")
return (
os.path.join(directory, "hooks.json"),
os.path.join(directory, "hooks", "hooks.json"),
os.path.join(directory, "settings.json"),
)
def _read_optional(file_path: str, label: str) -> Optional[str]:
try:
with open(file_path, "r", encoding="utf-8", newline="") as file:
return file.read()
except FileNotFoundError:
return None
except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error
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,
)
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,
)
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,
)
)
return DroidSources(root_path, primary, settings)
def _existing_mode(file_path: str) -> Optional[int]:
try:
return stat.S_IMODE(os.stat(file_path).st_mode)
except FileNotFoundError:
return None
except OSError as error:
raise OSError(f"Read file mode at {file_path}: {error}") from error
def file_change(
file_path: str,
label: str,
next_source: Optional[str],
mode: int,
) -> Optional[FileChange]:
original = _read_optional(file_path, label)
if original == next_source:
return None
return FileChange(
file_path,
label,
original,
next_source,
mode,
_existing_mode(file_path),
)
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 FileChange(
rendered.document.file_path,
label,
rendered.document.raw if rendered.document.exists else None,
rendered.next_source,
0o600,
_existing_mode(rendered.document.file_path),
)
def _remove_optional(file_path: Optional[str]) -> None:
if not file_path:
return
try:
os.remove(file_path)
except FileNotFoundError:
return
def _cleanup_paths(paths: Sequence[Optional[str]]) -> List[str]:
errors = []
for path in paths:
try:
_remove_optional(path)
except OSError as error:
errors.append(str(error))
return errors
def _write_staged(
directory: str,
basename: str,
suffix: str,
content: str,
mode: int,
label: str,
) -> str:
descriptor = -1
temporary_path = ""
try:
descriptor, temporary_path = tempfile.mkstemp(
prefix=f".{basename}.",
suffix=suffix,
dir=directory,
text=True,
)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file:
descriptor = -1
file.write(content)
file.flush()
os.fsync(file.fileno())
os.chmod(temporary_path, mode)
return temporary_path
except Exception as error:
failures = []
if descriptor >= 0:
try:
os.close(descriptor)
except OSError as close_error:
failures.append(f"close failed: {close_error}")
failures.extend(_cleanup_paths((temporary_path,)))
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
raise OSError(f"Stage {label}: {error}{suffix}") from error
def _assert_unchanged(change: FileChange) -> None:
current = _read_optional(change.file_path, change.label)
if current != change.original:
raise OSError(f"{change.label} changed during installation: {change.file_path}")
def _stage(change: FileChange) -> StagedChange:
_assert_unchanged(change)
directory = os.path.dirname(change.file_path)
try:
os.makedirs(directory, mode=0o700, exist_ok=True)
except OSError as error:
raise OSError(f"Create directory for {change.label} at {directory}: {error}") from error
basename = os.path.basename(change.file_path)
temporary_path = None
rollback_path = None
try:
if change.next_source is not None:
temporary_path = _write_staged(
directory,
basename,
".tmp",
change.next_source,
change.mode,
change.label,
)
if change.original is not None and change.next_source is not None:
rollback_path = _write_staged(
directory,
basename,
".rollback",
change.original,
change.original_mode or change.mode,
f"{change.label} rollback",
)
elif change.original is not None:
rollback_path = os.path.join(
directory,
f".{basename}.{uuid4().hex}.rollback",
)
if os.path.lexists(rollback_path):
raise FileExistsError(f"Rollback path already exists: {rollback_path}")
return StagedChange(change, temporary_path, rollback_path)
except Exception as error:
failures = _cleanup_paths((temporary_path, rollback_path))
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
raise OSError(f"Stage {change.label}: {error}{suffix}") from error
def _commit(staged: StagedChange) -> None:
_assert_unchanged(staged.change)
if staged.change.next_source is None:
if staged.rollback_path is None:
raise OSError(f"Missing rollback path for {staged.change.label}")
os.replace(staged.change.file_path, staged.rollback_path)
else:
if staged.temporary_path is None:
raise OSError(f"Missing staged file for {staged.change.label}")
os.replace(staged.temporary_path, staged.change.file_path)
staged.committed = True
def _rollback(staged: StagedChange) -> None:
if not staged.committed:
return
if staged.change.original is None:
_remove_optional(staged.change.file_path)
return
if staged.rollback_path is None:
raise OSError(f"Missing rollback data for {staged.change.label}")
os.replace(staged.rollback_path, staged.change.file_path)
def _cleanup(staged: StagedChange) -> None:
_remove_optional(staged.temporary_path)
_remove_optional(staged.rollback_path)
def write_changes(changes: Sequence[FileChange], label: str) -> None:
filtered = [change for change in changes if change.original != change.next_source]
if not filtered:
return
paths = [os.path.normcase(os.path.abspath(change.file_path)) for change in filtered]
if len(paths) != len(set(paths)):
raise ValueError(f"{label} contains duplicate file targets")
staged: List[StagedChange] = []
try:
for change in filtered:
staged.append(_stage(change))
for item in staged:
_commit(item)
except Exception as error:
recovery_errors = []
for item in reversed(staged):
try:
_rollback(item)
except OSError as rollback_error:
recovery_errors.append(str(rollback_error))
for item in staged:
try:
_cleanup(item)
except OSError as cleanup_error:
recovery_errors.append(str(cleanup_error))
suffix = (
f"; recovery failed: {'; '.join(recovery_errors)}"
if recovery_errors
else ""
)
raise OSError(f"{label}: {error}{suffix}") from error
cleanup_errors = []
for item in staged:
try:
_cleanup(item)
except OSError as error:
cleanup_errors.append(str(error))
if cleanup_errors:
raise OSError(f"{label} cleanup failed: {'; '.join(cleanup_errors)}")
def regular_file_exists(file_path: str, label: str) -> bool:
try:
return stat.S_ISREG(os.stat(file_path).st_mode)
except FileNotFoundError:
return False
except OSError as error:
raise OSError(f"Read {label} at {file_path}: {error}") from error
def require_runtime(file_path: str, label: str) -> None:
if not file_path:
raise ValueError(f"{label} path is required")
if not regular_file_exists(file_path, label):
raise FileNotFoundError(f"{label} is missing: {file_path}")
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
raw = _read_optional(file_path, "Elydora runtime config")
if raw is None:
return None
try:
value = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError(
f"Failed to parse Elydora runtime config at {file_path}: {error}"
) from error
if not isinstance(value, dict):
raise ValueError(
f"Elydora runtime config at {file_path} must contain a JSON object"
)
return value
def runtime_files_exist(contracts: Sequence[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
if (
config is None
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
return False
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
+387
View File
@@ -0,0 +1,387 @@
"""Strict JSONC parsing and source-preserving edits for Factory Droid."""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
JsonObject = Dict[str, Any]
JsonPathPart = Union[str, int]
_NUMBER = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
@dataclass(frozen=True)
class JsoncItem:
node: "JsoncNode"
comma: Optional[int]
@dataclass(frozen=True)
class JsoncMember:
key: str
start: int
end: int
value_node: "JsoncNode"
comma: Optional[int]
@dataclass(frozen=True)
class JsoncNode:
kind: str
start: int
end: int
value: Any
members: Tuple[JsoncMember, ...] = ()
items: Tuple[JsoncItem, ...] = ()
class _Parser:
def __init__(self, raw: str, label: str) -> None:
self.raw = raw
self.label = label
self.position = 0
def _error(self, message: str, position: Optional[int] = None) -> ValueError:
offset = self.position if position is None else position
return ValueError(f"Failed to parse {self.label}: {message} at offset {offset}")
def _skip_trivia(self) -> None:
while self.position < len(self.raw):
if self.position == 0 and self.raw.startswith("\ufeff"):
self.position += 1
continue
character = self.raw[self.position]
if character in " \t\r\n":
self.position += 1
continue
if self.raw.startswith("//", self.position):
newline = self.raw.find("\n", self.position + 2)
self.position = len(self.raw) if newline < 0 else newline + 1
continue
if self.raw.startswith("/*", self.position):
end = self.raw.find("*/", self.position + 2)
if end < 0:
raise self._error("unterminated block comment")
self.position = end + 2
continue
return
def _expect(self, character: str) -> None:
if self.position >= len(self.raw) or self.raw[self.position] != character:
raise self._error(f'expected "{character}"')
self.position += 1
def _parse_string(self) -> Tuple[str, int, int]:
start = self.position
self._expect('"')
escaped = False
while self.position < len(self.raw):
character = self.raw[self.position]
self.position += 1
if escaped:
escaped = False
continue
if character == "\\":
escaped = True
continue
if character == '"':
token = self.raw[start:self.position]
try:
value = json.loads(token)
except json.JSONDecodeError as error:
raise self._error(error.msg, start + error.pos) from error
return value, start, self.position
if ord(character) < 0x20:
raise self._error("unescaped control character", self.position - 1)
raise self._error("unterminated string", start)
def _parse_object(self) -> JsoncNode:
start = self.position
self.position += 1
members: List[JsoncMember] = []
value: JsonObject = {}
seen = set()
self._skip_trivia()
if self.position < len(self.raw) and self.raw[self.position] == "}":
self.position += 1
return JsoncNode("object", start, self.position, value)
while True:
self._skip_trivia()
if self.position >= len(self.raw) or self.raw[self.position] != '"':
raise self._error("object key must be a string")
key, member_start, _ = self._parse_string()
if key in seen:
raise self._error(f'duplicate field "{key}"', member_start)
seen.add(key)
self._skip_trivia()
self._expect(":")
node = self._parse_value()
value[key] = node.value
member_end = node.end
self._skip_trivia()
comma: Optional[int] = None
if self.position < len(self.raw) and self.raw[self.position] == ",":
comma = self.position
self.position += 1
self._skip_trivia()
members.append(JsoncMember(
key, member_start, member_end, node, comma
))
if self.position < len(self.raw) and self.raw[self.position] == "}":
self.position += 1
return JsoncNode(
"object", start, self.position, value, tuple(members)
)
continue
members.append(JsoncMember(key, member_start, member_end, node, None))
if self.position >= len(self.raw) or self.raw[self.position] != "}":
raise self._error('expected "," or "}"')
self.position += 1
return JsoncNode("object", start, self.position, value, tuple(members))
def _parse_array(self) -> JsoncNode:
start = self.position
self.position += 1
items: List[JsoncItem] = []
values: List[Any] = []
self._skip_trivia()
if self.position < len(self.raw) and self.raw[self.position] == "]":
self.position += 1
return JsoncNode("array", start, self.position, values)
while True:
node = self._parse_value()
values.append(node.value)
self._skip_trivia()
comma: Optional[int] = None
if self.position < len(self.raw) and self.raw[self.position] == ",":
comma = self.position
self.position += 1
self._skip_trivia()
items.append(JsoncItem(node, comma))
if self.position < len(self.raw) and self.raw[self.position] == "]":
self.position += 1
return JsoncNode(
"array", start, self.position, values, items=tuple(items)
)
continue
items.append(JsoncItem(node, None))
if self.position >= len(self.raw) or self.raw[self.position] != "]":
raise self._error('expected "," or "]"')
self.position += 1
return JsoncNode(
"array", start, self.position, values, items=tuple(items)
)
def _parse_value(self) -> JsoncNode:
self._skip_trivia()
if self.position >= len(self.raw):
raise self._error("expected a value")
start = self.position
character = self.raw[self.position]
if character == "{":
return self._parse_object()
if character == "[":
return self._parse_array()
if character == '"':
value, _, end = self._parse_string()
return JsoncNode("string", start, end, value)
for token, literal in (("true", True), ("false", False), ("null", None)):
if self.raw.startswith(token, self.position):
self.position += len(token)
return JsoncNode("scalar", start, self.position, literal)
match = _NUMBER.match(self.raw, self.position)
if match:
self.position = match.end()
token = match.group(0)
number = float(token) if any(marker in token for marker in ".eE") else int(token)
return JsoncNode("number", start, self.position, number)
raise self._error("invalid value")
def parse(self) -> JsoncNode:
node = self._parse_value()
self._skip_trivia()
if self.position != len(self.raw):
raise self._error("unexpected content")
return node
def parse_jsonc(raw: str, label: str) -> Any:
"""Parse strict JSON plus comments and trailing commas."""
return _Parser(raw, label).parse().value
class JsoncEditor:
"""Apply narrow path edits while retaining untouched source bytes."""
def __init__(self, raw: str, label: str) -> None:
self.raw = raw
self.label = label
self._refresh()
@property
def value(self) -> Any:
return self.root.value
def _refresh(self) -> None:
self.root = _Parser(self.raw, self.label).parse()
def _resolve(self, path: Sequence[JsonPathPart]) -> JsoncNode:
node = self.root
for part in path:
if isinstance(part, str) and node.kind == "object":
member = next((item for item in node.members if item.key == part), None)
if member is None:
raise KeyError(f"Missing JSONC path: {list(path)}")
node = member.value_node
continue
if isinstance(part, int) and node.kind == "array":
if part < 0 or part >= len(node.items):
raise IndexError(f"Invalid JSONC path: {list(path)}")
node = node.items[part].node
continue
raise TypeError(f"Invalid JSONC path: {list(path)}")
return node
def _apply(self, ranges: Sequence[Tuple[int, int, str]]) -> None:
next_raw = self.raw
for start, end, replacement in sorted(ranges, reverse=True):
next_raw = next_raw[:start] + replacement + next_raw[end:]
self.raw = next_raw
self._refresh()
def delete(self, path: Sequence[JsonPathPart]) -> None:
if not path:
raise ValueError("Cannot delete the JSONC root")
parent = self._resolve(path[:-1])
part = path[-1]
ranges: List[Tuple[int, int, str]] = []
if isinstance(part, str) and parent.kind == "object":
index = next(
(position for position, item in enumerate(parent.members) if item.key == part),
-1,
)
if index < 0:
raise KeyError(f"Missing JSONC path: {list(path)}")
member = parent.members[index]
ranges.append((member.start, member.end, ""))
if member.comma is not None:
ranges.append((member.comma, member.comma + 1, ""))
elif index > 0 and parent.members[index - 1].comma is not None:
comma = parent.members[index - 1].comma
if comma is None:
raise RuntimeError("JSONC object separator metadata is missing")
ranges.append((comma, comma + 1, ""))
elif isinstance(part, int) and parent.kind == "array":
if part < 0 or part >= len(parent.items):
raise IndexError(f"Invalid JSONC path: {list(path)}")
item = parent.items[part]
ranges.append((item.node.start, item.node.end, ""))
if item.comma is not None:
ranges.append((item.comma, item.comma + 1, ""))
elif part > 0 and parent.items[part - 1].comma is not None:
comma = parent.items[part - 1].comma
if comma is None:
raise RuntimeError("JSONC array separator metadata is missing")
ranges.append((comma, comma + 1, ""))
else:
raise TypeError(f"Invalid JSONC path: {list(path)}")
self._apply(ranges)
def _eol(self) -> str:
return "\r\n" if "\r\n" in self.raw else "\n"
def _indent_unit(self) -> str:
match = re.search(r"\r?\n([ \t]+)\S", self.raw)
if match is None:
return " "
indentation = match.group(1)
return "\t" if "\t" in indentation else " " * len(indentation)
def _line_indent(self, position: int) -> str:
line_start = self.raw.rfind("\n", 0, position) + 1
prefix = self.raw[line_start:position]
match = re.match(r"[ \t]*", prefix)
if match is None:
raise RuntimeError("JSONC indentation parser failed")
return match.group(0)
def _json_lines(self, value: Any) -> List[str]:
unit = self._indent_unit()
lines = json.dumps(value, ensure_ascii=False, indent=1).splitlines()
converted = []
for line in lines:
depth = len(line) - len(line.lstrip(" "))
converted.append(unit * depth + line[depth:])
return converted
def _insert(
self,
parent: JsoncNode,
lines: List[str],
had_trailing_comma: bool,
) -> None:
close = parent.end - 1
eol = self._eol()
unit = self._indent_unit()
pretty = "\n" in self.raw or "\r" in self.raw
ranges: List[Tuple[int, int, str]] = []
has_existing = bool(parent.members if parent.kind == "object" else parent.items)
if has_existing and not had_trailing_comma:
if parent.kind == "object":
previous_end = parent.members[-1].end
else:
previous_end = parent.items[-1].node.end
ranges.append((previous_end, previous_end, ","))
if pretty:
close_line_start = self.raw.rfind("\n", 0, close) + 1
close_prefix = self.raw[close_line_start:close]
if close_line_start > parent.start and not close_prefix.strip():
insertion_at = close_line_start
parent_indent = close_prefix
prefix = ""
suffix = eol
else:
insertion_at = close
parent_indent = self._line_indent(parent.start)
prefix = eol
suffix = eol + parent_indent
child_indent = parent_indent + unit
content = eol.join(child_indent + line for line in lines)
if had_trailing_comma:
content += ","
insertion = prefix + content + suffix
else:
insertion_at = close
content = " ".join(lines)
if had_trailing_comma:
content += ","
insertion = (" " if has_existing else "") + content
ranges.append((insertion_at, insertion_at, insertion))
self._apply(ranges)
def add_property(
self,
path: Sequence[JsonPathPart],
key: str,
value: Any,
) -> None:
parent = self._resolve(path)
if parent.kind != "object":
raise TypeError(f"JSONC path must be an object: {list(path)}")
if any(member.key == key for member in parent.members):
raise KeyError(f'JSONC field "{key}" already exists')
value_lines = self._json_lines(value)
key_text = json.dumps(key, ensure_ascii=False) + ": "
lines = [key_text + value_lines[0], *value_lines[1:]]
trailing = bool(parent.members and parent.members[-1].comma is not None)
self._insert(parent, lines, trailing)
def append(self, path: Sequence[JsonPathPart], value: Any) -> None:
parent = self._resolve(path)
if parent.kind != "array":
raise TypeError(f"JSONC path must be an array: {list(path)}")
trailing = bool(parent.items and parent.items[-1].comma is not None)
self._insert(parent, self._json_lines(value), trailing)
+5
View File
@@ -36,6 +36,11 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
"hook_event": "PostToolUse",
"config_path": "~/.cursor/hooks.json",
},
"droid": {
"name": "Factory Droid",
"hook_event": "PreToolUse/PostToolUse",
"config_path": "~/.factory/hooks.json",
},
"gemini": {
"name": "Gemini CLI",
"hook_event": "AfterTool",