fix(security): AppleScript injection fixes + plugin trust hardening (D-13, D-18, D-21)#55
Open
AVADSA25 wants to merge 2 commits into
Open
fix(security): AppleScript injection fixes + plugin trust hardening (D-13, D-18, D-21)#55AVADSA25 wants to merge 2 commits into
AVADSA25 wants to merge 2 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes three audit findings that share a "untrusted input crosses a trust boundary" pattern:
imessage_sendrecipient field. Skill isSKILL_MCP_EXPOSE=Trueso reachable from claude.ai.do_screenshot_questionfrom adversarial OCR text.D-13 —
imessage_sendrecipient validation_validate_recipientenforces strict format BEFORE any AppleScript interpolation:^\+?[1-9]\d{9,14}$^[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)+$Both anchored, length-capped at 254 (RFC 5321). The audit's documented breakout:
…fails the regex →
imessage_send_blockedaudit emit withrecipient_previewtruncated to 32 chars → noosascriptshellout. Text body escape extended to cover\\,\",\r,\n,\t(backslash first so subsequent escapes don't re-escape).D-21 —
do_screenshot_questionargv bindingOCR summary is no longer interpolated into the AppleScript source. Pattern:
Adversarial OCR text (
"\n display dialog "PWNED") arrives asargv[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:
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_fncomputes the file's SHA-256 and refuses to callexec_moduleif the hash isn't in the allowlist OR has changed. Refusal emitsplugin_load_blockedwithreason ∈ {not_in_allowlist, hash_mismatch, file_unreadable}+ optionaldetail(AST result for forensic clarity).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: emitplugin_hook_timeoutaudit, returnNone, calling thread continues. Daemon=True so process shutdown isn't held up.Grandfather migration. First
scan()after PR-2F lands with no allowlist file present + plugins in~/.codec/plugins/*.pyseeds the allowlist with their current hashes (approved_by: "initial_migration", emitplugin_allowlist_migrated). Idempotent — subsequent scans no-op.Allowlist IS the trust decision; AST is not. Same chokepoint pattern as PR-1A's
skills/.manifest.jsonfor 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 withapproved_by: "operator", clears any prior refusal cache, emitsplugin_approved.SKILL_MCP_EXPOSE=False— claude.ai cannot extend the plugin trust boundary.Existing plugin compat:
self_improve.pyimportsos,threadingetc. — 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
PluginRegistrynow owns itsallowlist_pathattribute (derived fromplugins_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:
tests/test_imessage_send.pytests/test_screenshot_question.pytests/test_plugin_registry.py_fire_one_pre_toolintegration + 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
🤖 Generated with Claude Code