workspace-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,13 +3,104 @@
3
3
  Decide whether a shell command or a file path would write outside a project
4
4
  root. It is the check you want in front of a coding agent you are not watching.
5
5
 
6
- `guard` answers one question and returns one bit. It does not run anything, it
7
- does not touch the filesystem beyond resolving symlinks, and it has no runtime
8
- dependencies — Node's standard library only.
6
+ Two entry points, one decision:
9
7
 
10
- ## Run it
8
+ - **`guard-hook`** — a `PreToolUse` hook. It reads the tool call the agent is
9
+ about to make and denies it before it runs. Allowed calls are silent: no
10
+ prompt, no output, nothing added to your day.
11
+ - **`guard`** — the same check as a CLI, for a wrapper or a script that wants
12
+ the verdict itself.
11
13
 
12
- Requires Node 18 or newer. There is nothing to install and nothing to build.
14
+ Both answer one question and return one bit. Neither runs anything, neither
15
+ touches the filesystem beyond resolving symlinks, and there are no runtime
16
+ dependencies — Node's standard library only. Requires Node 18 or newer.
17
+
18
+ The source, the test suite and the issue tracker live at
19
+ <https://github.com/plainroot/workspace-guard>.
20
+
21
+ ## Use it as a hook
22
+
23
+ ```
24
+ npm install -g workspace-guard
25
+ ```
26
+
27
+ Then in `~/.claude/settings.json`, or a project's `.claude/settings.json`:
28
+
29
+ ```json
30
+ {
31
+ "hooks": {
32
+ "PreToolUse": [
33
+ {
34
+ "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit",
35
+ "hooks": [{ "type": "command", "command": "guard-hook" }]
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ ```
41
+
42
+ The root is the directory the hook process starts in. To pin it instead, set
43
+ `WORKSPACE_GUARD_ROOT`. The command is run as a shell command, so an inline
44
+ assignment works, and Claude Code puts the project directory in the environment
45
+ as `CLAUDE_PROJECT_DIR`:
46
+
47
+ ```json
48
+ { "type": "command", "command": "WORKSPACE_GUARD_ROOT=\"$CLAUDE_PROJECT_DIR\" guard-hook" }
49
+ ```
50
+
51
+ Without a global install, name the file:
52
+ `"command": "node /abs/path/to/workspace-guard/bin/guard-hook.mjs"`.
53
+
54
+ ### What the first denial looks like
55
+
56
+ The tool call does not run, and the agent is handed one sentence:
57
+
58
+ ```
59
+ Blocked by workspace-guard: escapes the root through "..": ../notes.txt -> /Users/you/notes.txt. The project root is /Users/you/project.
60
+ ```
61
+
62
+ That is the whole interface. There is no prompt to answer and no log to read —
63
+ every call that is fine produces no output at all. The reason deliberately does
64
+ not mention how the guard is configured or switched off, because an agent told
65
+ where the off switch is will reach for it: issue #93602 on `anthropics/claude-code`
66
+ records one reading a block and reasoning *"Remove-Item is blocked. Alternative
67
+ approaches: robocopy /MOVE or cmd rmdir"*, then deleting 10,382 files with the
68
+ alternative.
69
+
70
+ ### It fails open, on purpose
71
+
72
+ A payload it cannot parse, a payload in a shape it does not recognise, a bug of
73
+ ours — in all three cases the hook prints nothing, exits `0`, and the call
74
+ proceeds.
75
+
76
+ This is deliberate and it is a real cost. A `PreToolUse` hook sits in front of
77
+ *every* tool call, so a hook that errors closed stops all work rather than
78
+ dangerous work, and an agent wedged by a crash in a guardrail is how people
79
+ learn to remove guardrails. The trade is stated so you can decide against it:
80
+ if `guard-hook` does not understand a call, that call is not checked.
81
+
82
+ Silence is not approval. Exiting `0` with no output means the hook has no
83
+ decision to report, so the call continues through whatever permission rules you
84
+ already have. `guard-hook` only ever denies; it never grants, and it cannot
85
+ loosen a rule you set elsewhere.
86
+
87
+ ### It is still not a sandbox
88
+
89
+ A hook is stronger than a CLI and weaker than a sandbox. Stronger, because it
90
+ denies the action rather than returning a verdict for someone else to act on.
91
+ Weaker, because it reads the command as text and is **not a shell parser** — a
92
+ command constructed so the splitter cannot see the write goes through, and
93
+ nothing stops a call it allowed from doing something it did not model. It is
94
+ built for a capable agent that may be careless, not for one that is hostile.
95
+
96
+ If you need the kernel to say no regardless of what the agent reasons its way
97
+ into — bubblewrap, firejail, a container, a VM — use one of those. This is not
98
+ a substitute for one, and the section ["What it is not"](#what-it-is-not) below
99
+ applies to the hook exactly as it does to the CLI.
100
+
101
+ ## Run it as a CLI
102
+
103
+ There is nothing to install and nothing to build.
13
104
 
14
105
  ```
15
106
  node bin/guard.mjs check --root <dir> --command "<shell command>"
@@ -41,30 +132,38 @@ it defaults to `--root`.
41
132
  It is also importable:
42
133
 
43
134
  ```js
44
- import { checkCommand, checkPath } from "./src/guard.mjs";
135
+ import { checkCommand, checkPath } from "workspace-guard";
45
136
 
46
137
  checkCommand("rm -rf build", { root: process.cwd() });
47
138
  // { allowed: false, reason: "deletes recursively and without prompting: rm -rf build" }
48
139
  ```
49
140
 
141
+ `workspace-guard/hook` exports `decide(payload, { root })` for wiring the same
142
+ decision into a hook runner of your own.
143
+
50
144
  ## Verify it works
51
145
 
52
146
  ```
53
147
  npm test # or: node --test test/
54
148
  ```
55
149
 
56
- 18 tests, no network, no fixtures to install. The suite is table-driven: every
150
+ 26 tests, no network, no fixtures to install. The suite is table-driven: every
57
151
  threat below has a case, and so does every ordinary command that must keep
58
152
  working. A guard that denies everything is not a guard, so the allow table is
59
153
  the half worth reading — an ordinary build, a write inside the root, a plain
60
154
  `git push origin <branch>`, a recursive delete of `build/`, and a copy that
61
155
  *reads* from outside the root while writing inside it are all allowed.
62
156
 
157
+ The hook has its own table, fed real `PreToolUse` payloads, including eleven
158
+ malformed ones that must each fail open with empty output, and a case asserting
159
+ that a deny reason never names the environment variable or the settings file.
160
+
63
161
  To satisfy yourself by hand, without trusting the suite:
64
162
 
65
163
  ```
66
164
  node bin/guard.mjs check --root . --path /etc/hosts # prints deny, exits 1
67
165
  node bin/guard.mjs check --root . --path src/index.js # prints allow, exits 0
166
+ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ../"}}' | WORKSPACE_GUARD_ROOT=$PWD node bin/guard-hook.mjs
68
167
  ```
69
168
 
70
169
  ## What it denies
@@ -88,11 +187,40 @@ Destination-aware commands (`cp`, `mv`, `rsync`, `install`, `tee`, `touch`,
88
187
  `mkdir`, `dd`, `sed -i`, …) are judged on where they *write*, not on every path
89
188
  they mention, so reading a file from outside the root into the project is fine.
90
189
 
190
+ ## It also watches git
191
+
192
+ Everything above is about the filesystem, which is where the attention goes.
193
+ The other way an unattended agent does something you cannot take back is git,
194
+ and the same check covers it:
195
+
196
+ | Denied | Why |
197
+ | --- | --- |
198
+ | `git push --force …`, `git push -uf origin main` | discards commits that exist only on the remote |
199
+ | `git push upstream main` | sends your work to a remote you did not nominate |
200
+ | `git -C /elsewhere push` | drives a repository outside the root |
201
+
202
+ A plain `git push origin <branch>` is allowed, as is `git push -u origin main`,
203
+ and so is every read-only git command.
204
+
205
+ This is the less-covered half. Agents committing, pushing and force-pushing
206
+ without approval — sometimes overriding a rule written down for them — is a
207
+ filed and repeated complaint: `anthropics/claude-code` issue #30475, *"Claude
208
+ Code often commits and pushes without showing the changes first and waiting for
209
+ user approval… even when the project has explicit guidelines (in CLAUDE.md)"*,
210
+ and a Tell HN, *"Cursor agent force-pushed despite explicit 'ask for permission'
211
+ rules"* ([46728766](https://news.ycombinator.com/item?id=46728766)). A container
212
+ or a VM contains the filesystem damage and does nothing about this: it has the
213
+ repository and the network, so the push leaves the sandbox exactly as intended.
214
+
91
215
  ## What it is not
92
216
 
93
- Not a sandbox. `guard` is a judgement, not an enforcement mechanism: it returns
94
- a verdict and something else has to act on it. Nothing stops a command it
95
- approved from doing something it did not model.
217
+ Not a sandbox. The CLI is a judgement and not an enforcement mechanism at all:
218
+ it returns a verdict and something else has to act on it. The hook does act —
219
+ it denies the call — but it is still not confinement. Both see only what the
220
+ agent declares it is about to do, the hook lets through anything it cannot
221
+ read, and nothing stops a call either of them approved from doing something it
222
+ did not model. Enforcement that holds against an agent working around it lives in
223
+ the kernel, not in a package like this one.
96
224
 
97
225
  Not a shell parser. Commands are split on `;`, `|`, `&&` and `||` and one layer
98
226
  of quotes is stripped — enough to judge the patterns above without choking on
@@ -108,3 +236,5 @@ runtime state the tool does not have.
108
236
  ## License
109
237
 
110
238
  MIT — the manifest declares it and [`LICENSE`](LICENSE) is the text.
239
+
240
+ Written with AI assistance.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { runHook } from "../src/hook.mjs";
3
+
4
+ let raw = "";
5
+ process.stdin.setEncoding("utf8");
6
+ process.stdin.on("error", () => process.exit(0));
7
+ process.stdin.on("data", (d) => { raw += d; });
8
+ process.stdin.on("end", () => {
9
+ const out = runHook(raw);
10
+ // No process.exit here: stdout is a pipe, writes to it are asynchronous, and
11
+ // exiting would truncate the deny decision. Nothing else holds the loop open,
12
+ // so the process ends with status 0 once the write drains.
13
+ if (out) process.stdout.write(out);
14
+ });
package/package.json CHANGED
@@ -1,23 +1,35 @@
1
1
  {
2
2
  "name": "workspace-guard",
3
- "version": "0.1.0",
4
- "description": "Guardrail for running a coding agent unattended: decides whether a shell command or file path would write outside a project root, before you let it run.",
3
+ "version": "0.2.0",
4
+ "description": "Guardrail for running a coding agent unattended: a PreToolUse hook that denies a command or file write leaving the project root, and the same check as a CLI. No prompts, no dependencies.",
5
5
  "keywords": [
6
6
  "agent",
7
7
  "coding-agent",
8
8
  "claude",
9
+ "hook",
10
+ "pretooluse",
9
11
  "sandbox",
10
12
  "confinement",
11
13
  "guardrails",
12
14
  "unattended"
13
15
  ],
16
+ "homepage": "https://github.com/plainroot/workspace-guard#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/plainroot/workspace-guard/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/plainroot/workspace-guard.git"
23
+ },
14
24
  "type": "module",
15
25
  "license": "MIT",
16
26
  "bin": {
17
- "guard": "bin/guard.mjs"
27
+ "guard": "bin/guard.mjs",
28
+ "guard-hook": "bin/guard-hook.mjs"
18
29
  },
19
30
  "exports": {
20
- ".": "./src/guard.mjs"
31
+ ".": "./src/guard.mjs",
32
+ "./hook": "./src/hook.mjs"
21
33
  },
22
34
  "files": [
23
35
  "bin",
package/src/hook.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { checkCommand, checkPath } from "./guard.mjs";
2
+
3
+ export const ROOT_ENV = "WORKSPACE_GUARD_ROOT";
4
+
5
+ const WRITE_TOOLS = {
6
+ Write: ["file_path"],
7
+ Edit: ["file_path"],
8
+ MultiEdit: ["file_path"],
9
+ NotebookEdit: ["notebook_path", "file_path"],
10
+ };
11
+
12
+ const open = (reason) => ({ allowed: true, reason });
13
+
14
+ // Total by construction: anything this cannot make sense of becomes an allow,
15
+ // because a hook that throws on an unexpected payload would block every tool
16
+ // call the agent makes until someone notices. The verdicts themselves still
17
+ // come from guard.mjs, so the hook and the CLI cannot drift apart.
18
+ export function decide(payload, { root, home } = {}) {
19
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
20
+ return open("payload is not an object");
21
+
22
+ const tool = payload.tool_name;
23
+ const input = payload.tool_input;
24
+ if (typeof tool !== "string" || !input || typeof input !== "object")
25
+ return open("payload carries no tool name or no tool input");
26
+
27
+ const opts = { root, home, cwd: typeof payload.cwd === "string" && payload.cwd ? payload.cwd : root };
28
+
29
+ const fields = WRITE_TOOLS[tool];
30
+ if (fields) {
31
+ const target = fields.map((f) => input[f]).find((v) => typeof v === "string" && v !== "");
32
+ return target ? checkPath(target, opts) : open(`${tool} carries no file path`);
33
+ }
34
+
35
+ if (tool === "Bash") {
36
+ const command = input.command;
37
+ if (typeof command !== "string" || !command.trim()) return open("Bash carries no command");
38
+ return checkCommand(command, opts);
39
+ }
40
+
41
+ return open(`${tool} does not write`);
42
+ }
43
+
44
+ // The reason is read back by the agent that was denied, so it says what was
45
+ // denied and nothing about how the hook is configured or removed: an agent
46
+ // told where the off switch is will reach for it (claude-code#93602).
47
+ export function denyPayload(reason, root) {
48
+ return {
49
+ hookSpecificOutput: {
50
+ hookEventName: "PreToolUse",
51
+ permissionDecision: "deny",
52
+ permissionDecisionReason: `Blocked by workspace-guard: ${reason}. The project root is ${root}.`,
53
+ },
54
+ };
55
+ }
56
+
57
+ export function runHook(raw, { env = process.env, cwd = process.cwd() } = {}) {
58
+ const root = env[ROOT_ENV] || cwd;
59
+ try {
60
+ const decision = decide(JSON.parse(raw), { root });
61
+ return decision.allowed ? "" : JSON.stringify(denyPayload(decision.reason, root));
62
+ } catch {
63
+ return "";
64
+ }
65
+ }