Secure Your Claude Skills with Custom PreToolUse Hooks

John Lindquist
InstructorJohn Lindquist

Social Share Links

Tweet

The allowed-tools setting offers good control, but what if you need to enforce even stricter rules, like allowing only specific script directories or blocking commands that reference sensitive files?

This lesson dives into Claude Code Hooks, specifically the PreToolUse hook, to create a powerful security layer for your AI agent. You'll learn how to write a custom hook in TypeScript, powered by Bun, that intercepts every tool call before it executes—adding an extra layer of protection beyond skill-level restrictions.

How it Works

The PreToolUse hook is configured in your settings.json file. It runs a specified command before any tool is used, passing the full tool call context as JSON to the command's standard input. Your hook script can then inspect this context and decide whether to allow or block the command by using an exit code.

  • Exit Code 0: Allow the command to proceed.
  • Exit Code 2: Block the command and stop the agent.

This gives you fine-grained, programmatic control over your agent's capabilities.

Workflow Demonstrated

  • Setup: Create a hook script and configure it in your settings.json.
  • Inspection: Use the @anthropic-ai/claude-agent-sdk to properly type and inspect the tool call context.
  • Validation: Write logic to validate commands against specific patterns (e.g., only allowing bun run scripts/*).
  • Control: Use exit codes to either allow or block tool execution.
  • Refactoring: Leverage Claude Code to refactor existing tools and skills to comply with your new, stricter rules.

By the end of this lesson, you'll have a robust system for securing your AI agent, preventing unwanted commands and ensuring all actions align with your project's security policies.

Key Prompts & Commands

A directory is created and a new bun project is initialized.

mkdir .claude/hooks
cd .claude/hooks
bun init

Install the Claude Agent SDK to get the necessary TypeScript types.

bun i @anthropic-ai/claude-agent-sdk

A prompt to update the hook to enforce a security policy:

@.claude/hooks/index.ts Please update our index.ts to only allow scripts that have been run from the bun run scripts directory. Use regex to check for that pattern. If the condition matches, exit with an exit code of zero. Otherwise, if any other bash tool is called, exit with an exit code of two.

A prompt to make a skill's script cross-platform:

Please update the timestamp script to ensure it works cross-platform between Windows, Linux, and Mac. Don't rely on the system date tool—instead generate a timestamp directly from Bun.

Code Snippets

Configure the PreToolUse hook in your .claude/settings.json to run your script for every tool call:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bun run .claude/hooks/index.ts"
          }
        ]
      }
    ]
  }
}

The final hook (.claude/hooks/index.ts) validates that only bash commands matching the bun run scripts/* pattern are allowed.

import type { PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

interface BashToolInput {
  command: string;
  description?: string;
  timeout?: number;
  run_in_background?: boolean;
}

const input: PreToolUseHookInput = await Bun.stdin.json();

// Only allow Bash commands that run scripts from "bun run scripts" directory
if (input.tool_name === "Bash") {
  const toolInput = input.tool_input as BashToolInput;
  const command = toolInput.command;

  // Block any command that references .env files
  if (command.includes(".env")) {
    process.exit(2);
  }

  // Check if the command matches the pattern "bun run scripts/*"
  const allowedPattern = /^bun\s+run\s+scripts\//;

  if (allowedPattern.test(command)) {
    // Allow this command
    process.exit(0);
  } else {
    // Block all other Bash commands
    process.exit(2);
  }
}

// Allow all non-Bash tools
process.exit(0);

[00:00] When you want even more control over allowed tools, such as restricting what sort of arguments or files that could be used with this tool, you're going to need to turn to clod hooks. And for that I'm going to create a directory called clod slash hooks and go ahead and make this directory. And inside of our Cloud hooks directory I'm going to bind init a project, just a blank one, and then I'll just work with that index.ts file to store our initial hook. Now our hooks are defined in our settings, So again I'm just using local user settings. Rename this to settings.json if you want that to be for your team.

[00:38] But for now I'm going to configure this so that hooks pre-tool use has a definition with a matcher which will match everything, and then an array of hooks. Let me fix the quotes real quick. An array of hooks which we'll call our index file over here. So anytime the pretool use hook is invoked We're going to use bun to invoke our index.ts file. Then to have this work better with TypeScript, we're going to bun install the AnthropicAI CloudAgent SDK, which is going to give us access to types, such as, if we pull them in from the SDK, it'll give us types we can use for pre-tool use hook input, which will allow us to grab the object off of standard in, and Bun has this awesome way of grabbing it and converting it to JSON, which saves us a ton of syntax.

[01:34] Then we'll just assign an input to this and make sure this is cast as pretoolusehookinput, which is the input which is essentially piped in from Clod into our script here. So this is just pseudocode here, but just assume this standard in being piped in is being received here and we can now operate on it. So to inspect this we're going to use Bond to create a file and we'll just put them in the root of our project. Then off the input we'll grab the session ID, then we can grab the input name which will just be pretool use, then you can grab the tool name which will be bash or skill, things like that. And I'm actually going to pull this string out to make it easier to read.

[02:21] Call this file name, then drop the file name in. And then I also want to strip any spaces and convert to lowercase. And just let cursor do that for us. And so now anytime we run Clawed and I'll just say compress index.ts so it will grab our skill. Now that this is run successfully We'll just close this out and now we should have a bunch of data in the root of these files of when a pre-tool use was invoked.

[02:53] Oh and I realized the mistake I made is I didn't give these a timestamp because there should be a couple of bash calls, but these are only separated by bash and skill. So let me update this. I'll say add a timestamp. So cursor added this date.now which should be enough. I do a lot of refactoring in cursor now with a code comment dictation and then accepting a tab.

[03:17] I find it's a great way for me to iterate when I'm just making minor changes. So now that we have timestamps I'll run our plot again, then I'll run this, and now we should have more files this time. So we have these four files of pretooluse-bash for generate timestamp, we have pretooluse-bash for running our compress script, and then one for skill compress, and one for skill timestamp. I'll quickly remove the old ones and then I'm going to use these new ones as essentially data to update our hook. So if I start a new conversation and then I'm just going to drop these files in like a junk folder.

[04:00] So I'll grab all these, toss them in here, and reference our junk directory and our hook. We can now say please update our index.ts to only allow scripts that have been run from the bun run scripts directory. So put in a condition that checks for that. You can just use regex and if that condition matches just exit with a hexa code of 0. Otherwise if any other bash tool is called then exit with an hexa code of 2.

[04:32] Allow it to modify the file. Let's open it up and this looks pretty legit based on what I requested. To get rid of the squiggly we'll say please create a tool input type for us and cast the tool input to that type. And this looks pretty good. This is just because the pretool use hook input, if you look at the tool input, they're all unknown.

[04:55] They just don't have types for this yet. So we're essentially generating our own at this point. So what we're doing is we're checking if it's a bash command. And remember any bun script or any script or any tool run from your system is considered a bash command. And this command on the input, if you look at the demo files, this command was date from our system and then this one is bun run.

[05:20] So we're checking to see if the tool input command matches this pattern, and if it does we'll allow it, if it doesn't we'll exit out. So we'll go ahead and give this another shot, open up clod, tell it to compress index, and now this time you'll see that bash with date failed, and our agent stopped because our hook prevented any bash command that's not a bun script to be run. So now I'm going to tell this, please convert the and reference our skill inside of timestamp, bash commands inside of this file into a bun script so that they'll be allowed to be run by our hooks. And so now our skill from timestamp is going to use a bun run script. And we now have a timestamp script which I'm gonna actually ask it to please update the timestamp script to ensure it works cross-platform between Windows, Linux, and Mac.

[06:15] So don't rely on the date tool, instead generate a timestamp from bun. So now that we have a timestamp script we can start a new Clod session. We'll run our compress index and it successfully ran the timestamp script and it ran the compress script which are now both cross-platform and you now have a lot more control over the information you want to log back for your agent to pick up. And you also have much finer control in the hook to prevent any sort of scenarios that might happen in here. So if I say something like make sure our index.ts prevents any script that may have a .env file as an argument.

[06:56] Now this added a basic line where it checks to see If the string that was passed in attempts to to do anything with .env in it, then it just exits. So you get much finer control over what your commands can do. You could control which directories they're run from. A hint here is that input has the cwd on it, so you could check your current working directory and you just have a lot more options to really lock down your agent from doing things it's not supposed to do. Then finally I do have other lessons about hooks that configure the options for the hook JSON output, where before you process exit you console log some stringified objects which define messages and properties that are sent back.

[07:42] But that's out of scope of this lesson for now, just know that Exit code 0 means success, exit code 2 means fail, or allow and prevent. So no matter how complex you want your matching patterns to be, if you want to spread your hook out across multiple files and cases, and have all sorts of decisions and systems in place, Just remember at the end of your hook you either end it with success or fail.