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 +21 -0
- package/README.md +110 -0
- package/bin/guard.mjs +10 -0
- package/package.json +33 -0
- package/src/cli.mjs +86 -0
- package/src/guard.mjs +189 -0
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
|
+
}
|