workspace-guard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Uzay Altıner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # guard
2
+
3
+ Decide whether a shell command or a file path would write outside a project
4
+ root. It is the check you want in front of a coding agent you are not watching.
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.
9
+
10
+ ## Run it
11
+
12
+ Requires Node 18 or newer. There is nothing to install and nothing to build.
13
+
14
+ ```
15
+ node bin/guard.mjs check --root <dir> --command "<shell command>"
16
+ node bin/guard.mjs check --root <dir> --path <file>
17
+ node bin/guard.mjs --help
18
+ ```
19
+
20
+ Once linked (`npm link`) the same commands are available as `guard`.
21
+
22
+ Exit status is the verdict: `0` allowed, `1` denied, `2` the invocation itself
23
+ was wrong. **`2` is never a verdict** — a caller that treats non-zero as "denied"
24
+ will read its own typo as a threat, so branch on `0` and `1` explicitly.
25
+
26
+ ```
27
+ $ node bin/guard.mjs check --root . --command "npm run build"
28
+ allow: no write outside the root found
29
+
30
+ $ node bin/guard.mjs check --root . --command "git push upstream main"
31
+ deny: pushes to a remote other than origin: upstream
32
+
33
+ $ node bin/guard.mjs check --root . --path ../../etc/hosts --json
34
+ {"allowed":false,"reason":"escapes the root through \"..\": ../../etc/hosts -> /etc/hosts","root":".","subject":{"kind":"path","value":"../../etc/hosts"}}
35
+ ```
36
+
37
+ `--json` prints one line: `allowed`, `reason`, the `root` as given, and the
38
+ `subject` that was judged. `--cwd <dir>` sets where relative paths resolve from;
39
+ it defaults to `--root`.
40
+
41
+ It is also importable:
42
+
43
+ ```js
44
+ import { checkCommand, checkPath } from "./src/guard.mjs";
45
+
46
+ checkCommand("rm -rf build", { root: process.cwd() });
47
+ // { allowed: false, reason: "deletes recursively and without prompting: rm -rf build" }
48
+ ```
49
+
50
+ ## Verify it works
51
+
52
+ ```
53
+ npm test # or: node --test test/
54
+ ```
55
+
56
+ 18 tests, no network, no fixtures to install. The suite is table-driven: every
57
+ threat below has a case, and so does every ordinary command that must keep
58
+ working. A guard that denies everything is not a guard, so the allow table is
59
+ the half worth reading — an ordinary build, a write inside the root, a plain
60
+ `git push origin <branch>`, a recursive delete of `build/`, and a copy that
61
+ *reads* from outside the root while writing inside it are all allowed.
62
+
63
+ To satisfy yourself by hand, without trusting the suite:
64
+
65
+ ```
66
+ node bin/guard.mjs check --root . --path /etc/hosts # prints deny, exits 1
67
+ node bin/guard.mjs check --root . --path src/index.js # prints allow, exits 0
68
+ ```
69
+
70
+ ## What it denies
71
+
72
+ | Threat | Example |
73
+ | --- | --- |
74
+ | a path resolving outside the root | `/etc/hosts` |
75
+ | escape through `..` | `../../etc/passwd` |
76
+ | escape through a symlink leaving the root | `vendor/out.txt` where `vendor` → `/tmp` |
77
+ | a link planted inside the root pointing out of it | `ln -s /tmp vendor` |
78
+ | recursive forced deletion | `rm -rf build` |
79
+ | running as another user | `sudo …`, `doas …` |
80
+ | a download piped into a shell | `curl … \| sh` |
81
+ | a force push | `git push --force`, `git push -uf origin main` |
82
+ | a push to a remote that is not origin | `git push upstream main` |
83
+ | a redirect whose target leaves the root | `npm test > /tmp/out.log` |
84
+ | a write into the agent's own configuration | `cp hook.mjs ~/.claude/hooks/` |
85
+ | git driven at a repository outside the root | `git -C /elsewhere push` |
86
+
87
+ Destination-aware commands (`cp`, `mv`, `rsync`, `install`, `tee`, `touch`,
88
+ `mkdir`, `dd`, `sed -i`, …) are judged on where they *write*, not on every path
89
+ they mention, so reading a file from outside the root into the project is fine.
90
+
91
+ ## What it is not
92
+
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.
96
+
97
+ Not a shell parser. Commands are split on `;`, `|`, `&&` and `||` and one layer
98
+ of quotes is stripped — enough to judge the patterns above without choking on
99
+ ordinary quoting, and not enough to survive an adversary who is actively
100
+ obfuscating. It is built for a capable agent that may be careless, not for one
101
+ that is hostile.
102
+
103
+ Deliberately not covered: process substitution, `eval` of a constructed string,
104
+ `$(…)` whose expansion is a path, and environment-variable indirection other
105
+ than `$HOME`. These are all real, and each would need the verdict to depend on
106
+ runtime state the tool does not have.
107
+
108
+ ## License
109
+
110
+ MIT — the manifest declares it and [`LICENSE`](LICENSE) is the text.
package/bin/guard.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { run } from "../src/cli.mjs";
5
+
6
+ const { version } = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
7
+ const { code, out, err } = run(process.argv.slice(2), { version });
8
+ if (out) process.stdout.write(out);
9
+ if (err) process.stderr.write(err);
10
+ process.exit(code);
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
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.",
5
+ "keywords": [
6
+ "agent",
7
+ "coding-agent",
8
+ "claude",
9
+ "sandbox",
10
+ "confinement",
11
+ "guardrails",
12
+ "unattended"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "bin": {
17
+ "guard": "bin/guard.mjs"
18
+ },
19
+ "exports": {
20
+ ".": "./src/guard.mjs"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test test/"
32
+ }
33
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { checkCommand, checkPath, GuardUsageError } from "./guard.mjs";
2
+
3
+ export const USAGE = `guard — decide whether a shell command or a file path would write outside a project root.
4
+
5
+ Usage:
6
+ guard check --root <dir> --command "<shell command>"
7
+ guard check --root <dir> --path <file>
8
+
9
+ Options:
10
+ --root <dir> the project directory the work is confined to (required)
11
+ --command "<cmd>" a shell command to judge
12
+ --path <file> a file path to judge
13
+ --cwd <dir> directory relative paths are resolved from (default: --root)
14
+ --json print the decision as JSON on one line
15
+ -h, --help print this message
16
+ -v, --version print the version
17
+
18
+ Exit status:
19
+ 0 allowed
20
+ 1 denied
21
+ 2 the invocation itself was wrong (exit code 2 is never a verdict)
22
+
23
+ Examples:
24
+ guard check --root . --command "npm test"
25
+ guard check --root . --command "rm -rf /" ; echo $?
26
+ guard check --root . --path ../../etc/hosts --json
27
+ `;
28
+
29
+ const TAKES_VALUE = new Set(["--root", "--command", "--path", "--cwd"]);
30
+
31
+ export function parseArgs(argv) {
32
+ const opts = { json: false };
33
+ const positional = [];
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i];
36
+ if (arg === "-h" || arg === "--help") return { help: true };
37
+ if (arg === "-v" || arg === "--version") return { version: true };
38
+ if (arg === "--json") opts.json = true;
39
+ else if (TAKES_VALUE.has(arg)) {
40
+ if (i + 1 >= argv.length) throw new GuardUsageError(`${arg} needs a value`);
41
+ opts[arg.slice(2)] = argv[++i];
42
+ } else if (arg.startsWith("--") && arg.includes("=")) {
43
+ const [name, ...value] = arg.split("=");
44
+ if (!TAKES_VALUE.has(name)) throw new GuardUsageError(`unknown option: ${name}`);
45
+ opts[name.slice(2)] = value.join("=");
46
+ } else if (arg.startsWith("-")) throw new GuardUsageError(`unknown option: ${arg}`);
47
+ else positional.push(arg);
48
+ }
49
+ return { ...opts, positional };
50
+ }
51
+
52
+ export function run(argv, { version = "0.0.0" } = {}) {
53
+ let args;
54
+ try {
55
+ args = parseArgs(argv);
56
+ } catch (err) {
57
+ if (err instanceof GuardUsageError) return { code: 2, err: `guard: ${err.message}\n\n${USAGE}` };
58
+ throw err;
59
+ }
60
+ if (args.help) return { code: 0, out: USAGE };
61
+ if (args.version) return { code: 0, out: `${version}\n` };
62
+ if (argv.length === 0) return { code: 2, err: USAGE };
63
+
64
+ const [command] = args.positional;
65
+ if (command !== "check") return { code: 2, err: `guard: unknown command: ${command ?? "(none)"}\n\n${USAGE}` };
66
+
67
+ try {
68
+ if ((args.command === undefined) === (args.path === undefined))
69
+ throw new GuardUsageError("give exactly one of --command or --path");
70
+ const subject = args.command !== undefined
71
+ ? { kind: "command", value: args.command }
72
+ : { kind: "path", value: args.path };
73
+ const opts = { root: args.root, cwd: args.cwd };
74
+ const result = subject.kind === "command"
75
+ ? checkCommand(subject.value, opts)
76
+ : checkPath(subject.value, opts);
77
+
78
+ const out = args.json
79
+ ? JSON.stringify({ ...result, root: args.root, subject }) + "\n"
80
+ : `${result.allowed ? "allow" : "deny"}: ${result.reason}\n`;
81
+ return { code: result.allowed ? 0 : 1, out };
82
+ } catch (err) {
83
+ if (err instanceof GuardUsageError) return { code: 2, err: `guard: ${err.message}\n\n${USAGE}` };
84
+ return { code: 2, err: `guard: ${err.message}\n` };
85
+ }
86
+ }
package/src/guard.mjs ADDED
@@ -0,0 +1,189 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+
5
+ export class GuardUsageError extends Error {}
6
+
7
+ const allow = (reason) => ({ allowed: true, reason });
8
+ const deny = (reason) => ({ allowed: false, reason });
9
+
10
+ const SHELL_DENY = [
11
+ [/(^|[\s;&|(])sudo(\s|$)/, "runs as another user via sudo"],
12
+ [/(^|[\s;&|(])doas(\s|$)/, "runs as another user via doas"],
13
+ [/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh(\s|$)/, "pipes a download into a shell"],
14
+ ];
15
+
16
+ const DEST_LAST = new Set(["cp", "mv", "rsync", "install", "scp"]);
17
+ const DEST_ALL = new Set(["tee", "touch", "mkdir", "rmdir", "truncate", "chmod", "chown"]);
18
+
19
+ function longestExistingReal(p) {
20
+ let cur = p;
21
+ while (!existsSync(cur)) {
22
+ const up = dirname(cur);
23
+ if (up === cur) return cur;
24
+ cur = up;
25
+ }
26
+ try {
27
+ return realpathSync(cur) + p.slice(cur.length);
28
+ } catch {
29
+ return p;
30
+ }
31
+ }
32
+
33
+ const inside = (p, root) => p === root || p.startsWith(root + "/");
34
+
35
+ function expandHome(raw, home) {
36
+ if (raw === "~" || raw.startsWith("~/")) return home + raw.slice(1);
37
+ const m = /^\$\{?HOME\}?(?=\/|$)/.exec(raw);
38
+ return m ? home + raw.slice(m[0].length) : raw;
39
+ }
40
+
41
+ export function resolveRoots({ root, cwd, home } = {}) {
42
+ if (!root) throw new GuardUsageError("a root directory is required");
43
+ const home_ = home ?? homedir();
44
+ const lexicalRoot = resolve(root);
45
+ const realRoot = longestExistingReal(lexicalRoot);
46
+ const base = cwd ? resolve(cwd) : lexicalRoot;
47
+ return { lexicalRoot, realRoot, base, home: home_, claude: resolve(home_, ".claude") };
48
+ }
49
+
50
+ // Two resolutions of the same path: the lexical one collapses `..` without
51
+ // touching the disk, the real one follows symlinks on the longest existing
52
+ // prefix. A path that is lexically inside the root but really outside it
53
+ // escaped through a link, and saying so is the difference between a reason a
54
+ // person can act on and "denied".
55
+ function locate(raw, roots) {
56
+ const expanded = expandHome(String(raw), roots.home);
57
+ const lexical = isAbsolute(expanded) ? resolve(expanded) : resolve(roots.base, expanded);
58
+ return { expanded, lexical, real: longestExistingReal(lexical) };
59
+ }
60
+
61
+ function verdict(raw, roots) {
62
+ const { expanded, lexical, real } = locate(raw, roots);
63
+
64
+ if (inside(lexical, roots.claude) || inside(real, roots.claude))
65
+ return `writes into the agent's own configuration: ${raw}`;
66
+
67
+ if (!inside(lexical, roots.lexicalRoot)) {
68
+ if (expanded.split("/").includes("..")) return `escapes the root through "..": ${raw} -> ${lexical}`;
69
+ return `resolves outside the root: ${raw} -> ${lexical}`;
70
+ }
71
+ if (!inside(real, roots.realRoot)) return `escapes the root through a symlink: ${raw} -> ${real}`;
72
+ return null;
73
+ }
74
+
75
+ // Not a shell parser. It splits on the separators that start a new command and
76
+ // strips one layer of quotes, which is enough to judge the deny patterns
77
+ // without choking on ordinary quoting.
78
+ const splitSegments = (cmd) => cmd.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean);
79
+
80
+ const tokenize = (segment) =>
81
+ (segment.match(/(?:"[^"]*"|'[^']*'|\S+)/g) ?? []).map((t) => t.replace(/^["']|["']$/g, ""));
82
+
83
+ const isFlag = (t) => t.startsWith("-");
84
+ const hasShortFlag = (tok, letter) => /^-[a-zA-Z]+$/.test(tok) && tok.slice(1).includes(letter);
85
+ const isRecursive = (t) => t === "--recursive" || hasShortFlag(t, "r") || hasShortFlag(t, "R");
86
+ const isForce = (t) => t.startsWith("--force") || hasShortFlag(t, "f");
87
+ const looksLikePath = (t) => !isFlag(t) && (t.includes("/") || t.includes("~") || t.includes("."));
88
+
89
+ function checkRm(tokens, roots) {
90
+ const flags = tokens.filter(isFlag);
91
+ const targets = tokens.filter((t) => !isFlag(t));
92
+ for (const t of targets) {
93
+ const bad = verdict(t, roots);
94
+ if (bad) return bad;
95
+ }
96
+ if (flags.some(isRecursive) && flags.some(isForce))
97
+ return `deletes recursively and without prompting: rm ${tokens.join(" ")}`;
98
+ return null;
99
+ }
100
+
101
+ // `ln -s <outside> <inside>` is judged on its source, not its destination: it
102
+ // plants a link inside the root whose target is not, which is how the next
103
+ // command walks out.
104
+ function checkLn(tokens, roots) {
105
+ const positional = tokens.filter((t) => !isFlag(t));
106
+ for (const t of positional) {
107
+ const bad = verdict(t, roots);
108
+ if (bad) return bad;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function checkGit(tokens, roots) {
114
+ const cIdx = tokens.indexOf("-C");
115
+ if (cIdx !== -1 && tokens[cIdx + 1]) {
116
+ const { lexical } = locate(tokens[cIdx + 1], roots);
117
+ if (!inside(lexical, roots.lexicalRoot))
118
+ return `operates on a repository outside the root: ${tokens[cIdx + 1]}`;
119
+ }
120
+ const pushIdx = tokens.indexOf("push");
121
+ if (pushIdx === -1) return null;
122
+ const args = tokens.slice(pushIdx + 1);
123
+ if (args.some(isForce)) return `force-pushes, which discards commits on the remote: ${tokens.join(" ")}`;
124
+ const positional = args.filter((t) => !isFlag(t));
125
+ if (positional.length && positional[0] !== "origin")
126
+ return `pushes to a remote other than origin: ${positional[0]}`;
127
+ return null;
128
+ }
129
+
130
+ function checkDestinations(head, tokens, roots) {
131
+ const positional = tokens.filter((t) => !isFlag(t));
132
+ if (DEST_LAST.has(head)) {
133
+ const dest = positional.at(-1);
134
+ return dest && looksLikePath(dest) ? verdict(dest, roots) : null;
135
+ }
136
+ for (const t of positional) {
137
+ if (looksLikePath(t)) {
138
+ const bad = verdict(t, roots);
139
+ if (bad) return bad;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function checkRedirects(cmd, roots) {
146
+ for (const m of cmd.matchAll(/>{1,2}\s*("([^"]+)"|'([^']+)'|(\S+))/g)) {
147
+ const target = m[2] ?? m[3] ?? m[4];
148
+ if (target.startsWith("/dev/") || target.startsWith("&")) continue;
149
+ const bad = verdict(target, roots);
150
+ if (bad) return `redirect target ${bad}`;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export function checkCommand(command, options) {
156
+ const roots = resolveRoots(options);
157
+ const cmd = String(command ?? "");
158
+ if (!cmd.trim()) throw new GuardUsageError("a command is required");
159
+
160
+ for (const [re, why] of SHELL_DENY) if (re.test(cmd)) return deny(why);
161
+
162
+ for (const segment of splitSegments(cmd)) {
163
+ const tokens = tokenize(segment);
164
+ if (!tokens.length) continue;
165
+ const head = tokens.at(0);
166
+ const rest = tokens.slice(1);
167
+
168
+ let bad = null;
169
+ if (head === "git") bad = checkGit(rest, roots);
170
+ else if (head === "rm") bad = checkRm(rest, roots);
171
+ else if (head === "ln") bad = checkLn(rest, roots);
172
+ else if (head === "dd") bad = checkDestinations("dd", rest.filter((t) => t.startsWith("of=")).map((t) => t.slice(3)), roots);
173
+ else if (head === "sed" && rest.includes("-i")) bad = checkDestinations("sed", rest, roots);
174
+ else if (DEST_LAST.has(head) || DEST_ALL.has(head)) bad = checkDestinations(head, rest, roots);
175
+ if (bad) return deny(bad);
176
+ }
177
+
178
+ const redirect = checkRedirects(cmd, roots);
179
+ if (redirect) return deny(redirect);
180
+
181
+ return allow("no write outside the root found");
182
+ }
183
+
184
+ export function checkPath(path, options) {
185
+ const roots = resolveRoots(options);
186
+ if (path === undefined || path === null || String(path) === "") throw new GuardUsageError("a path is required");
187
+ const bad = verdict(path, roots);
188
+ return bad ? deny(bad) : allow(`stays inside the root: ${locate(path, roots).real}`);
189
+ }