context-attachment-manager
Codex and Claude Code plugin (works in Claude Desktop): choose which past images go to the model with your next message. Unchecked images become placeholders, the conversation goes on, and requests st
Claude can draft it. You decide if it sends. A tested hook that blocks sends, posts and payments until you approve them.
PluginsJavaScriptGitHub @KylefrcOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Claude Code PreToolUse hook that blocks sends, posts and payments until you approve the exact action. Installs as a plugin. Dependency-free Node, MIT, 156 tests.
Taken from the repository README
Loading the full README.
Read the full README ↗A Claude Code PreToolUse hook that blocks tool calls which send a message, publish a post, or move money, unless you approved that specific action a moment ago.
Dependency-free Node (20 or newer), MIT licensed, 156 regression tests. Independent project, not affiliated with or endorsed by Anthropic.
A rule in a prompt or in CLAUDE.md ("always ask before sending") is advice. The model usually follows it. Usually is not a control.
The Claude Code permissions docs say it directly: instructions in your prompt or CLAUDE.md "shape what Claude tries to do, but they don't change what Claude Code allows." If you run in a mode that skips permission prompts (bypassPermissions is documented as one), or you have clicked "always allow" on a mail or chat tool, nothing outside the model's own judgment sits between a draft and a send.
A hook does not depend on the model's judgment. Hooks run before the permission prompt, and a hook that returns a deny decision, or exits with code 2, stops the tool call. So the check lives in code that the model cannot talk its way past, and the approval step is something you do on purpose.
This repo is that check, for the narrow case of "this action reaches a person or moves money".
References:
"deny" prevents the tool call; exit code 2 blocks it)What was checked: on 2026-10-25, Claude Code 2.1.310 was run headless (claude -p) with --dangerously-skip-permissions and this hook wired in. A gated sendmail -bp (it only prints the mail queue) was denied; the approve script, run from Claude's Bash tool with its file name split so the text guard did not match, exited 1 and wrote nothing; and after an approval typed at a terminal, the gated command went through once. The transcript is LIVE-TEST.txt. That was headless mode, not the interactive screen, one version, one machine. The test suite runs the hook directly with sample payloads. Run the "Try it" step below in your own setup before you rely on it.
Claude Code sends the hook a JSON payload on stdin (tool_name, tool_input) before every tool call. The gate classifies the call. If it does not look like a send or a payment, the hook exits 0 and prints nothing. If it does, the policy is:
~/.claude/away.flag exists, deny. Nothing gets out, approved or not.--match, it only fits a call that mentions that text.Before any of that, a guard denies a call that would approve a send, lift away mode, uninstall the gate, edit the gate's own files or build a pseudo-terminal.
What counts as a send:
send_email, chatPostMessage). It is gated when a send, pay, buy or notify word appears (send, reply, forward, publish, tweet, post, submit, pay, charge, refund, transfer, payout, sms, dial, order, broadcast, invoice, finalize, void, cancel, comment, merge, schedule, book, notify, invite, enroll, buy, purchase, checkout) and no read word (list, get, read, search, find, fetch, query, ...) comes before it. send_email, create_refund, finalize_invoice and merge_pull_request are blocked. create_draft, list_sent_messages, list_invoices and get_order pass. Drafts save; they do not send.mail, sendmail, mutt, msmtp), osascript scripts that send from Mail, Messages, Outlook or Slack, gh issue|pr|release writes and gh api writes, aws ses|sns send/publish, the stripe CLI's create/cancel/refund verbs, and curl, wget, python, node and similar that make a mutating HTTP call (POST, PUT, PATCH, DELETE, -d, --data, --post-data, requests.post) to a known mail, chat, SMS, CRM, accounting, scheduling or payment host, or call a send SDK (smtplib, Twilio messages.create). That includes calls hidden inside a script file the command runs. The program is what counts, so git commit -m "fix sendmail handling" and grep "curl -X POST https://api.stripe.com" docs/ pass.The token is a file, ~/.claude/state/send-gate/send-approved. You mint it, in your own terminal window, not through Claude:
node ~/.claude/bin/approve-send.mjs --match [email protected] "invoice reminder"
The script prints what you are approving, shows a random 4-digit code, and writes the token only if you type that code. It reads the code from /dev/tty, the terminal a person is sitting at. Claude's Bash tool has no terminal, so there the script prints an error, exits 1 and writes nothing, and piping a code into it does not help. --match is optional; with it, the token only fits a call whose tool name or input contains that text (case-insensitive), and a different call is denied without using the token up.
The token is valid for 180 seconds by default (tokenTtlSeconds in send-gate.json changes that). A stale token is deleted and the call is denied. The note you pass is written to the log so you can audit what each approval was for.
Away mode is one flag file. Turning it on works anywhere; turning it off also asks for a code at your own terminal:
node ~/.claude/bin/away-mode.mjs on # block every gated action, token or not
node ~/.claude/bin/away-mode.mjs off # needs a person at a terminal
node ~/.claude/bin/away-mode.mjs status
Every decision is appended to ~/.claude/state/send-gate/send-gate.log.
This is real output from the shipped files, run against a throwaway HOME the same way the tests run them (the terminal step was driven through a pseudo-terminal, standing in for you). The deny message is one JSON line in practice; it is wrapped here to read it.
1. send with no token
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SEND GATE: this action reaches a person or moves money (mcp__gmail__send_email). It was NOT sent.
To proceed: (1) show the owner the exact final content and recipient and get an explicit \"yes\" to THAT action in this conversation; (2) ask the owner to approve it in their own terminal window, not through you: node ~/.claude/bin/approve-send.mjs --match \"<recipient>\" \"<what>\" (it asks them to type a code, which you cannot do); (3) when the owner says it is approved, retry the same call within 180s.
Do not run approve-send yourself and do not try to create the approval another way. Unattended or scheduled runs stay draft-only."}}
2. you, in your own terminal: node bin/approve-send.mjs --match [email protected] "invoice reminder"
SEND GATE approval. This lets ONE gated action through, once, in the next few minutes.
Action: invoice reminder
Only for a call that mentions: [email protected]
Type 5601 and press Return to approve (anything else cancels): 5601
Approved the next gated action (invoice reminder; only calls mentioning "[email protected]"). Tell Claude it is approved; it expires soon.
3. same send again -> no output, exit 0 (allowed, token consumed)
4. same send a second time -> deny
5. mcp__gmail__create_draft -> no output, exit 0 (allowed)
6. away mode on, new token, same send -> deny ("away mode is ON ...")
and the log from that run (message text shortened):
DENY mcp__gmail__send_email :: SEND GATE: this action reaches a person or moves money ...
ALLOW mcp__gmail__send_email :: token "invoice reminder" (match "[email protected]") (0s old)
DENY mcp__gmail__send_email :: SEND GATE: this action reaches a person or moves money ...
DENY mcp__gmail__send_email :: SEND GATE: away mode is ON, so nothing that reaches a person or moves money can run. ...
If you would rather not run the installer, add it from the plugin marketplace in this repo. Inside Claude Code:
/plugin marketplace add Kylefrc/claude-code-send-gate
/plugin install send-gate@send-gate
(On Claude Code 2.1.275 or later, /plugin install send-gate --marketplace Kylefrc/claude-code-send-gate does both steps.) Then run /hooks to confirm the PreToolUse entry is listed; start a new session if it is not. The hook runs from the plugin's own folder, so nothing is written to your settings.json.
The approval script is not copied to ~/.claude/bin/ by the plugin, but the deny message points there. Copy the two scripts once, from the plugin folder, so the printed command works:
mkdir -p ~/.claude/bin && cp ~/.claude/plugins/cache/send-gate/send-gate/*/bin/*.mjs ~/.claude/bin/
Use the plugin or install-gate.mjs, not both. The approval token is single-use, so with two copies of the hook the first one consumes it and the second denies the call. To remove the plugin: /plugin uninstall send-gate@send-gate. The same Node 20+ and macOS or Linux notes as below apply, and the plugin route has been tested only through claude plugin validate and a local install, not on Windows.
Needs Node 20 or newer (node -v) and Claude Code on macOS or Linux. On Windows, use WSL: the hook command in settings.json is a POSIX shell line.
git clone https://github.com/Kylefrc/claude-code-send-gate && cd claude-code-send-gate
node install-gate.mjs --dry-run
node install-gate.mjs && node tests/test-send-gate.mjs
The installer:
hooks/send-gate.mjs to ~/.claude/hooks/, and bin/approve-send.mjs and bin/away-mode.mjs to ~/.claude/bin/--force~/.claude/settings.json to settings.json.bak-send-gate before changing it, adds one PreToolUse entry (no matcher, so it sees every tool call, 5 second timeout), and leaves your other hooks and settings alonesettings.json is not valid JSONThe wired command fails closed on one specific thing: if node cannot be found, the call is blocked with a message instead of passing silently.
Then run /hooks in Claude Code to confirm the hook is listed (start a new session if it is not).
Try it: ask Claude to send a test message through any connected mail or chat tool. It should be blocked and told how to ask you for approval. Approve it the proper way (a second terminal window of your own, node ~/.claude/bin/approve-send.mjs --match "<recipient>" "<what>", type the code), tell Claude it is approved, and watch it go through once.
$ node tests/test-send-gate.mjs
156 passed, 0 failed
Run on macOS (Darwin 24.1.0) with Node v20.11.0 and v22.23.1. The 156 cases are 89 classification checks (what is gated and what passes, including commands that only mention a sender), 6 token checks (one use, consumed, untouched by ungated calls, stale tokens denied and removed), 6 recipient-match checks, 3 away-mode checks, 31 self-approval guard checks, 8 config checks and 13 terminal checks (the approval scripts refuse without a terminal and work with one). Each case runs in a temporary HOME, so the suite never touches your real ~/.claude. The terminal checks use python3 to drive a pseudo-terminal; without it they are reported as skipped.
What the tests are: the hook run directly with sample JSON payloads. What they are not: a Claude Code session, or proof that every tool in your setup is covered. Passing means those named cases behave as written. After you install, ask Claude to attempt a send with each of your own tools and watch what happens.
TEST-TRANSCRIPT.txt has the transcript of the installer checks (dry run, install, repeat install, uninstall, existing hooks preserved, no overwrite without --force, invalid settings left alone). LIVE-TEST.txt has the headless Claude Code run described above.
Catches, out of the box:
osascript sends, gh issue, PR and release writes, aws ses and aws sns, the stripe CLI, and curl, wget, python or node calls that POST, PUT, PATCH or DELETE to Gmail, Microsoft Graph, SendGrid, Mailgun, Postmark, Resend, Twilio, Slack, Discord, Telegram, Stripe, Square, PayPal, Mailchimp, OpenPhone, Dialpad, HubSpot, Salesforce, Pipedrive, QuickBooks, Xero, Calendly and several more (the full list is HOSTS in the hook)Cannot see, so cover these in your CLAUDE.md and your own review:
extraHosts.notify_customer is listed, but alert_customer is not). Add them to extraGatedTools.git push, npm publish and deploys. Off by default; add "extraShellPatterns": ["\\bgit\\s+push\\b"] to turn on the one you want. GitHub create_issue and similar MCP writes are not gated either; add them to extraGatedTools.submit_review or a spreadsheet's merge_cells, for example). Use allowTools for those.Limits of the design:
--match, the approval is not tied to a particular call: the next gated call within the time window uses the token, whatever its recipient. With --match, the call must mention the text you gave./dev/tty. Claude's Bash tool has no terminal, so Claude cannot type the code. But any process that can run Python can open its own pseudo-terminal and type it (the test suite does exactly that to test the script). The hook therefore also denies commands, and scripts they run, that name approve-send, the token file, away.flag or the gate's own files, or that build a pseudo-terminal. That is text matching, and it can be dodged: in the headless run a split file name got past it (the terminal requirement still stopped the script). I did not try anything cleverer than that. This is a safeguard against mistakes, drift and over-eager automation, not a sandbox.disableAllHooks set outside Claude Code, or starting Claude Code with --bare) turn this off too, and so does an edit to settings.json made outside Claude Code. The hook does block Claude's own Edit, Write and shell writes to settings.json, but a command that builds the file name from pieces can still dodge that text match. A permissions.ask rule on Bash(*approve-send*) adds a second layer; the permission-modes docs say ask rules still prompt in bypass mode, and a deny rule and an ask rule held in a headless bypass run (see LIVE-TEST.txt).Everything is optional. Create ~/.claude/send-gate.json; it is merged over the defaults, and a file that does not parse is ignored.
{
"extraGatedTools": ["^mcp__mycrm__update_deal$"],
"allowTools": ["^mcp__slack__send_message_to_self$"],
"extraHosts": ["api.mycrm.com"],
"extraShellPatterns": ["\\bgit\\s+push\\b"],
"tokenTtlSeconds": 180
}
extraGatedTools: regexes (case-insensitive) matched against the full tool name. A match is always gated.allowTools: regexes for MCP tools to exempt from the name rule. Checked first. It does not exempt shell commands or browser scripts, which have their own checks.extraHosts: hostnames added to the list of mail, chat, CRM and payment hosts for shell commands.extraShellPatterns: regexes tested against each part of a shell command with its quoted text removed. A match is always gated.tokenTtlSeconds: how long an approval stays valid.node install-gate.mjs --uninstall
This removes the PreToolUse entry that references send-gate.mjs (other hooks stay) and the three runtime files. A file that differs from the copy in this repo is left alone unless you add --force. It leaves ~/.claude/send-gate.json, the log in ~/.claude/state/send-gate/, and the settings backup in place. Or restore settings.json.bak-send-gate by hand.
hooks/send-gate.mjs the hook
bin/approve-send.mjs mints a single-use approval token (needs a code typed at a terminal)
bin/away-mode.mjs on / off / status for the away flag (off needs a terminal)
tests/test-send-gate.mjs 156 cases, runs in a temporary HOME
install-gate.mjs installer / uninstaller
.claude-plugin/plugin.json plugin manifest
.claude-plugin/marketplace.json marketplace entry (source is this repo)
hooks/hooks.json plugin hook wiring (runs hooks/send-gate.mjs)
TEST-TRANSCRIPT.txt installer test transcript
LIVE-TEST.txt headless Claude Code run, bypass mode
I also wrote a fuller Claude Code setup (a CLAUDE.md template, a memory system, two skills, rules for sub-agents, seven rule packs for common business tools and a written playbook) that is sold separately as The Operator Kit for Claude Code, USD 19 at https://operatorkit-cc.netlify.app. The gate here is complete without it.
MIT. See LICENSE.