Blocks Write, Edit and MultiEdit calls that target a path outside the project root or inside .git, plus optional deny globs.

Refuses a Write, Edit or MultiEdit call whose file_path resolves outside the project root or into .git. Claude gets the reason and is told to stay inside the project or ask you.
A tool.call guard that resolves a path in plain JS (no path module in a mod): ~, relative paths, . and .., compared against $.session.repo()?.root ?? $.session.cwd(). It also shows userConfig driving a hook, and a .catch that fails closed.
| Rule | Default |
|---|---|
| Path resolves outside the project root | denied |
Any .git path segment (writes only) | denied |
deny_globs match | none |
allow_outside dirs | none |
guard_reads | off (Read unchecked) |
Relative paths resolve against the session cwd, then . and .. fold (.. never climbs above /). ~ and ~/… expand to $HOME. ./a/../../x and /proj/src/../../../etc/passwd are caught because the check runs on the folded path. The root test is abs === root || abs.startsWith(root + "/"), so /work/proj-evil is not inside /work/proj.
Set with /config or pluginConfigs in settings.json.
| Key | Type | Meaning |
|---|---|---|
deny_globs | string | Comma-separated globs, e.g. **/*.lock,infra/. any depth, * within a segment, ? one character. No / in the pattern: matches at any depth. With a /: anchored to the project root, or to / when it starts with one. A matched directory covers what is under it. |
allow_outside | string | Comma-separated absolute directories (~ allowed) that may be written outside the root, e.g. /tmp. .git and deny_globs still apply there (globs match the absolute path). |
guard_reads | boolean | Apply the outside-root and glob rules to Read too. .git stays readable. |
Headless claude -p --model haiku --permission-mode acceptEdits, project in a temp dir, asked to write a file in a sibling dir and then ok.txt in the project. The outside write was refused and nothing was created there. ok.txt was written. Claude relayed that the path resolves outside the project root. The model made the call; the hook did the refusing.
The deny text:
Path Guard blocked this Write call: "<path>" resolves to <abs>, which is outside the project root. Stay inside the project (<root>) and do not write into .git. If the user really wants this path, ask them to allow it (the allow_outside or deny_globs setting) instead of retrying.
One tool.call hook. It reads the repo root (falls back to cwd) and HOME, normalises file_path, then checks in order: outside the root (unless under an allow_outside dir), .git segment, deny_globs. A hit returns { deny }; otherwise next(e). A failure in the hook itself denies the call.
What claude plugin validate reports:
hooks: tool.call
calls: $.env.get, $.session.cwd, $.session.repo
Requires Claude Code 2.1.287 or later.
claude --plugin-dir ./mods/path-guard # one session
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install path-guard@awesome-claude-mods --scope user
Test it: claude plugin test mods/path-guard. The kit cannot set userConfig, so the option rules are tested by calling register() directly with an options object. The default behaviour was also checked once in a live headless session (see Demo); the config options were not.
echo x > /etc/y, cp, sed -i and scripts bypass it. Pair it with a Bash guard (for example shell-guard or sensitive-file-guard) or a sandbox./Work/Proj is seen as outside /work/proj; that fails closed.file_path is checked. NotebookEdit (notebook_path) and MCP tools that write files are not covered..git is matched as any path segment, so a nested repo's .git is protected too, and so is a directory literally named .git.[...] or {a,b}; they match literally. Commas cannot appear inside a pattern.~user prefix are treated as relative names.None.
hooks/path-guard.mjs 101 lines1// Path Guard: refuses Write/Edit/MultiEdit (and optionally Read) on a path that
2// resolves outside the project root, inside .git, or matches a deny glob.
3//
4// The path is resolved purely in JS (no path module in a mod): `~`, relative
5// paths, `.` and `..` are normalised, then compared with the project root.
6// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
7// spelled literally.
8
9const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
10
11export function register(on, options = {}) {
12 on("tool.call", async ($, e, next) => {
13 const isRead = e.tool === "Read" && options.guard_reads === true;
14 if ((!WRITE_TOOLS.has(e.tool) && !isRead) || typeof e.file_path !== "string") {
15 return next(e);
16 }
17 const cwd = await $.session.cwd();
18 const root = norm((await $.session.repo())?.root ?? cwd, "/", "");
19 const home = String((await $.env.get("HOME")) ?? "");
20 const abs = norm(e.file_path, cwd, home);
21 const rel = abs === root ? "" : abs.slice(root.length + 1);
22
23 const inside = abs === root || abs.startsWith(root === "/" ? "/" : root + "/");
24 const outsideOk = list(options.allow_outside).some((d) => {
25 const dir = norm(d, "/", home);
26 return abs === dir || abs.startsWith(dir === "/" ? "/" : dir + "/");
27 });
28 let rule = null;
29 if (!inside && !outsideOk) {
30 rule = "outside the project root";
31 } else if (!isRead && abs.split("/").includes(".git")) {
32 // ponytail: any `.git` path segment, also nested repos; it does not read .git files
33 rule = "inside .git";
34 } else {
35 const hit = list(options.deny_globs).find((g) => {
36 const re = globRe(g);
37 return re.test(abs) || (inside && re.test(`/${rel}`));
38 });
39 if (hit !== undefined) {
40 rule = `deny_globs pattern "${hit}"`;
41 }
42 }
43 if (rule === null) {
44 return next(e);
45 }
46 return {
47 deny:
48 `Path Guard blocked this ${e.tool} call: "${e.file_path}" resolves to ${abs}, which is ${rule}. ` +
49 `Stay inside the project (${root}) and do not write into .git. ` +
50 `If the user really wants this path, ask them to allow it (the allow_outside or deny_globs setting) instead of retrying.`,
51 };
52 }).catch(async () => ({
53 // fail closed: a skipped guard would let the write through
54 deny: "Path Guard failed while checking this call, so it was not run. Do not retry it unless the user asks you to.",
55 }));
56}
57
58/** Absolute, normalised path: expands `~`, resolves against `base`, folds `.` and `..` (never above `/`). */
59// ponytail: purely lexical; symlinks are not followed and case is compared as-is (macOS is case-insensitive). Add $.process.run(["realpath"]) if that matters.
60function norm(p, base, home) {
61 let s = p === "~" || p.startsWith("~/") ? home + p.slice(1) : p;
62 if (!s.startsWith("/")) {
63 s = `${base}/${s}`;
64 }
65 const out = [];
66 for (const part of s.split("/")) {
67 if (part === "..") {
68 out.pop();
69 } else if (part !== "" && part !== ".") {
70 out.push(part);
71 }
72 }
73 return "/" + out.join("/");
74}
75
76function list(v) {
77 return typeof v === "string" ? v.split(",").map((x) => x.trim()).filter((x) => x !== "") : [];
78}
79
80/** Glob to RegExp: `**` any depth, `*` within a segment, `?` one char. No `/` in the pattern: matches at any depth. Else anchored to the project root, or to `/` if it starts with one. */
81// ponytail: no `[...]` or `{a,b}`; they match literally.
82function globRe(glob) {
83 const g = glob.includes("/") ? glob : `**/${glob}`;
84 let re = g.startsWith("/") || g.startsWith("**") ? "" : "/?";
85 for (let i = 0; i < g.length; i += 1) {
86 const c = g[i];
87 if (c === "*" && g[i + 1] === "*") {
88 const slash = g[i + 2] === "/";
89 re += slash ? "(?:.*/)?" : ".*";
90 i += slash ? 2 : 1;
91 } else if (c === "*") {
92 re += "[^/]*";
93 } else if (c === "?") {
94 re += "[^/]";
95 } else {
96 re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
97 }
98 }
99 return new RegExp(`^${re}(?:/.*)?$`); // a matched directory covers what is under it
100}
101