Why PreToolUse Misses Direct Slash Commands: The Prompt-Layer Fix

The article explains how Claude Code's slash commands bypass PreToolUse hooks, requiring UserPromptExpansion for interception, details skill rendering-phase command execution and permission checks, and covers the 2.1.299 change moving UserPromptSubmit before command expansion.

Architecture Development Notes
Architecture Development Notes
Architecture Development Notes
Why PreToolUse Misses Direct Slash Commands: The Prompt-Layer Fix

Skill Body Commands Run Before the Prompt Reaches the Model

Claude Code skills support inline commands in their body: a line starting with ! followed by a backtick-wrapped command (e.g., !`gh pr diff`) or a fenced block ending with !. During skill rendering, these commands are executed and their output replaces the placeholder. The model receives the rendered output, not the command itself. This rendering happens before the prompt is sent to the model.

- PR diff: !`gh pr diff`
- PR comments: !`gh pr view --comments`
- Changed files: !`gh pr diff --name-only`

Two boundaries apply: replacement occurs once on the original file (command output is not rescanned for further placeholders), and the inline form only triggers when ! is at line start or preceded by whitespace; embedding it in an assignment (e.g., var=!`cmd`) treats it as plain text.

Rendering-Phase Commands Skip Permission Prompts; Permission Check Decides Fate

When rendering a skill, inline commands do not trigger interactive permission prompts. Instead, Claude Code matches each command against the permission rules:

If a deny rule matches, the entire skill invocation aborts with a permission-check failure.

In non-auto mode, any result other than "allow" aborts the call — including rules that would normally prompt. To permit execution, the skill's preamble must list the command in allowed-tools. Deny and ask rules still override allowed-tools.

In auto mode, a command that would normally require confirmation no longer aborts; the skill loads, but the body gains an instruction for Claude to run the command later. When Claude actually invokes the tool, auto-mode checks apply. Exceptions: if the skill sets an agent (runs in a forked sub-agent) or the session lacks a shell tool, the call still aborts.

Thus the permission-check result decides first; mode only changes the path for non-allowed commands. Allowed commands run at render time in both modes; non-allowed commands abort in non-auto mode, while in auto mode they defer to a later tool invocation.

A critical failure point: if an injected command fails permission, the whole skill aborts — the model never sees the skill body. In non-auto mode there is no "ask" intermediate state. A skill that relies on a shell command to fetch data will simply fail to load if its preamble lacks the proper allowed-tools entry.

Organization-level control: enabling disableSkillShellExecution in settings replaces such commands with a placeholder notice for user, project, and plugin skills (managed settings are immutable; built-in and managed skills are unaffected). Skills synced from claude.ai never execute these commands locally, regardless of the setting.

Direct Slash Commands Bypass the Skill Tool — and PreToolUse

The documentation states plainly: a PreToolUse hook matching the Skill tool only fires when Claude invokes that tool. Typing a slash command directly in the input box takes a different path and bypasses PreToolUse[Skill] entirely.

The event that closes this gap is UserPromptExpansion. It triggers when a user-entered command is expanded into a prompt but before it reaches the model. Its matcher works on the command name. The event provides richer fields than UserPromptSubmit: expansion_type: distinguishes local skill/custom command ( slash_command) from MCP server prompt ( mcp_prompt). command_name, command_args, command_source, and the original prompt.

Returning decision: block stops the expansion. A community issue reported this exact gap: PreToolUse / PostToolUse did not fire for direct slash commands, making command-name-based interception impossible. The documentation's answer is to move the check to UserPromptExpansion.

Two Prompt-Layer Events See Different Objects

UserPromptSubmit

fires after prompt submission but before Claude processes it. It receives the raw submitted text, can attach extra context, and can block the entire prompt (allow/block only). It triggers for all prompt sources: user input, scheduled tasks, background sub-agent reports, cross-session messages. It cannot modify the prompt content. UserPromptExpansion sees the parsed command: name, arguments, source. Therefore "block this prompt" and "block this command" are two different gates with different inputs and decision capabilities.

Version 2.1.299 Reorders These Two Steps

Released 2026-10-14, version 2.1.299 moves UserPromptSubmit before skill/custom slash command expansion. The changelog notes: "a hook that blocks the prompt now also stops the inline shell lines in the command." The phrasing "now also" implies that previously blocking the prompt did not necessarily stop those inline commands.

The same version changes another related behavior: a skill's own preamble UserPromptSubmit hook first runs on the next prompt after the skill is invoked. The documentation explains that preamble hooks are registered when the skill is called and persist for the session. Combining these two facts, a skill cannot use its own preamble hook to block the very prompt that invokes it — though this inference is not explicitly stated in the docs.

Where to Place the Guard

Each hook point inspects a different object:

To allow/deny based on command name for skills or slash commands: use UserPromptExpansion with a matcher on the command name. PreToolUse matching Skill does not cover direct input.

To judge based on raw prompt text: use UserPromptSubmit, but it cannot see the parsed command name nor modify content; decision is binary allow/block.

For inline skill commands to run at render time, the prerequisite is a permission-rule "allow" (via preamble allowed-tools), not a hook.

A guard that only watches PreToolUse[Skill] covers just the model-initiated skill invocation; the direct-command path requires UserPromptExpansion. The documentation notes that render-phase injected commands run via the Bash tool and are subject to permission rules, but does not state whether they trigger PreToolUse(Bash) — this article does not conclude on that point. Choosing which layer bears the admission check determines coverage more fundamentally than how finely the criteria are written at that layer.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

hooksClaude Codepermission checksslash commandsPreToolUseskill renderingUserPromptExpansionUserPromptSubmit
Architecture Development Notes
Written by

Architecture Development Notes

Focused on architecture design, technology trend analysis, and practical development experience sharing.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.