diff --git a/AGENTS.md b/AGENTS.md index 2dab16f..0cc61c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,9 +93,15 @@ CODEC supports user-authored Python plugins at `~/.codec/plugins/*.py` that regi **Audit:** every successful hook fire emits `hook_fired`; plugin-internal exceptions emit `hook_error` with `level="warning"` (operation still succeeded). `correlation_id` inherits from the wrapping operation per Step 1 §1.4 — never regenerated. -**Trust model:** local Python files curated by the user. No marketplace, no auto-install, no inter-plugin sandbox. Same trust model as skills. +**Trust model (PR-2F, closes D-18):** local Python files curated by the user, plus a SHA-256 allowlist gate + daemon-thread timeout. Same chokepoint pattern as PR-1A's `skills/.manifest.json` for skills: -Implementation: `codec_hooks.py` (run_with_hooks + PluginRegistry), wired into `codec_dispatch.py:run_skill` (covers wake-word + chat pre-LLM + chat post-LLM tag), `codec_agents.py:Agent.run` (crew), `codec_voice.py:dispatch_skill` (voice WebSocket), `codec_mcp.py:tool_fn` (MCP stdio + HTTP). Voice WebSocket also fires `emit_operation_start` / `emit_operation_end` from `VoicePipeline.run` start/finally. +- **`~/.codec/plugins.allowlist`** (JSON, 0600, atomic-write) keys plugins by basename → `{sha256, approved_at, approved_by}`. `PluginRegistry.get_fn` computes the file's current SHA-256 and refuses to call `exec_module` if the hash isn't in the allowlist OR has changed. Refusal → `plugin_load_blocked` audit (`reason ∈ {not_in_allowlist, hash_mismatch, file_unreadable}`, optional `detail` field with the AST result from `is_dangerous_skill_code` for forensic clarity). +- **Allowlist IS the trust decision, AST IS NOT.** The same pattern as PR-1A: a plugin that imports `subprocess` (e.g. `self_improve.py`) would fail the AST check, but its hash is in the allowlist so it loads. AST is only consulted to enrich the audit emit for refused plugins. +- **Grandfather migration.** First `scan()` after PR-2F lands with no allowlist file present + plugins in `~/.codec/plugins/*.py` writes their current hashes to the allowlist with `approved_by: "initial_migration"` + emits `plugin_allowlist_migrated`. Idempotent — subsequent scans no-op. +- **Daemon-thread timeout.** Every hook fire runs inside `threading.Thread(daemon=True)` with a hard timeout (default 500ms, configurable via `~/.codec/config.json:plugin_hook_timeout_ms`). On timeout: `plugin_hook_timeout` audit, return `None`, calling thread continues. Daemon=True so process shutdown isn't blocked. +- **Operator approval flow.** New skill `plugin_approve` (`SKILL_MCP_EXPOSE=False`) — invoked via chat "approve plugin newhook.py" — computes the file's SHA-256, writes the allowlist entry with `approved_by: "operator"`, clears any prior refusal cache, emits `plugin_approved`. The skill is NEVER MCP-exposed (claude.ai can't extend the plugin trust boundary). + +Implementation: `codec_hooks.py` (run_with_hooks + PluginRegistry — now owns its `allowlist_path` for per-test isolation), wired into `codec_dispatch.py:run_skill` (covers wake-word + chat pre-LLM + chat post-LLM tag), `codec_agents.py:Agent.run` (crew), `codec_voice.py:dispatch_skill` (voice WebSocket), `codec_mcp.py:tool_fn` (MCP stdio + HTTP). Voice WebSocket also fires `emit_operation_start` / `emit_operation_end` from `VoicePipeline.run` start/finally. ### AskUserQuestion + stuck detection + step budget (Phase 1 Step 3) @@ -818,6 +824,8 @@ These zones break running infrastructure if changed without coordination. NEVER - `~/.codec/oauth_state.json` — clearing this invalidates ALL claude.ai connections; touch only after explicit user OK + `pm2 restart codec-mcp-http` - Keychain entry `ai.avadigital.codec.internal_token` (Phase 1 Wave 2, PR-2D) — deleting forces a full token regen across every CODEC daemon. Heartbeat / scheduler / skills miss for ≤30s while their cache misses; `internal_token_autogenerated` audits emit. Use only for rotation. The legacy `X-Internal: codec` literal header is no longer recognized anywhere in `AuthMiddleware` — restoring it would re-open D-11. - Keychain entry `ai.avadigital.codec.audit_hmac_secret` (Phase 1 Wave 2, PR-2E) — backs HMAC-SHA256 signing of `~/.codec/audit.log`. Deleting it forces a fresh bootstrap on next write; subsequent `verify_audit_log()` will count lines signed by the OLD secret as `broken` (stdlib WARNING at bootstrap time tells the operator). Rotate only as part of a planned forensic cycle (export the old log first via the verify utility). NEVER call `codec_audit.log_event` from inside `codec_keychain.get_audit_hmac_secret()` — circular write path → deadlock on the non-reentrant `_LOCK`. The silent bootstrap path in PR-2E exists exactly to avoid this. +- `~/.codec/plugins.allowlist` (Phase 1 Wave 2, PR-2F) — SHA-256 allowlist that gates plugin loading. Deleting it forces every plugin in `~/.codec/plugins/` to fall through grandfather migration on next scan, which re-adds them all with `approved_by: "initial_migration"` — effectively a "trust everything currently in the dir again" reset. Use cautiously: if a malicious file is in the plugins dir at the time of deletion, the migration grandfathers it too. Atomic-write contract — never hand-edit; use the `plugin_approve` skill (operator-only, `SKILL_MCP_EXPOSE=False`) or `codec_hooks.approve_plugin(filename)`. File perms enforced at 0600. +- `~/.codec/config.json:plugin_hook_timeout_ms` (Phase 1 Wave 2, PR-2F) — default 500ms. Lower than ~50ms and legitimate hooks (notably the synchronous body of `self_improve.py`'s `on_operation_end`, which does a snapshot + daemon-thread spawn) start tripping the timeout. Higher than ~2000ms reintroduces DoS risk via a malicious hook blocking the calling thread. Keep in the [100, 1000] band unless surfaced. - `~/.codec/audit.log` (Phase 1 Wave 2, PR-2E) — tampering with existing lines or appending forged lines is detected by HMAC verification (`verify_audit_log()` / `audit_verify` skill). Whole-line deletion is **NOT** detected by per-line HMAC (documented limitation of Q5=a — hash-chain considered too fragile on rotation). File perms enforced at 0600. Direct edits via shell are technically possible but observable post-hoc; do not edit by hand. - `~/.codec/pending_questions.json` (Phase 1 Step 3) — direct edits race in-flight `threading.Event` waiters; agents will hang or skip answers. Use `POST /api/agents/answer/{qid}` or `codec_ask_user.submit_answer()` instead. - `~/.codec/voice_session.json` (Phase 1 Step 3) — voice-session active-marker; `VoicePipeline.run` owns its lifecycle. diff --git a/codec.py b/codec.py index 37164b1..060475f 100644 --- a/codec.py +++ b/codec.py @@ -797,16 +797,30 @@ def do_screenshot_question(): push(lambda: show_overlay('Screenshot failed', '#ff3333', 2000)) return print(f"[CODEC] Screenshot captured ({len(ctx)} chars)") - # Show brief summary of what was captured, then open question dialog - summary = ctx[:120].replace('"', '\\"').replace('\n', ' ') + # PR-2F (closes D-21): pass the OCR summary as an osascript ARGV argument + # rather than interpolating it into the script source. AppleScript reads + # `summary` from `item 1 of argv` — NO string interpolation means an + # adversarial OCR result (`"\n display dialog "PWNED"`) is treated as + # literal text by AppleScript and cannot break out of the string context. + summary = ctx[:120] + body = f"I captured your screen:\n\n{summary}…\n\nWhat would you like to know about it?" + script = ( + 'on run argv\n' + ' set bodyText to item 1 of argv\n' + ' tell application "System Events"\n' + ' set frontmost of first process whose frontmost is true to true\n' + ' end tell\n' + ' set t to text returned of (display dialog bodyText ' + 'default answer "" with title "CODEC Screenshot" ' + 'buttons {"Cancel","Ask"} default button "Ask")\n' + ' return t\n' + 'end run' + ) try: - r = subprocess.run(["osascript", "-e", - f'tell application "System Events"\nset frontmost of first process whose frontmost is true to true\nend tell\n' - f'set t to text returned of (display dialog ' - f'"I captured your screen:\\n\\n{summary}…\\n\\nWhat would you like to know about it?" ' - f'default answer "" with title "CODEC Screenshot" ' - f'buttons {{"Cancel","Ask"}} default button "Ask")'], - capture_output=True, text=True, timeout=120) + r = subprocess.run( + ["osascript", "-e", script, body], + capture_output=True, text=True, timeout=120, + ) question = r.stdout.strip() if question: task = question + " [SCREEN CONTEXT: " + ctx[:800] + "]" diff --git a/codec_hooks.py b/codec_hooks.py index e0ca8cd..8212712 100644 --- a/codec_hooks.py +++ b/codec_hooks.py @@ -22,13 +22,18 @@ from __future__ import annotations import ast +import hashlib import importlib.util +import json import logging import os +import queue +import stat import threading import time from dataclasses import dataclass, field, replace from datetime import datetime, timezone +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Union from codec_audit import log_event as _log_event, _PREVIEW_MAX, _truncate @@ -37,6 +42,27 @@ # ── Storage ──────────────────────────────────────────────────────────────────── _PLUGINS_DIR_DEFAULT = os.path.expanduser("~/.codec/plugins") +_PLUGINS_ALLOWLIST_DEFAULT = os.path.expanduser("~/.codec/plugins.allowlist") + +# PR-2F (D-18): hook timeout default. Set high enough not to false-positive on +# legitimate hooks (self_improve.py's on_operation_end does a buffer snapshot + +# spawns a daemon thread for the slow Qwen call — synchronous body is <10ms). +# Operator can override via ~/.codec/config.json:plugin_hook_timeout_ms. +_HOOK_TIMEOUT_DEFAULT_S = 0.5 + + +def _hook_timeout_seconds() -> float: + """Read the hook timeout from config. Falls through to default on any + error. Read lazily so config changes take effect on next hook fire + (no restart needed for tuning).""" + try: + from codec_config import cfg + ms = cfg.get("plugin_hook_timeout_ms", _HOOK_TIMEOUT_DEFAULT_S * 1000) + if isinstance(ms, (int, float)) and ms > 0: + return float(ms) / 1000.0 + except Exception: + pass + return _HOOK_TIMEOUT_DEFAULT_S # Lifecycle hook names. AST discovery looks for top-level def's matching # any of these. Plugins implement any subset. @@ -69,6 +95,285 @@ _PLUGIN_FILE_SUFFIX = ".py" +# ── PR-2F (D-18): SHA-256 allowlist + AST gate ──────────────────────────────── +# +# Plugins are local Python with full process privileges. Before PR-2F, any +# file dropped into ~/.codec/plugins/*.py would auto-load on next restart. +# Now a plugin's SHA-256 must be in `~/.codec/plugins.allowlist` (operator- +# managed) before `spec.loader.exec_module` runs. Mirrors PR-1A's hash-pinned +# `skills/.manifest.json` chokepoint: hash-pinned trust IS the security +# decision, not the AST check. +# +# AST check (via `codec_config.is_dangerous_skill_code`) is still run when +# the file is NOT in the allowlist — but only to enrich the `plugin_load_blocked` +# audit event with a specific reason ("ast_dangerous: subprocess.run" vs +# "not_in_allowlist"). Either reason → refuse. Both checks must pass for a +# plugin to load — hash match OR (well, the hash match alone is sufficient; +# AST is only run when hash misses, for forensic clarity). +# +# Migration: existing plugins at `~/.codec/plugins/*.py` on first run after +# PR-2F are grandfathered — their current hashes are written to the +# allowlist with `approved_by: "initial_migration"` and an `info`-level +# audit event. This avoids surprising the operator on upgrade. + +_ALLOWLIST_LOCK = threading.Lock() + + +def _default_allowlist_path_for(plugins_dir: str) -> str: + """Derive the allowlist path from the plugins dir. Production: + `~/.codec/plugins/` → `~/.codec/plugins.allowlist`. Tests pointing + at a tmp plugins dir get a sibling allowlist in the same tmp tree, + so they don't touch the operator's real allowlist file.""" + parent = os.path.dirname(os.path.abspath(plugins_dir.rstrip(os.sep))) + return os.path.join(parent, "plugins.allowlist") + + +def _allowlist_path_for(plugins_dir: str) -> Path: + """Resolved allowlist path for a given plugins dir.""" + return Path(_default_allowlist_path_for(plugins_dir)) + + +def _read_allowlist(path: Optional[Path] = None) -> Dict[str, Dict[str, Any]]: + """Load the allowlist as a dict keyed by plugin filename. Returns {} on + any error (missing file, parse error, wrong shape) — fail-closed: no + file = no allowed plugins.""" + p = path if path is not None else Path(_PLUGINS_ALLOWLIST_DEFAULT) + if not p.exists(): + return {} + try: + raw = p.read_text(encoding="utf-8") + obj = json.loads(raw) + if not isinstance(obj, dict): + log.warning("[plugins] allowlist root is not a dict; treating as empty") + return {} + # Validate shape — each entry must have a sha256 hex string. + clean: Dict[str, Dict[str, Any]] = {} + for fname, entry in obj.items(): + if not isinstance(entry, dict): + continue + h = entry.get("sha256") + if isinstance(h, str) and len(h) == 64 and all(c in "0123456789abcdef" for c in h.lower()): + clean[fname] = entry + return clean + except (OSError, json.JSONDecodeError) as e: + log.warning("[plugins] allowlist read failed: %s", e) + return {} + + +def _write_allowlist(allowlist: Dict[str, Dict[str, Any]], + path: Optional[Path] = None) -> bool: + """Atomic-write the allowlist with 0600 perms. Returns True on success.""" + p = path if path is not None else Path(_PLUGINS_ALLOWLIST_DEFAULT) + try: + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_text(json.dumps(allowlist, indent=2, sort_keys=True), + encoding="utf-8") + os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) + os.replace(tmp, p) + try: + os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + return True + except OSError as e: + log.warning("[plugins] allowlist write failed: %s", e) + return False + + +def _file_sha256(path: str) -> Optional[str]: + """SHA-256 hex digest of file contents. None on read error.""" + try: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + except OSError: + return None + + +def _maybe_grandfather_existing_plugins(plugins_dir: str, + allowlist_path: Optional[Path] = None) -> None: + """One-shot migration: if no allowlist file exists yet AND the plugins + dir has .py files, write their current hashes to the allowlist with + `approved_by: "initial_migration"`. Idempotent — runs once at the + upgrade boundary, then becomes a no-op. + + `allowlist_path` defaults to the sibling of `plugins_dir` (i.e. + `/plugins.allowlist`) so tests pointing at a + tmp plugins dir get a tmp allowlist, not the real one.""" + if allowlist_path is None: + allowlist_path = _allowlist_path_for(plugins_dir) + if allowlist_path.exists(): + return + if not os.path.isdir(plugins_dir): + return + pys = [f for f in os.listdir(plugins_dir) + if f.endswith(_PLUGIN_FILE_SUFFIX) and not f.startswith("_")] + if not pys: + # No plugins to grandfather — still create an empty allowlist so + # subsequent loads don't re-attempt migration on every restart. + _write_allowlist({}, allowlist_path) + return + seed: Dict[str, Dict[str, Any]] = {} + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + for fname in pys: + fpath = os.path.join(plugins_dir, fname) + h = _file_sha256(fpath) + if h is None: + continue + seed[fname] = { + "sha256": h, + "approved_at": now, + "approved_by": "initial_migration", + } + if _write_allowlist(seed, allowlist_path): + log.info("[plugins] grandfathered %d existing plugin(s) into allowlist", len(seed)) + try: + _log_event( + "plugin_allowlist_migrated", "codec-hooks", + f"grandfathered {len(seed)} plugin(s)", + extra={"plugin_count": len(seed), "filenames": sorted(seed.keys())}, + level="info", outcome="ok", + ) + except Exception: + pass + + +def _is_plugin_allowed(filepath: str, + allowlist_path: Optional[Path] = None) -> tuple[bool, str]: + """Return (allowed, reason). `allowed` True only if the file's current + SHA-256 matches an entry in the allowlist keyed by basename. Tamper + detection: a previously-approved plugin whose content changed will + have a hash mismatch and be refused until re-approved.""" + if allowlist_path is None: + allowlist_path = _allowlist_path_for(os.path.dirname(filepath)) + fname = os.path.basename(filepath) + h = _file_sha256(filepath) + if h is None: + return False, "file_unreadable" + allowlist = _read_allowlist(allowlist_path) + entry = allowlist.get(fname) + if entry is None: + return False, "not_in_allowlist" + if entry.get("sha256") != h: + return False, "hash_mismatch" + return True, "" + + +def _emit_plugin_load_blocked(plugin_name: str, filepath: str, + reason: str, extra_detail: str = "") -> None: + """Audit emit for plugin load refusal. Fire-and-forget.""" + extra: Dict[str, Any] = { + "plugin_name": plugin_name, + "plugin_path": filepath, + "reason": reason, + } + if extra_detail: + extra["detail"] = extra_detail[:200] + try: + _log_event( + "plugin_load_blocked", "codec-hooks", + f"Plugin {plugin_name!r} refused: {reason}", + extra=extra, + level="warning", outcome="error", + ) + except Exception: + pass + + +# ── PR-2F (D-18): thread-with-timeout for hook execution ─────────────────────── +# +# Before PR-2F, hook functions ran in the calling thread. A slow / malicious +# `pre_tool` could block the entire chat / voice / crew turn. Now every hook +# fires inside a daemon thread with a hard timeout (default 500ms, configurable +# via `~/.codec/config.json:plugin_hook_timeout_ms`). On timeout, the calling +# thread receives a sentinel and the audit log records `plugin_hook_timeout`. +# The daemon thread keeps running in the background; daemon=True means it +# doesn't block process shutdown. + + +class _HookTimedOut: + """Sentinel returned by `_run_hook_with_timeout` when the timeout + elapsed. Distinct from None (which means "no return"); callers check + `isinstance(result, _HookTimedOut)` to decide whether to log timeout.""" + __slots__ = ("hook_name", "plugin_name", "timeout_s") + + def __init__(self, *, hook_name: str, plugin_name: str, timeout_s: float): + self.hook_name = hook_name + self.plugin_name = plugin_name + self.timeout_s = timeout_s + + +def _run_hook_with_timeout( + fn: Callable, + args: tuple, + *, + hook_name: str, + plugin_name: str, + timeout_s: Optional[float] = None, +) -> tuple[Any, Optional[BaseException]]: + """Run `fn(*args)` in a daemon thread with a hard timeout. Returns + `(result, exception)`: + - on success → `(retval, None)` + - on hook exception → `(None, exception)` (caller emits hook_error) + - on timeout → `(_HookTimedOut(...), None)` (caller emits plugin_hook_timeout) + Never raises. The daemon thread keeps running on timeout; daemon=True + so process shutdown isn't blocked. + """ + if timeout_s is None: + timeout_s = _hook_timeout_seconds() + result_q: "queue.Queue" = queue.Queue(maxsize=1) + exc_q: "queue.Queue" = queue.Queue(maxsize=1) + + def _runner(): + try: + result_q.put(fn(*args)) + except BaseException as e: + try: + exc_q.put(e) + except Exception: + pass + + t = threading.Thread(target=_runner, daemon=True, + name=f"codec-hook:{plugin_name}.{hook_name}") + t.start() + t.join(timeout=timeout_s) + + if t.is_alive(): + # Abandon — thread keeps running but caller doesn't wait. + return _HookTimedOut( + hook_name=hook_name, plugin_name=plugin_name, + timeout_s=timeout_s, + ), None + if not exc_q.empty(): + return None, exc_q.get_nowait() + if not result_q.empty(): + return result_q.get_nowait(), None + return None, None + + +def _emit_plugin_hook_timeout(plugin_name: str, hook_name: str, + tool_name: Optional[str], transport: str, + correlation_id: str, timeout_s: float) -> None: + """Audit emit for hook timeout. level=warning per Step 2 §7.5 contract + (operational signal, not operation failure).""" + try: + _log_event( + "plugin_hook_timeout", "codec-hooks", + f"plugin {plugin_name}.{hook_name} exceeded {timeout_s * 1000:.0f}ms", + extra={ + "plugin_name": plugin_name, + "hook_name": hook_name, + "tool_name": tool_name, + "timeout_ms": int(timeout_s * 1000), + }, + level="warning", outcome="error", + transport=transport, tool=tool_name or "", + correlation_id=correlation_id, + ) + except Exception as e: + log.debug("plugin_hook_timeout emit failed: %s", e) + + # ── HookCtx + HookVeto ───────────────────────────────────────────────────────── @dataclass(frozen=True) class HookCtx: @@ -212,8 +517,14 @@ class PluginRegistry: Exposed for tests; production uses the module-level _registry instance. """ - def __init__(self, plugins_dir: str): + def __init__(self, plugins_dir: str, + allowlist_path: Optional[str] = None): self.plugins_dir = plugins_dir + # PR-2F: each registry owns its allowlist path. Default derives from + # plugins_dir's parent, so tests with tmp plugins dirs get a tmp + # allowlist (no pollution of the operator's real ~/.codec/plugins.allowlist). + self.allowlist_path = Path(allowlist_path or + _default_allowlist_path_for(plugins_dir)) # Sort key: (priority asc, filename asc) per §5.1 self._plugins: List[_PluginMeta] = [] # name → loaded module (populated on first fire) @@ -223,7 +534,13 @@ def __init__(self, plugins_dir: str): self._lock = threading.Lock() def scan(self) -> int: - """AST-parse every plugin file; cache metadata. Cheap — no imports.""" + """AST-parse every plugin file; cache metadata. Cheap — no imports. + + PR-2F (D-18): runs the one-shot grandfather migration on first + encounter (no allowlist file + plugins dir has .py files → + seed allowlist with current hashes). Idempotent — once the + allowlist file exists, migration is a no-op.""" + _maybe_grandfather_existing_plugins(self.plugins_dir, self.allowlist_path) plugins: List[_PluginMeta] = [] if os.path.isdir(self.plugins_dir): for fname in sorted(os.listdir(self.plugins_dir)): @@ -257,13 +574,46 @@ def for_hook(self, hook_name: str, tool_name: Optional[str] = None) -> List[_Plu def get_fn(self, plugin: _PluginMeta, hook_name: str) -> Optional[Callable]: """Lazy-load the plugin module and return the named hook function. - Returns None on import failure (plugin marked broken) or if the - function turns out to not be defined at runtime. + PR-2F (D-18): a two-stage gate runs BEFORE `exec_module`: + 1. SHA-256 hash of the file must match an entry in + `~/.codec/plugins.allowlist` keyed by basename. + 2. If the hash doesn't match (not in allowlist OR content changed), + also run `is_dangerous_skill_code` for forensic clarity in the + refusal audit event. Either way, refuse the load. + + On refusal the plugin is marked broken and `plugin_load_blocked` + is emitted with the specific reason. Returns None. + + On success the module is cached for the process lifetime. """ if plugin.name in self._broken: return None mod = self._modules.get(plugin.name) if mod is None: + # ── Two-stage trust gate ── + allowed, reason = _is_plugin_allowed( + plugin.file_path, self.allowlist_path) + if not allowed: + # Run AST check too, so the audit event captures the SPECIFIC + # dangerous pattern if there is one — helps the operator + # decide whether to approve the file. + detail = "" + try: + with open(plugin.file_path, "r", encoding="utf-8", + errors="ignore") as f: + src = f.read() + from codec_config import is_dangerous_skill_code + dangerous, ast_reason = is_dangerous_skill_code(src) + if dangerous: + detail = f"ast_dangerous: {ast_reason}" + except Exception: + pass + _emit_plugin_load_blocked( + plugin.name, plugin.file_path, reason, detail, + ) + self._broken.add(plugin.name) + return None + try: import sys module_name = f"codec_plugin_{plugin.name}" @@ -282,7 +632,8 @@ def get_fn(self, plugin: _PluginMeta, hook_name: str) -> Optional[Callable]: sys.modules.pop(module_name, None) # roll back on failure raise self._modules[plugin.name] = mod - log.info("Lazy-loaded plugin: %s", plugin.name) + log.info("Lazy-loaded plugin: %s (sha256 allowlist-approved)", + plugin.name) except Exception as e: log.warning("Plugin import error (%s): %s", plugin.name, e) self._broken.add(plugin.name) @@ -387,25 +738,34 @@ def _emit_tool_vetoed(*, tool_name: str, transport: str, correlation_id: str, def _fire_one_pre_tool(plugin: _PluginMeta, ctx: HookCtx) -> Any: """Run one plugin's pre_tool. Returns None / dict / HookVeto / sentinel-skip. - Internal contract: on plugin exception, logs + emits hook_error and - returns None (skip — operation continues with unmutated state). + PR-2F (D-18): wrapped in daemon-thread timeout. On timeout, emits + `plugin_hook_timeout` audit and returns None (operation continues + with unmutated state — never block the calling thread on a slow plugin). """ fn = _registry.get_fn(plugin, "pre_tool") if fn is None: return None plugin_ctx = replace(ctx, plugin_name=plugin.name) t0 = time.monotonic() - try: - ret = fn(plugin_ctx) - except BaseException as e: - elapsed = (time.monotonic() - t0) * 1000.0 + ret, exc = _run_hook_with_timeout( + fn, (plugin_ctx,), + hook_name="pre_tool", plugin_name=plugin.name, + ) + elapsed = (time.monotonic() - t0) * 1000.0 + if isinstance(ret, _HookTimedOut): + _emit_plugin_hook_timeout( + plugin_name=plugin.name, hook_name="pre_tool", + tool_name=ctx.tool_name, transport=ctx.transport, + correlation_id=ctx.correlation_id, timeout_s=ret.timeout_s, + ) + return None + if exc is not None: _emit_hook_error( plugin_name=plugin.name, hook_name="pre_tool", tool_name=ctx.tool_name, transport=ctx.transport, - correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=e, + correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=exc, ) return None - elapsed = (time.monotonic() - t0) * 1000.0 mutated = isinstance(ret, dict) and bool(ret) vetoed = isinstance(ret, HookVeto) if vetoed and ret.plugin_name is None: @@ -421,22 +781,31 @@ def _fire_one_pre_tool(plugin: _PluginMeta, ctx: HookCtx) -> Any: def _fire_one_post_tool(plugin: _PluginMeta, ctx: HookCtx, result: str) -> Any: + """Run one plugin's post_tool. Wrapped in daemon-thread timeout (PR-2F).""" fn = _registry.get_fn(plugin, "post_tool") if fn is None: return None plugin_ctx = replace(ctx, plugin_name=plugin.name) t0 = time.monotonic() - try: - ret = fn(plugin_ctx, result) - except BaseException as e: - elapsed = (time.monotonic() - t0) * 1000.0 + ret, exc = _run_hook_with_timeout( + fn, (plugin_ctx, result), + hook_name="post_tool", plugin_name=plugin.name, + ) + elapsed = (time.monotonic() - t0) * 1000.0 + if isinstance(ret, _HookTimedOut): + _emit_plugin_hook_timeout( + plugin_name=plugin.name, hook_name="post_tool", + tool_name=ctx.tool_name, transport=ctx.transport, + correlation_id=ctx.correlation_id, timeout_s=ret.timeout_s, + ) + return None + if exc is not None: _emit_hook_error( plugin_name=plugin.name, hook_name="post_tool", tool_name=ctx.tool_name, transport=ctx.transport, - correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=e, + correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=exc, ) return None - elapsed = (time.monotonic() - t0) * 1000.0 mutated = isinstance(ret, str) _emit_hook_fired( plugin_name=plugin.name, hook_name="post_tool", @@ -449,23 +818,32 @@ def _fire_one_post_tool(plugin: _PluginMeta, ctx: HookCtx, result: str) -> Any: def _fire_one_observe(plugin: _PluginMeta, hook_name: str, ctx: HookCtx, *extra_args: Any) -> None: - """Run one observe-only hook (on_error / on_operation_*). Return ignored.""" + """Run one observe-only hook (on_error / on_operation_*). Return ignored. + Wrapped in daemon-thread timeout (PR-2F).""" fn = _registry.get_fn(plugin, hook_name) if fn is None: return plugin_ctx = replace(ctx, plugin_name=plugin.name) t0 = time.monotonic() - try: - fn(plugin_ctx, *extra_args) - except BaseException as e: - elapsed = (time.monotonic() - t0) * 1000.0 + ret, exc = _run_hook_with_timeout( + fn, (plugin_ctx, *extra_args), + hook_name=hook_name, plugin_name=plugin.name, + ) + elapsed = (time.monotonic() - t0) * 1000.0 + if isinstance(ret, _HookTimedOut): + _emit_plugin_hook_timeout( + plugin_name=plugin.name, hook_name=hook_name, + tool_name=ctx.tool_name, transport=ctx.transport, + correlation_id=ctx.correlation_id, timeout_s=ret.timeout_s, + ) + return + if exc is not None: _emit_hook_error( plugin_name=plugin.name, hook_name=hook_name, tool_name=ctx.tool_name, transport=ctx.transport, - correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=e, + correlation_id=ctx.correlation_id, duration_ms=elapsed, exc=exc, ) return - elapsed = (time.monotonic() - t0) * 1000.0 _emit_hook_fired( plugin_name=plugin.name, hook_name=hook_name, tool_name=ctx.tool_name, transport=ctx.transport, @@ -646,6 +1024,68 @@ def emit_operation_end( _fire_one_observe(p, "on_operation_end", ctx) +def approve_plugin(filename: str, *, approved_by: str = "operator") -> dict: + """Public API for the `plugin_approve` skill (PR-2F, D-18). + + Resolves `filename` to `/`, computes its SHA-256, + writes/updates the entry in `~/.codec/plugins.allowlist`, and returns + a summary dict: `{ok, sha256, last8, filename, reason}`. + + Caller MUST be the operator (the skill is `SKILL_MCP_EXPOSE=False`). + """ + # Reject path traversal — only accept a basename + if "/" in filename or ".." in filename or filename.startswith("."): + return {"ok": False, "reason": "invalid_filename: must be a basename"} + if not filename.endswith(_PLUGIN_FILE_SUFFIX): + return {"ok": False, "reason": f"must end with {_PLUGIN_FILE_SUFFIX}"} + + # Use the singleton registry's plugins_dir + allowlist_path so the + # operator can wire in a custom registry (tests do). + plugins_dir = _registry.plugins_dir + allowlist_path = _registry.allowlist_path + + fpath = os.path.join(plugins_dir, filename) + if not os.path.exists(fpath): + return {"ok": False, "reason": f"plugin file not found: {filename}"} + + h = _file_sha256(fpath) + if h is None: + return {"ok": False, "reason": "could not read plugin file"} + + with _ALLOWLIST_LOCK: + allowlist = _read_allowlist(allowlist_path) + allowlist[filename] = { + "sha256": h, + "approved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "approved_by": approved_by, + } + if not _write_allowlist(allowlist, allowlist_path): + return {"ok": False, "reason": "allowlist write failed"} + + # Invalidate the broken-plugin cache so the plugin is re-attempted on + # next fire (the operator just approved it; clear the prior refusal). + plugin_name = filename[:-len(_PLUGIN_FILE_SUFFIX)] + with _registry._lock: + _registry._broken.discard(plugin_name) + _registry._modules.pop(plugin_name, None) + + try: + _log_event( + "plugin_approved", "codec-hooks", + f"Plugin {filename} approved", + extra={"filename": filename, "sha256_last8": h[-8:], + "approved_by": approved_by}, + level="info", outcome="ok", + ) + except Exception: + pass + + return { + "ok": True, "sha256": h, "last8": h[-8:], + "filename": filename, "approved_by": approved_by, + } + + __all__ = [ "HookCtx", "HookVeto", @@ -653,4 +1093,5 @@ def emit_operation_end( "run_with_hooks", "emit_operation_start", "emit_operation_end", + "approve_plugin", ] diff --git a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md index 40c1143..faf9fb0 100644 --- a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md +++ b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md @@ -226,7 +226,7 @@ Mirror the Intake Phase 3 wave pattern. 7 waves planned; sizes are PR-counts, NO - PR-2C: D-9 + D-10 — `python_exec` sandbox + `/api/execute` removal-or-strict-consent - PR-2D: D-11 — replace `x-internal: codec` header trust with per-process HMAC token in macOS Keychain ✅ (branch `fix/pr2d-internal-token-replacement`; token never lands on disk in plaintext — `codec_keychain.get_internal_token()` bootstraps to Keychain or 0600 envelope-encrypted fallback) - PR-2E: D-12 + D-19 + D-22 — audit log HMAC-SHA256-per-line + secret redaction + chmod 0600 ✅ (branch `fix/pr2e-audit-log-hmac-redaction`; secret in macOS Keychain `ai.avadigital.codec.audit_hmac_secret`; 15-pattern redaction sweep applied before truncation; `verify_audit_log()` utility + operator-only `audit_verify` skill) -- PR-2F: D-13 — AppleScript injection fix in `imessage_send`; D-21 — same for `do_screenshot_question`; D-18 — plugin AST validation + thread-timeout +- PR-2F: D-13 — AppleScript injection fix in `imessage_send`; D-21 — same for `do_screenshot_question`; D-18 — plugin AST validation + thread-timeout ✅ (branch `fix/pr2f-applescript-and-plugin-hardening`; strict phone/email regex gate for recipient; argv-binding for screenshot dialog so adversarial OCR text can't escape the string context; SHA-256 allowlist at `~/.codec/plugins.allowlist` + 500ms daemon-thread hook timeout + new operator-only `plugin_approve` skill; existing plugins grandfathered via one-time migration) - PR-2G: D-14 + D-16 + D-20 — path-blocklist hardening across `codec_agent_plan`, `extract_user_paths`, `file_ops` **Rationale:** these 17 don't share a single chokepoint, so they break into 4-6 themed PRs. Wave 2 closes the rest of the security debt. diff --git a/docs/audits/PHASE-1-SECURITY.md b/docs/audits/PHASE-1-SECURITY.md index d8b6094..7af431f 100644 --- a/docs/audits/PHASE-1-SECURITY.md +++ b/docs/audits/PHASE-1-SECURITY.md @@ -277,6 +277,9 @@ In addition, **no secret redaction patterns** are applied before writing. `task_ ### D-13 — AppleScript injection in `imessage_send` recipient field [MEDIUM] **Location:** `skills/imessage_send.py:53-73` (`_send`) **CWE / OWASP:** CWE-78 OS Command Injection (via AppleScript) / OWASP A03 Injection + +> **Closed by PR-2F** (branch `fix/pr2f-applescript-and-plugin-hardening`). `imessage_send._validate_recipient` enforces strict phone/email format before any AppleScript interpolation. Phone regex: `^\+?[1-9]\d{9,14}$` (E.164-ish, 10-15 digits with optional `+`). Email regex: `^[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)+$`. Both anchored — no prefix matches. Length cap 254 (RFC 5321 SMTP). All AppleScript metachars (quotes, newlines, carriage returns, tabs, backslashes, control chars) and the audit's documented breakout (`xx@x.com" of targetService\nactivate application "Calculator"\n...`) are rejected before `osascript` runs; audit emit `imessage_send_blocked` fires on every refusal with reason=`invalid_recipient_format` and `recipient_preview` truncated to 32 chars. Text body escape extended to cover `\\`, `\"`, `\r`, `\n`, `\t` (backslash first so subsequent escapes don't re-escape its own backslash). 32 tests in `tests/test_imessage_send.py`. + **Description:** `recipient` is interpolated into AppleScript WITHOUT escaping (only `text` is escaped at line 54). An attacker who controls the recipient string (e.g., LLM-controlled, or user-typed with adversarial intent) can craft: `recipient = 'xx@x.com" of targetService\nactivate application "Calculator"\nset targetBuddy to buddy "yy@y.com'` The AppleScript breaks out of the string literal and executes arbitrary AppleScript. The blocker for `osascript -e 'tell application "System Events"` in DANGEROUS_PATTERNS doesn't cover this because the embedded AppleScript can use any app name. @@ -356,6 +359,16 @@ Plus: `socket` is in DANGEROUS_MODULES, but `urllib.request` is not. `from urlli ### D-18 — Plugin lifecycle hooks have no privilege isolation and can suppress audit [MEDIUM] **Location:** `codec_hooks.py:265-300` (PluginRegistry.get_fn → exec_module), `codec_hooks.py:302-358` (audit emit helpers) **CWE / OWASP:** CWE-269 Improper Privilege Management / OWASP A04 Insecure Design / Agentic A09 Overreliance + +> **Closed by PR-2F** (branch `fix/pr2f-applescript-and-plugin-hardening`). Three layers: +> 1. **SHA-256 allowlist gate.** `~/.codec/plugins.allowlist` (0600, atomic-write) keys plugins by basename → `{sha256, approved_at, approved_by}`. `PluginRegistry.get_fn` computes the file's current SHA-256 and refuses to load (`exec_module` never runs) if the hash isn't in the allowlist OR doesn't match. Refusal emits `plugin_load_blocked` with reason=`not_in_allowlist`, `hash_mismatch`, or `file_unreadable`. Defense-in-depth — `is_dangerous_skill_code` runs additionally for not-allowlisted plugins and its result rides under `extra.detail` so the operator sees both signals when reviewing refusals. +> 2. **Daemon-thread timeout.** Every hook fire (`pre_tool`, `post_tool`, `on_error`, `on_operation_start`, `on_operation_end`) runs inside a `threading.Thread(daemon=True)` with a hard timeout (default 500ms, configurable via `~/.codec/config.json:plugin_hook_timeout_ms`). On timeout: emit `plugin_hook_timeout` audit, return `None`, calling thread continues (never blocks). Daemon=True so process shutdown isn't held up by a runaway hook. +> 3. **Grandfather migration.** First scan after PR-2F lands seeds the allowlist with the current hashes of every plugin in `~/.codec/plugins/*.py` (`approved_by: "initial_migration"`, emit `plugin_allowlist_migrated`). Idempotent — once the allowlist file exists, migration is a no-op. Avoids surprising the operator at upgrade time. +> +> New skill `plugin_approve` (`SKILL_MCP_EXPOSE=False` — never reachable from claude.ai) is the operator's tool for approving subsequently-added plugins: it computes the file's SHA-256, writes the allowlist entry with `approved_by: "operator"`, clears any prior refusal cache, and emits `plugin_approved`. 17 tests in `tests/test_plugin_registry.py` (gate, mismatch, migration, approve helper, timeout, source-level invariants). +> +> **Allowlist is the trust decision, not the AST check.** PR-1A's manifest-pinned skills can use dangerous imports because the operator hash-pinned them; same here — `self_improve.py` plugin imports `os`, `threading` etc., which would fail the AST check, but its hash is in the allowlist so it loads. AST is only consulted to enrich the audit emit for refused plugins. + **Description:** Per CLAUDE.md §3, plugins in `~/.codec/plugins/*.py` are "local Python files curated by the user. No marketplace, no auto-install, no inter-plugin sandbox. Same trust model as skills." They wrap EVERY tool call (`pre_tool`, `post_tool`, `on_error`, `on_operation_start`, `on_operation_end`). A malicious plugin can: - Mutate `task` / `context` arbitrarily in `pre_tool`. The runner accepts the mutation (codec_hooks.py:478-509) — only "identity fields" (tool_name, correlation_id, etc.) are immutable. So a plugin could rewrite "schedule a meeting" → "rm -rf /" before it reaches `terminal` skill — `is_dangerous` runs on the original task at the dashboard level, but if the plugin mutates between that check and skill execution, the check is bypassed. @@ -401,6 +414,9 @@ The audit emit path `_emit_hook_fired` / `_emit_hook_error` is robust against ho ### D-21 — `do_screenshot_question` interpolates OCR text into AppleScript with minimal escaping [LOW] **Location:** `codec.py:799-809` **CWE / OWASP:** CWE-78 / Agentic A01 Memory Poisoning + +> **Closed by PR-2F** (branch `fix/pr2f-applescript-and-plugin-hardening`). `do_screenshot_question` no longer interpolates the OCR summary into the AppleScript source. The new pattern uses `on run argv` + `item 1 of argv`: Python passes the body as `osascript -e