Skip to content

fix(security): AppleScript injection fixes + plugin trust hardening (D-13, D-18, D-21)#55

Open
AVADSA25 wants to merge 2 commits into
mainfrom
fix/pr2f-applescript-and-plugin-hardening
Open

fix(security): AppleScript injection fixes + plugin trust hardening (D-13, D-18, D-21)#55
AVADSA25 wants to merge 2 commits into
mainfrom
fix/pr2f-applescript-and-plugin-hardening

Conversation

@AVADSA25
Copy link
Copy Markdown
Owner

Summary

Closes three audit findings that share a "untrusted input crosses a trust boundary" pattern:

  • D-13 (MEDIUM) — AppleScript injection in imessage_send recipient field. Skill is SKILL_MCP_EXPOSE=True so reachable from claude.ai.
  • D-21 (LOW) — AppleScript injection in do_screenshot_question from adversarial OCR text.
  • D-18 (MEDIUM) — plugin hooks have full Python privileges with no isolation, no AST gate, no timeout, no allowlist.

D-13 — imessage_send recipient validation

_validate_recipient enforces strict format BEFORE any AppleScript interpolation:

Pattern Regex
Phone (E.164-ish) ^\+?[1-9]\d{9,14}$
Email ^[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)+$

Both anchored, length-capped at 254 (RFC 5321). The audit's documented breakout:

recipient = 'xx@x.com" of targetService\nactivate application "Calculator"\nset targetBuddy to buddy "yy@y.com'

…fails the regex → imessage_send_blocked audit emit with recipient_preview truncated to 32 chars → no osascript shellout. Text body escape extended to cover \\, \", \r, \n, \t (backslash first so subsequent escapes don't re-escape).

D-21 — do_screenshot_question argv binding

OCR summary is no longer interpolated into the AppleScript source. Pattern:

script = (
    'on run argv\n'
    '  set bodyText to item 1 of argv\n'
    '  ... display dialog bodyText ...\n'
    'end run'
)
subprocess.run(["osascript", "-e", script, body], ...)

Adversarial OCR text ("\n display dialog "PWNED") arrives as argv[1] → literal AppleScript string → cannot escape the variable context. Considered tkinter (audit's suggestion) but argv-binding preserved the focus-stealing-friendly dialog behavior used by the PM2 daemon context.

D-18 — Plugin SHA-256 allowlist + thread-timeout

Three layers:

  1. SHA-256 allowlist gate. ~/.codec/plugins.allowlist (0600, atomic write) keys plugins by basename:

    {
      "self_improve.py": {
        "sha256": "<hex>",
        "approved_at": "2026-05-18T00:00:00",
        "approved_by": "initial_migration"
      }
    }

    PluginRegistry.get_fn computes the file's SHA-256 and refuses to call exec_module if the hash isn't in the allowlist OR has changed. Refusal emits plugin_load_blocked with reason ∈ {not_in_allowlist, hash_mismatch, file_unreadable} + optional detail (AST result for forensic clarity).

  2. 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: emit plugin_hook_timeout audit, return None, calling thread continues. Daemon=True so process shutdown isn't held up.

  3. Grandfather migration. First scan() after PR-2F lands with no allowlist file present + plugins in ~/.codec/plugins/*.py seeds the allowlist with their current hashes (approved_by: "initial_migration", emit plugin_allowlist_migrated). Idempotent — subsequent scans no-op.

Allowlist IS the trust decision; AST is not. Same chokepoint pattern as PR-1A's skills/.manifest.json for skills: an operator-approved plugin can use dangerous imports because the hash is pinned. AST check only runs when the file ISN'T allowlisted — its result enriches the refusal audit emit for operator review.

New operator-only skill: plugin_approve. Invoked via chat: "approve plugin newhook.py". Computes SHA-256, writes the allowlist entry with approved_by: "operator", clears any prior refusal cache, emits plugin_approved. SKILL_MCP_EXPOSE=False — claude.ai cannot extend the plugin trust boundary.

Existing plugin compat: self_improve.py imports os, threading etc. — would fail the AST check. But its hash is in the allowlist (auto-grandfathered) so it loads normally. Hook timeout default 500ms is well above its synchronous body cost (<10ms; the slow Qwen call runs in its own daemon thread).

Refactor: per-registry allowlist path

PluginRegistry now owns its allowlist_path attribute (derived from plugins_dir's parent by default — ~/.codec/plugins/~/.codec/plugins.allowlist). Tests pointing at tmp plugins dirs get tmp allowlists, so they don't pollute the operator's real allowlist file.

Tests

53 new tests across three files:

File Count Coverage
tests/test_imessage_send.py 32 regex accepts (8) + rejects (15) + non-string/overlength (2) + escape (2) + audit emit + no-subprocess + integration
tests/test_screenshot_question.py 4 source invariant + runtime argv binding + adversarial OCR safety + early-return empty ctx
tests/test_plugin_registry.py 17 allowlist gate (3) + hash mismatch + AST detail + grandfather migration (idempotent) + 0600 perms + approve_plugin (3 — adds entry, rejects traversal, clears broken cache) + hook timeout (3) + _fire_one_pre_tool integration + source-level invariants (2)

Regression: 146 PR-2-related tests pass (test_audit_integrity, test_keychain, test_auth_middleware, test_hook_, test_plugin_, test_imessage_send, test_screenshot_question). 24 pre-existing failures unchanged (PIN brute force, pilot e2e skill load, etc. — none touched by this PR).

Manual verification post-merge

# D-13: legitimate send works
# In chat: "imessage mickael@avadigital.ai testing"
# → expect "✅ Sent to mickael@avadigital.ai: testing"

# D-13: injection blocked
# In chat: 'recipient: "; tell app "Finder" to delete; " | body: hi'
# → expect "❌ Refused to send..."
# Check ~/.codec/audit.log: "imessage_send_blocked" with reason=invalid_recipient_format

# D-18: allowlist file exists
ls -l ~/.codec/plugins.allowlist     # expect -rw-------
cat ~/.codec/plugins.allowlist       # should contain self_improve.py grandfathered entry

# D-18: unauthorized plugin blocked
cp /tmp/test_unauth.py ~/.codec/plugins/test_unauth.py    # benign pre_tool hook
pm2 restart codec-dashboard
# Trigger any tool. Check audit: "plugin_load_blocked" reason=not_in_allowlist

# D-18: approval flow
# In chat: "approve plugin test_unauth.py"
# → expect "✅ Plugin test_unauth.py approved. SHA-256 …<last8>."
# Trigger any tool. Plugin loads normally.

# D-18: timeout (synthetic)
# Write a plugin with pre_tool: time.sleep(1.0)
# Approve. Trigger a tool. Audit shows "plugin_hook_timeout".

🤖 Generated with Claude Code

Mikarina13 and others added 2 commits May 18, 2026 01:07
…D-13, D-18, D-21)

Closes three audit findings that share a "untrusted input crosses a trust
boundary" pattern, even though the surfaces differ:

- D-13 (MEDIUM): AppleScript injection in imessage_send recipient field
- D-21 (LOW): AppleScript injection in do_screenshot_question OCR ctx
- D-18 (MEDIUM): plugin hooks have full Python privileges with no isolation

## D-13 — imessage_send recipient validation

_validate_recipient enforces strict phone/email regex before any
AppleScript interpolation. Phone: ^\+?[1-9]\d{9,14}$ (E.164-ish).
Email: ^[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)+$. Both anchored, length cap
254 (RFC 5321). Quotes, newlines, tabs, carriage returns, backslashes
all rejected — the audit's documented breakout
(`xx@x.com" of targetService\nactivate application "Calculator"...`)
fails the regex. Audit emit `imessage_send_blocked` on refusal.
Text body escape extended to cover `\\`, `\"`, `\r`, `\n`, `\t`.

## D-21 — do_screenshot_question argv binding

The OCR summary is no longer interpolated into the AppleScript source.
The new pattern uses `on run argv` + `item 1 of argv`: Python passes
the body as `osascript -e <script> <body>`, AppleScript reads it from
argv[1] inside the script handler. Adversarial OCR text like
`"\n display dialog "PWNED"` arrives as a literal AppleScript string,
not as additional script source — cannot escape the variable context.

## D-18 — Plugin SHA-256 allowlist + thread-timeout

Three layers:

1. SHA-256 allowlist gate. ~/.codec/plugins.allowlist (0600, atomic
   write) keys plugins by basename. 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 emits
   `plugin_load_blocked` with reason and optional AST `detail`.

2. Daemon-thread timeout. Every hook fire runs inside
   threading.Thread(daemon=True) with hard timeout (default 500ms,
   configurable via plugin_hook_timeout_ms). On timeout: emit
   `plugin_hook_timeout`, return None, calling thread continues.

3. Grandfather migration. First scan() after PR-2F with no allowlist
   file present seeds it with current hashes of all existing plugins
   (approved_by: "initial_migration"). Idempotent.

Allowlist IS the trust decision; AST is only consulted to enrich the
refusal audit emit. Same pattern as PR-1A's skills/.manifest.json.
self_improve.py imports os/subprocess (would fail AST) but its hash
is in the allowlist so it loads.

New operator-only skill: `plugin_approve <filename.py>` writes the
allowlist entry. SKILL_MCP_EXPOSE=False — claude.ai cannot extend
the plugin trust boundary.

## Refactor: per-registry allowlist path

PluginRegistry now owns its allowlist_path attribute (derived from
plugins_dir's parent by default). Tests pointing at tmp plugins dirs
get tmp allowlists — no pollution of the operator's real allowlist.

## Tests

53 new tests across three files:
- tests/test_imessage_send.py (32): regex accepts/rejects, escape,
  audit emit on refusal, no-subprocess-call on invalid recipient
- tests/test_screenshot_question.py (4): source invariant, runtime
  argv binding, adversarial OCR safety
- tests/test_plugin_registry.py (17): allowlist gate, hash mismatch,
  AST detail in refusal audit, grandfather migration idempotent,
  approve_plugin path traversal rejection, hook timeout + emit,
  source-level invariants

146 PR-2-related tests pass; 24 pre-existing failures unchanged.

AGENTS.md §3 + §10 + docs/audits/PHASE-1-SECURITY.md + docs/audits/
PHASE-1-CONSOLIDATED-TRIAGE.md updated with closure documentation.

Reference: docs/audits/PHASE-1-SECURITY.md findings D-13, D-18, D-21.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stale manifest from PR-2F — plugin_approve.py was edited (moved
`import re` inside the function for E402 compliance) after the
prior manifest write. CI's `--check` caught the drift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants