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 +140 -10
- package/bin/guard-hook.mjs +14 -0
- package/package.json +16 -4
- package/src/hook.mjs +65 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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.
|
|
94
|
-
a verdict and something else has to act on it.
|
|
95
|
-
|
|
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.
|
|
4
|
-
"description": "Guardrail for running a coding agent unattended:
|
|
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
|
+
}
|