Claude Code's Hook System Has a Permission Gap, and the Vercel Plugin Just Demonstrated It
Source: hackernews
A few days ago, Akshay Chugh documented something worth paying attention to: the official Vercel plugin for Claude Code was registering a hook that captured the raw text of every prompt a developer typed and forwarding it to Vercel’s telemetry infrastructure. The post reached 267 points on Hacker News and sparked over a hundred comments. Vercel updated the plugin and reframed the collection as opt-out telemetry; the immediate incident is more or less resolved. The structural problem it exposed, however, is not.
To understand why, you need to understand how Claude Code’s hook system actually works, and specifically how it relates to the permission model that the documentation teaches you to configure.
How Claude Code Hooks Work
Claude Code supports a lifecycle hook system configured in .claude/settings.json. There are five event types:
PreToolUse— fires before a tool call executesPostToolUse— fires after a tool call completesNotification— fires when the agent emits a notificationStop— fires when the agent finishes a turnUserPromptSubmit— fires when the user submits a message, before it reaches the model
Hooks are shell subprocesses. They receive structured JSON on stdin:
{
"session_id": "abc123",
"hook_event_type": "UserPromptSubmit",
"prompt": "Refactor the auth module to use JWT instead of sessions"
}
A PreToolUse hook can block a tool call entirely by exiting with code 2. This is useful for enforcing policies: deny rm -rf commands, require confirmation before pushing to production, log sensitive operations to an audit trail. The hook system is genuinely powerful for security and compliance use cases.
The UserPromptSubmit hook fires before the user’s message reaches Anthropic’s API. Whatever subprocess handles that event receives the verbatim prompt text, with the user’s full OS permissions, and can do anything with it: write to disk, open a network socket, call a remote API. Claude Code does not intercept or inspect what the subprocess does.
The Gap in the Permission Model
Claude Code’s permission system lives in the allow and deny arrays of settings.json. You can write rules like:
{
"permissions": {
"deny": [
"Bash(curl *)",
"Bash(wget *)"
]
}
}
These rules constrain what the Claude agent process can do when it invokes tools. They do not constrain what hook subprocesses do. A deny rule on Bash(curl *) has no authority over a UserPromptSubmit hook script that calls curl internally. The permission model and the hook system are orthogonal. One enforces what the AI can do; the other is a plugin execution environment that the AI never touches.
This is not a bug in the traditional sense. The hook system was designed to run subprocesses with user permissions precisely because the security use cases require it: a hook that blocks dangerous commands needs to be able to read the command, evaluate it, and exit with a blocking code. The problem is that the same capability that makes hooks useful for security also makes them a viable channel for data exfiltration, and nothing in the current architecture distinguishes between the two.
Users who carefully read the Claude Code permissions documentation and configure their deny rules have a reasonable mental model of what the agent can and cannot do. That mental model does not include hook behavior at all, because hooks are a separate layer that the permission model documentation does not address.
The Installation Vector Makes It Worse
The Vercel plugin distributed via npm. npm’s postinstall lifecycle event runs automatically during npm install, before the user has seen or reviewed anything about what the package does. The plugin’s postinstall script modified .claude/settings.json to register both an MCP server and the telemetry hook. By the time the installation completed, the hook was active.
There is a compounding factor: the Claude Code documentation recommends committing .claude/settings.json to the repository for shared team configuration. If a developer on a team installs the plugin, the modified settings file gets committed, and every team member who pulls the repo inherits the hook, even if they never ran the plugin install themselves. This is not a hypothetical; it is how the shared configuration model was designed to work.
This combination, automatic modification of a configuration file during installation followed by that file being committed to version control, is structurally similar to the supply chain attack vectors that have caused incidents in the npm ecosystem over the past several years. The Vercel case was not malicious; it was analytics by the package’s own publisher. But the installation mechanism is identical to what an attacker would use.
Why Prompt Data Is Different
Developer telemetry is not new. IDEs have collected usage analytics for years: which commands you run, how often you use certain features, error rates. This data is useful for product teams and mostly benign from a privacy perspective.
Prompt data is categorically different. Developers writing prompts in Claude Code routinely include:
- Proprietary source code, often unredacted and in context
- Internal API shapes and database schemas
- Business logic described in plain language
- Configuration structures that reveal infrastructure details
- Occasionally, credentials or tokens pasted accidentally
A prompt like “Here is our payment processing module, refactor the webhook handler to support idempotency” contains competitive information about your company’s payment architecture. A prompt like “Why is this query slow?” followed by a pasted SQL query reveals your database schema. Developers write prompts without the filtering they apply to commit messages or documentation, because prompts feel transient.
Capturing this data for telemetry, even with opt-out semantics, is not equivalent to capturing which menu items a user clicked. The sensitivity level is closer to capturing clipboard contents or keystrokes, and most developers would not agree to that as an implicit default.
What the MCP Layer Does Not Solve
Claude Code’s plugin ecosystem includes both hooks and MCP (Model Context Protocol) servers. These are different things. An MCP server exposes tools and resources to the Claude agent; it sees only what Claude explicitly passes in a structured tool call. The MCP protocol has its own security considerations around trust boundaries and server authentication, but an MCP server does not have direct access to raw prompt text unless the agent explicitly passes it.
Hooks are different. A UserPromptSubmit hook receives the raw prompt as part of its JSON payload. An MCP server and a hook can be distributed together in the same npm package, which is exactly what the Vercel plugin did. The MCP server handles the legitimate plugin functionality; the hook handles telemetry. From the user’s perspective, they installed one thing; from an architectural standpoint, they installed two components with different capabilities and different trust properties.
What Would Actually Help
The fix here is not “read the source code of every plugin before installing.” That does not scale, and it puts the burden entirely on users rather than on the platform.
Several changes at the platform level would meaningfully reduce the risk:
Hook network authorization. Outbound network requests from hook subprocesses should require explicit authorization in the plugin manifest, visible to the user before installation. Browser extensions have required network permission declarations for years; the pattern is well understood.
Explicit UserPromptSubmit consent. The UserPromptSubmit hook type has a uniquely sensitive capability: it receives raw prompt text before it reaches the model. Access to this hook type should require an explicit, user-visible acknowledgment at install time, separate from the general plugin installation flow.
A hook audit surface in the UI. Right now, understanding what hooks are registered requires reading .claude/settings.json directly. Claude Code could surface registered hooks in the UI with clear labels for what each hook receives, making it legible without JSON parsing.
Postinstall script restrictions. Preventing postinstall scripts from modifying .claude/ configuration files automatically would require explicit user confirmation for this class of change, similar to how some package managers handle global configuration modifications.
None of this requires changes to the MCP protocol or the underlying hook architecture. The hook system is useful; the UserPromptSubmit hook in particular has legitimate applications for organizations that want to enforce prompt policies or log queries for compliance. The goal is making those capabilities visible and consent-based rather than silent defaults.
Vercel acted quickly once the issue was public, and opt-out telemetry is better than opt-in-by-default. But the mechanism that made this possible, hooks that run with full OS permissions and are outside the permission model users are taught to configure, is still there. Every plugin that ships a UserPromptSubmit hook has the same capability. The next case might not be a major company with a reputational incentive to respond quickly.
The Hacker News thread had people noting that this is expected behavior given how the hook system works. That observation is correct and is precisely the problem. When exfiltrating raw prompt data is expected behavior, the permission model needs to expand to cover it.