- Book: TypeScript Essentials
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your agent is working in a git worktree, on its own branch, in a folder next to your main checkout. Then a stack trace from your dev server lands in its context. Every path in that trace points at the main checkout, one directory over. The agent opens ../app/src/config.ts, fixes the bug there, and your "isolated" task has just edited the copy you were using.
Worktrees keep agents apart on disk. They don't stop an agent from writing to a path outside its own folder. In Claude Code's acceptEdits mode, edits outside the working directory still ask you first, but bypassPermissions skips the prompts, and a prompt you approve at the end of a long day is still an approval.
Claude Code v2.1.287 and later can run mods: plugins whose JavaScript or TypeScript handlers run inside Claude Code when an event fires, such as a tool call. A handler can watch the event, change it, or answer it in Claude Code's place. This post builds one that answers Edit and Write calls aimed outside the session's folder with a refusal. It is 30 lines of JavaScript, plus two small JSON files.
What a mod is
A mod is a plugin with three files, laid out the way the create-a-mod tutorial lays out its own:
worktree-guard/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.js
plugin.json is the plugin manifest. hooks.json points to your code, and its modules key is what makes the plugin a mod. register.js is the code, which the docs call the hooks module. You don't need Node.js or a build step, because Claude Code loads .js and .ts files directly.
When the mod loads, Claude Code calls the register function the file exports and passes it a function named on. Each on call registers a hook for one event. Every hook gets three arguments: $ (the mods API), e (the event, frozen), and next (the rest of the chain, ending in Claude Code's own behaviour). Return next(e) and the tool runs. Return something else without calling next, and you have answered the event yourself.

The worktree is the plot. The mod is the gate.
Write the guard
Create the directories:
mkdir -p worktree-guard/.claude-plugin \
worktree-guard/hooks worktree-guard/tests
Save the manifest as worktree-guard/.claude-plugin/plugin.json. Don't start the name with claude-: claude plugin validate fails names that look like Anthropic's own.
{
"name": "worktree-guard",
"version": "0.1.0",
"description": "Refuses Edit and Write outside the session root"
}
Save the pointer to your code as worktree-guard/hooks/hooks.json. The path is relative to this file:
{
"description": "worktree-guard hooks module",
"modules": ["./register.js"]
}
And the guard itself, worktree-guard/hooks/register.js:
// Refuse Edit/Write outside the session's project root
function normalize(p) {
const out = []
for (const part of p.split('/')) {
if (part === '..') out.pop()
else if (part && part !== '.') out.push(part)
}
return '/' + out.join('/')
}
function isInside(root, file) {
const r = normalize(root)
const f = normalize(file.startsWith('/') ? file : r + '/' + file)
return f === r || f.startsWith(r + '/')
}
async function guard($, e, next) {
const root = await $.session.root()
if (isInside(root, e.file_path)) return next(e)
return { deny: `${e.file_path} is outside ${root}. ` +
'Edit files in this worktree, or ask the user first.' }
}
export function register(on) {
on('tool.call', { tool: ['Edit', 'Write'] }, guard)
.catch(async ($, e, next) => {
if (next.called) return next(e)
return { deny: 'worktree-guard failed: ' + next.error.kind }
})
}
{ tool: ['Edit', 'Write'] } is the matcher, a filter. An array matches any value in it, so the hook runs for those two tools only. Both carry their target as e.file_path, the field the events guide reads in its own Edit/Write example.
For the root, the mods API has both $.session.cwd() and $.session.root(). The type declarations describe root() as where the session started, or where /cd or a worktree move took it, and say a shell cd during the session does not move it. That is the boundary you want: start Claude Code in a worktree and the root is that worktree.
The hooks module has no Node.js APIs, so there is no path module to lean on (API docs). normalize folds ., .. and doubled slashes by hand, so /work/app/../main/x.ts becomes /work/main/x.ts before the comparison. The r + '/' in the prefix test stops /work/app-old from passing as a child of /work/app.
Returning { deny } without calling next means the tool never runs and no permission prompt appears. Claude reads the deny text as the tool's result, so write it as an instruction. Claude Code runs its permission check inside next, so the refusal holds in every permission mode, bypassPermissions included.
A hook that throws or times out is skipped, and the edit would go through. The .catch handler makes the guard fail closed: if guard failed before calling next, the edit is refused. The handler has a one-second limit of its own (reference), and if it fails too, the hook is skipped.
Load it and check what it touches
Load the directory for one session, without installing anything:
claude --plugin-dir ./worktree-guard
Type /plugin at the prompt. The overview says a dim line under the tabs gives the count and the names of loaded mods, so this one should read 1 mod active · worktree-guard. If the name is missing, the module didn't load.
Ask Claude to change a file one level up, for example add a comment to the top of ../README.md. The call should come back refused, with your message as the result. An edit inside the folder goes through as usual. Claude Code hot-reloads a --plugin-dir mod when you save a file in it, so you can tweak the deny text without restarting.
Before you share a mod, or install someone else's, read it the way Claude Code does:
claude plugin validate ./worktree-guard
validate runs the same static analysis Claude Code runs at load time, without executing your code. Look at three lines. The hooks: line should list tool.call with its tool filter in braces. The calls: line should list $.session.root and nothing else: no $.fs, no $.process, no $.http. And because this hook can refuse an action, validate prints a gating-hook line saying whether it has a .catch handler. This one does.
That calls: line is your audit. Mods aren't sandboxed: they run with your permissions and can read files, start processes and make network requests through the mods API.
Test it without a session
claude plugin test runs any *.test.ts file in the plugin directory with no session, sign-in or network (test docs). Nothing real answers in a test, so you stub the session root and the tool itself. Save this as worktree-guard/tests/guard.test.ts:
import { expect, test } from 'claude-code/testing'
test('refuses a write outside the root', async ($, on) => {
on('session.root', () => ({ value: '/work/app' }))
on('tool.call', () => ({ result: 'ok' }))
const out = await $.tool.call({
tool: 'Write',
file_path: '/work/app/../main/x.ts',
content: '',
})
expect(out.deny).toBeDefined()
})
test('lets an edit inside the root through', async ($, on) => {
on('session.root', () => ({ value: '/work/app' }))
on('tool.call', () => ({ result: 'ok' }))
const out = await $.tool.call({
tool: 'Edit',
file_path: '/work/app/src/a.ts',
old_string: 'a',
new_string: 'b',
})
expect(out).toEqual({ result: 'ok' })
})
A stub for a mods API call is named without the $. and returns { value }. The tool.call stub stands in for Claude Code running the tool, so a call the guard lets through resolves to { result: 'ok' }. Run claude plugin test from the worktree-guard directory; it exits with status 1 on a failure, so it fits in CI. I haven't run this suite myself, so if a field name disagrees with your build, trust the declarations Claude Code writes into .claude-plugin/types/.
Every Hermes task, guarded
The guard gets more useful when every task already has a worktree. I build Hermes IDE, so weigh this section with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) starts each task on its own hermes/<name> branch, in its own worktree, with the task as the agent's first prompt. Claude Code runs there as the regular CLI in a real terminal, so a Claude Code session in a Hermes task starts with its root at that task's worktree.

The ⌘N New task sheet in Hermes 2.3.4: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, and a preview of the claude command Hermes will run.
You can't add --plugin-dir to a command you don't type yourself. For that case Claude Code reads CLAUDE_CODE_PLUGIN_DIRS, an environment variable the reference describes as plugin directories "for apps you can't pass a flag to". It takes absolute paths and can live in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/mods/worktree-guard"
}
}
Put the mod somewhere outside your repositories, so no task can rewrite the guard through Edit or Write. From then on, every normal Claude Code session loads the mod, wherever it was started, and an Edit or Write aimed outside the folder Hermes made for the task comes back refused.

The Review Desk's Worktrees tab: one Hermes worktree each for orders-api, web-app and orders-types, with each one's hermes/ branch, task, age and disk size.
One interaction to know about. Since Hermes 2.3.0, a task can include up to 5 extra folders, and those are worked on where they are, on their current branch, not in the task's worktree. The guard will refuse Claude's edits in them. If you use extra folders, add their paths to an allow list in isInside, or leave the guard off for those tasks.
What this mod doesn't stop
It guards two tools by path. It is not a sandbox.
-
Bash.
sed -i,cporecho >into../appnever goes throughEditorWrite. OutsidebypassPermissions, Claude Code checks output redirects andteetargets against the working directories and asks first. Its sandboxing isolates the Bash commands Claude runs. -
Other writers.
NotebookEditand MCP tools that write files carry their paths in other fields. Add them to the matcher with the field each one uses. - Symlinks. The check compares path strings. A symlink inside the worktree that points outside it passes.
-
Subagents.
tool.callfires for subagent calls too. I haven't checked what$.session.root()returns inside a subagent that runs in its own worktree, so test that before you rely on it. -
POSIX paths only.
normalizesplits on/. On Windows you would need to handle drive letters and backslashes.
If you'd rather not write JavaScript, a PreToolUse settings hook running a script you already have can block the same calls, and the docs' comparison table recommends exactly that case for settings hooks. What the mod adds is claude plugin test and a validate report anyone can read before loading it.
If this was useful
The hooks module is a plain ES module of async functions, and once you rename it to register.ts and point modules at the new name, the declarations Claude Code writes into .claude-plugin/types/ narrow e as soon as you check e.tool. That is the everyday TypeScript in TypeScript Essentials: types, narrowing, modules and async, across Node, Bun, Deno and the browser. If you write mods, it's the book that makes those type files read like documentation.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Start with books 1 and 2. Coming from Kotlin or Java, take book 3 in place of book 1; coming from PHP, book 4. If you ship TypeScript at work, add book 5.
Hermes IDE is free, source-available under BSL 1.1, on macOS, Windows and Linux, and not affiliated with Anthropic or any agent vendor: download it or star it on GitHub.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)