ai newsSa., 17. Okt.
EilmeldungClaude Code can draw now. An experiment in reviewing from inside the agent

Your First Claude Code Mod: Block Edits Outside the Task's Worktree in 30 Lines

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

dev.toSamstag 10:01 · 17.10.9 Min. Lesezeit
Bild: dev.to

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.

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 Enter fullscreen mode Exit fullscreen mode 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.

mkdir -p worktree-guard/.claude-plugin \ worktree-guard/hooks worktree-guard/tests Enter fullscreen mode Exit fullscreen mode 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" } Enter fullscreen mode Exit fullscreen mode 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" ] } Enter fullscreen mode Exit fullscreen mode 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 } }) } Enter fullscreen mode Exit fullscreen mode { 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 the directory for one session, without installing anything:

claude --plugin-dir./worktree-guard Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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.

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 ' }) }) Enter fullscreen mode Exit fullscreen mode 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/.

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" } } Enter fullscreen mode Exit fullscreen mode 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.

It guards two tools by path. It is not a sandbox.

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.

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.

Quelle: dev.to (dev.to). Original öffnen