@loophubs/agent-guard 0.2.0 → 0.4.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/package.json +1 -1
- package/src/argv.ts +69 -22
- package/src/core.ts +7 -5
- package/src/docker-targets.ts +87 -0
- package/src/frontend.ts +54 -18
- package/src/git-targets.ts +125 -0
- package/src/guard.ts +4 -1
- package/src/interpreters.ts +61 -0
- package/src/links.ts +66 -59
- package/src/paths.ts +85 -66
- package/src/pipeline.ts +120 -14
- package/src/probes.ts +5 -0
- package/src/programs.ts +341 -0
- package/src/reasons.ts +5 -1
- package/src/record.ts +33 -2
- package/src/rules/appdata.ts +29 -59
- package/src/rules/credentials.ts +38 -219
- package/src/rules/secrets.ts +143 -0
- package/src/search-roles.ts +5 -0
- package/src/search-targets.ts +33 -0
- package/src/ssh-targets.ts +46 -0
- package/src/ssh.ts +65 -0
- package/src/targets.ts +151 -0
- package/src/words.ts +16 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loophubs/agent-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Blocks filesystem scans and credential reads that macOS records as TCC App Data access, and points the agent at a safe alternative.",
|
|
5
5
|
"homepage": "https://github.com/LoopHubs/agent-guard#readme",
|
|
6
6
|
"bugs": {
|
package/src/argv.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import { basename, resolve } from "node:path";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
3
|
+
import { interpreterCode, interpreterName } from "./interpreters";
|
|
4
|
+
import { absPath, expandHome } from "./paths";
|
|
5
|
+
import { findRoots } from "./programs";
|
|
6
|
+
import type { Command, Items, Word } from "./record";
|
|
7
|
+
import { searchRoles, showsHidden } from "./search-roles";
|
|
6
8
|
|
|
7
|
-
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
8
|
-
const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
|
|
9
|
+
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh"]);
|
|
9
10
|
const zshBuiltins = new Set(["echo", "printf", "print", "export", "typeset", "declare", "set", "command", "eval", "source", "."]);
|
|
10
11
|
const shellCodeFlag = /^-[a-z]*c[a-z]*$/;
|
|
12
|
+
const sudoValueOption = /^(-[A-Za-z]*[ughpCDRTrtU]|--(user|group|host|prompt|chdir|chroot|role|type|other-user|close-from|command-timeout))$/;
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
// egrep and fgrep are grep with a fixed matcher.
|
|
15
|
+
export function programName(text: string): string {
|
|
16
|
+
const name = basename(text);
|
|
17
|
+
return name === "egrep" || name === "fgrep" ? "grep" : name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Child {
|
|
21
|
+
source: string;
|
|
22
|
+
items?: Items;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveCommand(cmd: Command, home: string): { children: Child[]; code: string[] } {
|
|
13
26
|
const w = cmd.argv;
|
|
14
|
-
const children:
|
|
27
|
+
const children: Child[] = [];
|
|
15
28
|
const code: string[] = [];
|
|
16
29
|
let i = 0;
|
|
17
30
|
let shell = true;
|
|
@@ -58,6 +71,31 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
58
71
|
if (w[i]?.text === "-n" || w[i]?.text === "--adjustment") i += 2;
|
|
59
72
|
else if (/^(-n|--adjustment=|-\d)/.test(w[i]?.text ?? "")) i++;
|
|
60
73
|
break;
|
|
74
|
+
case "sudo":
|
|
75
|
+
case "doas":
|
|
76
|
+
w[i++]!.role = "precommand";
|
|
77
|
+
while (i < w.length && w[i]!.text !== "--" && (w[i]!.text.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]!.text))) {
|
|
78
|
+
if (sudoValueOption.test(w[i]!.text)) w[++i]!.role = "precommand";
|
|
79
|
+
w[i++]!.role = "precommand";
|
|
80
|
+
}
|
|
81
|
+
if (w[i]?.text === "--") w[i++]!.role = "precommand";
|
|
82
|
+
break;
|
|
83
|
+
case "script":
|
|
84
|
+
// script [-adkpqr] [-F pipe] [-t time] [file [command ...]]
|
|
85
|
+
w[i++]!.role = "precommand";
|
|
86
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
87
|
+
if (["-F", "-t"].includes(w[i]!.text)) i++;
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
if (w[i]) w[i++]!.role = "precommand";
|
|
91
|
+
break;
|
|
92
|
+
case "arch":
|
|
93
|
+
w[i++]!.role = "precommand";
|
|
94
|
+
while (w[i]?.text?.startsWith("-")) {
|
|
95
|
+
if (["-e", "-d", "-arch"].includes(w[i]!.text)) i++;
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
61
99
|
case "stdbuf":
|
|
62
100
|
w[i++]!.role = "precommand";
|
|
63
101
|
while (/^-[ioe]/.test(w[i]?.text ?? "")) {
|
|
@@ -99,7 +137,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
99
137
|
} else if (["-u", "-P"].includes(arg)) i += 2;
|
|
100
138
|
else if (arg === "-S") {
|
|
101
139
|
// The split string is a command line of its own.
|
|
102
|
-
children.push(w[i + 1]?.text ?? "");
|
|
140
|
+
children.push({ source: w[i + 1]?.text ?? "" });
|
|
103
141
|
cmd.wrappers.push("env-S");
|
|
104
142
|
i = w.length;
|
|
105
143
|
} else if (arg.startsWith("-") || arg.includes("=")) i++;
|
|
@@ -130,22 +168,32 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
130
168
|
cmd.program = i;
|
|
131
169
|
w[i]!.role = "program";
|
|
132
170
|
if (w[i]!.text.startsWith("=") && w[i]!.text.length > 1) w[i]!.text = w[i]!.text.slice(1);
|
|
133
|
-
const name =
|
|
171
|
+
const name = programName(w[i]!.text);
|
|
134
172
|
const rest = w.slice(i + 1);
|
|
135
173
|
if (["rg", "grep", "ag", "ack"].includes(name)) {
|
|
136
174
|
searchRoles(cmd, rest, name);
|
|
175
|
+
} else if (name === "find") {
|
|
176
|
+
for (const [n, word] of rest.entries()) {
|
|
177
|
+
if (!["-exec", "-execdir", "-ok", "-okdir"].includes(word.text)) continue;
|
|
178
|
+
const end = rest.findIndex((a, k) => k > n && (a.text === ";" || a.text === "+"));
|
|
179
|
+
const clause = rest.slice(n + 1, end < 0 ? undefined : end);
|
|
180
|
+
// find hands the command every name it walks, dotfiles included.
|
|
181
|
+
children.push({ source: clause.map((a) => a.raw).join(" "), items: { root: absPath(findRoots(rest)[0]?.text ?? ".", cmd.cwd, home), hidden: true } });
|
|
182
|
+
}
|
|
137
183
|
} else if (name === "fd") {
|
|
138
184
|
let pattern = true;
|
|
185
|
+
const roots: string[] = [];
|
|
139
186
|
for (let n = 0; n < rest.length; n++) {
|
|
140
187
|
const arg = rest[n]!.text;
|
|
141
188
|
if (["-x", "-X", "--exec", "--exec-batch"].includes(arg)) {
|
|
142
189
|
rest[n]!.role = "option";
|
|
143
|
-
children.push(
|
|
144
|
-
rest
|
|
190
|
+
children.push({
|
|
191
|
+
source: rest
|
|
145
192
|
.slice(n + 1)
|
|
146
193
|
.map((word) => word.raw)
|
|
147
194
|
.join(" "),
|
|
148
|
-
|
|
195
|
+
items: { root: absPath(roots[0] ?? ".", cmd.cwd, home), hidden: showsHidden(rest) },
|
|
196
|
+
});
|
|
149
197
|
break;
|
|
150
198
|
} else if ((/^(--search-path|--base-directory)(=|$)/.test(arg) || arg === "-C") && (arg.includes("=") || rest[n + 1])) {
|
|
151
199
|
const separate = !arg.includes("=");
|
|
@@ -165,7 +213,10 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
165
213
|
else if (pattern) {
|
|
166
214
|
rest[n]!.role = "pattern";
|
|
167
215
|
pattern = false;
|
|
168
|
-
} else
|
|
216
|
+
} else {
|
|
217
|
+
rest[n]!.role = "path";
|
|
218
|
+
roots.push(arg);
|
|
219
|
+
}
|
|
169
220
|
}
|
|
170
221
|
} else if (name === "du") {
|
|
171
222
|
for (let n = 0; n < rest.length; n++) {
|
|
@@ -179,16 +230,12 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
179
230
|
const flag = rest.findIndex((a) => shellCodeFlag.test(a.text));
|
|
180
231
|
if (flag >= 0) {
|
|
181
232
|
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
182
|
-
children.push(rest[flag + 1]?.text ?? "");
|
|
233
|
+
children.push({ source: rest[flag + 1]?.text ?? "" });
|
|
183
234
|
}
|
|
184
235
|
} else if (name === "eval" && (shell || cmd.wrappers.includes("command"))) {
|
|
185
|
-
children.push(rest.map((a) => a.text).join(" "));
|
|
186
|
-
} else if (
|
|
187
|
-
|
|
188
|
-
if (flag >= 0) {
|
|
189
|
-
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
190
|
-
code.push(rest[flag + 1]?.text ?? "");
|
|
191
|
-
}
|
|
236
|
+
children.push({ source: rest.map((a) => a.text).join(" ") });
|
|
237
|
+
} else if (interpreterName(name)) {
|
|
238
|
+
code.push(...interpreterCode(interpreterName(name)!, rest));
|
|
192
239
|
}
|
|
193
240
|
return { children, code };
|
|
194
241
|
}
|
|
@@ -199,6 +246,6 @@ export function stdinKind(cmd: Command): "shell" | "code" | undefined {
|
|
|
199
246
|
const name = basename(program.text);
|
|
200
247
|
const args = cmd.argv.slice(cmd.program + 1);
|
|
201
248
|
if (shellPrograms.has(name) && !args.some((a) => shellCodeFlag.test(a.text))) return "shell";
|
|
202
|
-
if (
|
|
249
|
+
if (interpreterName(name)) return "code";
|
|
203
250
|
return undefined;
|
|
204
251
|
}
|
package/src/core.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { parseScript } from "./frontend";
|
|
2
|
-
import {
|
|
2
|
+
import { linkedTargets } from "./links";
|
|
3
3
|
import { absPath } from "./paths";
|
|
4
4
|
import { reasons } from "./reasons";
|
|
5
5
|
import type { Request, Runtime, Tool } from "./record";
|
|
6
6
|
import { appdataRules } from "./rules/appdata";
|
|
7
7
|
import { credentialFilesystemRules, credentialRules } from "./rules/credentials";
|
|
8
8
|
import { claudeWorkflowRules } from "./rules/workflow";
|
|
9
|
+
import { extractTargets } from "./targets";
|
|
9
10
|
|
|
10
11
|
export function buildRequest(runtime: Runtime, tool: Tool, cwd: string, input: string, glob: string, home: string): Request {
|
|
11
12
|
const inputCwd = cwd || "/";
|
|
@@ -38,17 +39,18 @@ export function buildRequest(runtime: Runtime, tool: Tool, cwd: string, input: s
|
|
|
38
39
|
|
|
39
40
|
export function evaluate(req: Request): string | undefined {
|
|
40
41
|
if (req.parseFailed) return reasons.syntax;
|
|
41
|
-
const
|
|
42
|
+
const targets = extractTargets(req);
|
|
43
|
+
const denials = [...appdataRules(req, targets), ...credentialRules(req, targets)];
|
|
42
44
|
// Deny lexically protected paths before asking the filesystem about links.
|
|
43
45
|
if (!denials.length) {
|
|
44
46
|
try {
|
|
45
|
-
const linked =
|
|
46
|
-
if (linked) denials.push(...appdataRules(linked), ...credentialRules(linked));
|
|
47
|
+
const linked = linkedTargets(targets, req.home);
|
|
48
|
+
if (linked) denials.push(...appdataRules(req, linked), ...credentialRules(req, linked));
|
|
49
|
+
if (!denials.length) denials.push(...credentialFilesystemRules(req, linked ?? targets));
|
|
47
50
|
} catch {
|
|
48
51
|
denials.push(reasons.symlink);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
|
-
if (!denials.length) denials.push(...credentialFilesystemRules(req));
|
|
52
54
|
return denials[0];
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Context } from "./programs";
|
|
2
|
+
import type { Target, Word } from "./record";
|
|
3
|
+
|
|
4
|
+
const globalValueOptions = ["-H", "--host", "-c", "--context", "-l", "--log-level", "--config", "--tlscacert", "--tlscert", "--tlskey"];
|
|
5
|
+
|
|
6
|
+
// The options of `docker run`, `create` and `exec` that take no value; any other option takes the next word unless its value is glued.
|
|
7
|
+
const flagOptions = [
|
|
8
|
+
"--detach",
|
|
9
|
+
"--help",
|
|
10
|
+
"--init",
|
|
11
|
+
"--interactive",
|
|
12
|
+
"--no-healthcheck",
|
|
13
|
+
"--oom-kill-disable",
|
|
14
|
+
"--privileged",
|
|
15
|
+
"--publish-all",
|
|
16
|
+
"--quiet",
|
|
17
|
+
"--read-only",
|
|
18
|
+
"--rm",
|
|
19
|
+
"--sig-proxy",
|
|
20
|
+
"--tty",
|
|
21
|
+
"--use-api-socket",
|
|
22
|
+
];
|
|
23
|
+
const flagLetters = "diqPt";
|
|
24
|
+
const composeValueOptions = ["-f", "--file", "-p", "--project-name", "--project-directory", "--profile", "--env-file", "--ansi", "--parallel", "--progress"];
|
|
25
|
+
|
|
26
|
+
// Where the image (`run`, `create`) or the container (`exec`) is: every word after it belongs to the command run inside the container, not to docker.
|
|
27
|
+
function commandStart(words: Word[], start: number): number {
|
|
28
|
+
for (let i = start + 1; i < words.length; i++) {
|
|
29
|
+
const text = words[i]!.text;
|
|
30
|
+
if (!text.startsWith("-")) return i;
|
|
31
|
+
if (text.startsWith("--")) {
|
|
32
|
+
if (!text.includes("=") && !flagOptions.includes(text) && !/^-\D/.test(words[i + 1]?.text ?? "")) i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const value = [...text.slice(1)].findIndex((letter) => !flagLetters.includes(letter));
|
|
36
|
+
if (value >= 0 && value === text.length - 2) i++;
|
|
37
|
+
}
|
|
38
|
+
return words.length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A bind mount, a copy out of the host, a build secret or an image archive hands a host file to docker; the image, container names and the command run inside it are names.
|
|
42
|
+
export function dockerTargets({ words, make, claimed }: Context): Target[] {
|
|
43
|
+
const targets: Target[] = [];
|
|
44
|
+
const mount = (path: string, word: Word, effect: "read" | "write" | "use" = "read", via: "option" | "operand" = "option") => {
|
|
45
|
+
claimed.add(word);
|
|
46
|
+
targets.push(make(path, word, effect, { via }));
|
|
47
|
+
};
|
|
48
|
+
// The subcommand follows the global options and their values, and a management word such as `container` in `docker container cp`.
|
|
49
|
+
let start = 0;
|
|
50
|
+
while (words[start]?.text.startsWith("-")) start += globalValueOptions.includes(words[start]!.text) ? 2 : 1;
|
|
51
|
+
if (["container", "image", "buildx"].includes(words[start]?.text ?? "")) start++;
|
|
52
|
+
const sub = words[start]?.text;
|
|
53
|
+
const end = ["run", "create", "exec"].includes(sub ?? "") ? commandStart(words, start) : words.length;
|
|
54
|
+
// After a compose subcommand, `-f` is that subcommand's option (`logs -f` follows), not the compose file.
|
|
55
|
+
let composeSub = start + 1;
|
|
56
|
+
while (words[composeSub]?.text.startsWith("-")) composeSub += composeValueOptions.includes(words[composeSub]!.text) ? 2 : 1;
|
|
57
|
+
for (const [i, word] of words.slice(0, end).entries()) {
|
|
58
|
+
// cp names a container as `name:path`; any other operand is a host path, and the last one is where the copy lands.
|
|
59
|
+
if (sub === "cp" && i > start && !word.text.startsWith("-") && !/^[\w.-]+:/.test(word.text)) mount(word.text, word, i === words.length - 1 ? "write" : "read", "operand");
|
|
60
|
+
// A short option's value is glued (`-i.env`, `-vSPEC`, `-v=SPEC`) or the next word; pflag reads the rest of a cluster after the option that takes it.
|
|
61
|
+
const previous = words[i - 1]?.text;
|
|
62
|
+
if (sub === "load") {
|
|
63
|
+
const input = /^(?:--input=|-i)(.+)$/s.exec(word.text)?.[1] ?? (previous === "-i" || previous === "--input" ? word.text : undefined);
|
|
64
|
+
if (input) mount(input, word);
|
|
65
|
+
}
|
|
66
|
+
const secret = word.text.startsWith("--secret=") ? word.text.slice("--secret=".length) : previous === "--secret" ? word.text : undefined;
|
|
67
|
+
for (const field of secret?.split(",") ?? []) if (/^(src|source)=/.test(field)) mount(field.slice(field.indexOf("=") + 1), word);
|
|
68
|
+
// The client reads or writes these files itself; `-f` names one only for build and compose.
|
|
69
|
+
const hostFile =
|
|
70
|
+
/^--(file|label-file|cidfile|iidfile|tlscacert|tlscert|tlskey|config)(?:=(.*))?$/s.exec(word.text) ??
|
|
71
|
+
(sub === "build" || (sub === "compose" && i < composeSub) ? /^-[A-Za-z]*?(f)=?(.*)$/s.exec(word.text) : null);
|
|
72
|
+
if (hostFile) {
|
|
73
|
+
const holder = hostFile[2] ? word : words[i + 1];
|
|
74
|
+
if (holder) mount(hostFile[2] || holder.text, holder, ["cidfile", "iidfile"].includes(hostFile[1]!) ? "write" : "use");
|
|
75
|
+
}
|
|
76
|
+
const long = /^--(volume|mount)(?:=(.*))?$/s.exec(word.text);
|
|
77
|
+
const short = /^-[A-Za-z]*?v(=?)(.*)$/s.exec(word.text);
|
|
78
|
+
const key = long?.[1] ?? (short ? "volume" : undefined);
|
|
79
|
+
const inline = long ? long[2] : short?.[2];
|
|
80
|
+
const value = inline ? word : words[i + 1];
|
|
81
|
+
if (!key || !value) continue;
|
|
82
|
+
const spec = inline || value.text;
|
|
83
|
+
if (key === "volume") mount(spec.split(":")[0]!, value);
|
|
84
|
+
else for (const field of spec.split(",")) if (/^(src|source)=/.test(field)) mount(field.slice(field.indexOf("=") + 1), value);
|
|
85
|
+
}
|
|
86
|
+
return targets;
|
|
87
|
+
}
|
package/src/frontend.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
|
-
import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
|
|
8
|
+
import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, FuncDecl, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
|
|
9
9
|
import sh from "mvdan-sh";
|
|
10
10
|
|
|
11
11
|
import { resolveCommand, stdinKind } from "./argv";
|
|
@@ -13,7 +13,7 @@ import { misreadComment } from "./comments";
|
|
|
13
13
|
import { boundedDirectories } from "./cwd";
|
|
14
14
|
import { movedOnSuccess } from "./moves";
|
|
15
15
|
import { expandHome } from "./paths";
|
|
16
|
-
import { xargsReplacements } from "./pipeline";
|
|
16
|
+
import { markWalkedInput, shellInput, xargsHereInput, xargsReplacements } from "./pipeline";
|
|
17
17
|
import type { Command, Redirect, Script, Word } from "./record";
|
|
18
18
|
import { readWord } from "./words";
|
|
19
19
|
|
|
@@ -40,6 +40,10 @@ interface Scope {
|
|
|
40
40
|
|
|
41
41
|
const type = (node: Node) => syntax.NodeType(node);
|
|
42
42
|
|
|
43
|
+
// A function call runs the body in the caller's shell; bound the work a chain of
|
|
44
|
+
// calls can multiply.
|
|
45
|
+
const maxFunctionRuns = 256;
|
|
46
|
+
|
|
43
47
|
export function parseScript(source: string, cwd: string, home: string): Script {
|
|
44
48
|
const script: Script = { commands: [], uninspectable: [], parseFailed: false };
|
|
45
49
|
|
|
@@ -60,6 +64,10 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
60
64
|
for (const stmt of file.Stmts) statement(stmt!, scope);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
const functions = new Map<string, { body: Stmt; slice: Scope["slice"] }>();
|
|
68
|
+
const running = new Set<string>();
|
|
69
|
+
let functionRuns = 0;
|
|
70
|
+
|
|
63
71
|
const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
|
|
64
72
|
// A cd inside runs in a separate shell and leaves this scope's cwd alone.
|
|
65
73
|
const isolated = (scope: Scope): Scope => ({ ...scope, dir: { ...scope.dir }, vars: new Map(scope.vars) });
|
|
@@ -97,16 +105,26 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
97
105
|
scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
|
|
98
106
|
return;
|
|
99
107
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
// zsh runs the last element of a pipeline in this shell and bash forks it,
|
|
109
|
+
// so a cd there may or may not move the commands after it.
|
|
110
|
+
const right = op === "||" ? failed : op === "&&" ? scope : isolated(scope);
|
|
111
|
+
statement(cmd.Y!, right);
|
|
112
|
+
if (op === "|" || op === "|&") {
|
|
113
|
+
const left = script.commands.slice(start, middle);
|
|
114
|
+
const rest = script.commands.slice(middle);
|
|
115
|
+
markWalkedInput(left, rest);
|
|
116
|
+
for (const item of [...xargsReplacements(left, rest), ...shellInput(left, rest)]) parse(item.source, item.cwd);
|
|
104
117
|
}
|
|
118
|
+
if (op !== "&&") scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), right.dir.cwd, ...(right.dir.alternatives ?? [])], home);
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
// Compound words may hold substitutions; branch bodies may not run.
|
|
108
122
|
function children(node: Node, scope: Scope) {
|
|
109
|
-
if (type(node) === "FuncDecl")
|
|
123
|
+
if (type(node) === "FuncDecl") {
|
|
124
|
+
const func = node as FuncDecl;
|
|
125
|
+
if (func.Name && func.Body) functions.set(func.Name.Value, { body: func.Body, slice: scope.slice });
|
|
126
|
+
scope = isolated(scope);
|
|
127
|
+
}
|
|
110
128
|
if (type(node) === "IfClause") return conditional(node as IfClause, scope);
|
|
111
129
|
const outer = scope;
|
|
112
130
|
const loop = type(node) === "WhileClause" || type(node) === "ForClause";
|
|
@@ -157,7 +175,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
157
175
|
}
|
|
158
176
|
|
|
159
177
|
function word(node: WordNode, scope: Scope): Word {
|
|
160
|
-
return readWord(node, scope.slice, scope.vars, home, (part, names) => expansions(part, scope, names));
|
|
178
|
+
return readWord(node, scope.slice, scope.vars, home, scope.dir.cwd, (part, names) => expansions(part, scope, names));
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
function assign(node: Assign, scope: Scope, role: "assign" | "arg"): Word {
|
|
@@ -171,7 +189,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
171
189
|
return type(child) !== "Word";
|
|
172
190
|
});
|
|
173
191
|
return {
|
|
174
|
-
...(value ?? { expands: false, globs: false, vars: [] }),
|
|
192
|
+
...(value ?? { expands: false, globs: false, vars: [], pwd: false }),
|
|
175
193
|
text: `${name}=${value?.text ?? ""}`,
|
|
176
194
|
raw: text(node, scope),
|
|
177
195
|
role,
|
|
@@ -180,7 +198,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
function literal(value: string): Word {
|
|
183
|
-
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
|
|
201
|
+
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value, pwd: false };
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
function simple(cmd: Node | null, redirs: (RedirectNode | null)[], scope: Scope) {
|
|
@@ -208,14 +226,32 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
208
226
|
};
|
|
209
227
|
script.commands.push(command);
|
|
210
228
|
const { children: sources, code } = resolveCommand(command, home);
|
|
211
|
-
|
|
212
|
-
for (const
|
|
213
|
-
|
|
229
|
+
const moved = (cwd: string) => command.argv.map((w) => (w.pwd ? { ...w, text: w.text.replaceAll(scope.dir.cwd, cwd), value: w.value.replaceAll(scope.dir.cwd, cwd) } : w));
|
|
230
|
+
for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd, argv: moved(cwd) });
|
|
231
|
+
for (const { source, items } of sources) {
|
|
232
|
+
for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) {
|
|
233
|
+
const start = script.commands.length;
|
|
234
|
+
parse(source, cwd);
|
|
235
|
+
if (items) for (const child of script.commands.slice(start)) child.items ??= items;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const text of code) script.uninspectable.push({ text, cwd: command.cwd });
|
|
214
239
|
for (const r of redirects) {
|
|
215
240
|
if (r.direction !== "heredoc" && r.direction !== "herestring") continue;
|
|
216
241
|
const stdin = stdinKind(command);
|
|
217
242
|
if (stdin === "shell") parse(r.target, command.cwd);
|
|
218
|
-
if (stdin === "code") script.uninspectable.push(r.target);
|
|
243
|
+
if (stdin === "code") script.uninspectable.push({ text: r.target, cwd: command.cwd });
|
|
244
|
+
if (command.wrappers.includes("xargs")) for (const item of xargsHereInput(command, r.target)) parse(item.source, item.cwd);
|
|
245
|
+
}
|
|
246
|
+
const called = command.wrappers.every((w) => w === "time") ? command.argv[command.program]?.text : undefined;
|
|
247
|
+
const func = called === undefined ? undefined : functions.get(called);
|
|
248
|
+
if (func && !running.has(called!)) {
|
|
249
|
+
if (++functionRuns > maxFunctionRuns) script.parseFailed = true;
|
|
250
|
+
else {
|
|
251
|
+
running.add(called!);
|
|
252
|
+
statement(func.body, { ...scope, slice: func.slice });
|
|
253
|
+
running.delete(called!);
|
|
254
|
+
}
|
|
219
255
|
}
|
|
220
256
|
track(command, scope);
|
|
221
257
|
}
|
|
@@ -226,13 +262,13 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
226
262
|
const quoted = node.Word!.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
|
|
227
263
|
const vars: string[] = [];
|
|
228
264
|
if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
|
|
229
|
-
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
|
|
265
|
+
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", globs: false, vars };
|
|
230
266
|
}
|
|
231
267
|
const target = word(node.Word!, scope);
|
|
232
|
-
if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
|
|
268
|
+
if (op === "<<<") return { direction: "herestring", target: target.text, globs: false, vars: target.vars };
|
|
233
269
|
if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
|
|
234
270
|
const direction = op === "<" || op === "<>" ? "in" : "out";
|
|
235
|
-
return { direction, target: target.text, vars: [] };
|
|
271
|
+
return { direction, target: target.text, globs: target.globs, vars: [] };
|
|
236
272
|
}
|
|
237
273
|
|
|
238
274
|
function track(command: Command, scope: Scope) {
|
|
@@ -245,7 +281,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
245
281
|
if (!program || !command.shell || !["cd", "pushd"].includes(program.text)) return;
|
|
246
282
|
const args = command.argv.slice(command.program + 1);
|
|
247
283
|
let target = args.find((w) => !w.text.startsWith("-"))?.text;
|
|
248
|
-
if (target === undefined &&
|
|
284
|
+
if (target === undefined && args.every((w) => /^(--|-[PLqs]+)$/.test(w.text)) && program.text === "cd") target = home;
|
|
249
285
|
if (target !== undefined) {
|
|
250
286
|
scope.dir.alternatives = boundedDirectories(
|
|
251
287
|
resolve(scope.dir.cwd, target),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Context } from "./programs";
|
|
2
|
+
import type { Effect, Target, Word } from "./record";
|
|
3
|
+
|
|
4
|
+
// Subcommands that print the content of a file, a commit, or the index.
|
|
5
|
+
const printing = new Set(["show", "diff", "log", "cat-file", "blame", "annotate", "grep", "archive", "format-patch", "whatchanged", "difftool", "diff-index", "diff-tree", "credential"]);
|
|
6
|
+
// Subcommands whose operands are paths that git stages, moves, or inspects without printing them.
|
|
7
|
+
const metadata = new Set(["add", "rm", "mv", "restore", "checkout", "reset", "stash", "check-ignore", "check-attr", "update-index", "ls-files", "status", "clean", "commit"]);
|
|
8
|
+
// Subcommands whose operands are refs, remotes, names, or URLs.
|
|
9
|
+
const names = new Set([
|
|
10
|
+
"branch",
|
|
11
|
+
"tag",
|
|
12
|
+
"remote",
|
|
13
|
+
"switch",
|
|
14
|
+
"push",
|
|
15
|
+
"fetch",
|
|
16
|
+
"pull",
|
|
17
|
+
"merge",
|
|
18
|
+
"rebase",
|
|
19
|
+
"cherry-pick",
|
|
20
|
+
"revert",
|
|
21
|
+
"reflog",
|
|
22
|
+
"rev-parse",
|
|
23
|
+
"describe",
|
|
24
|
+
"bisect",
|
|
25
|
+
"init",
|
|
26
|
+
"clone",
|
|
27
|
+
"submodule",
|
|
28
|
+
"worktree",
|
|
29
|
+
"config",
|
|
30
|
+
"lfs",
|
|
31
|
+
"sparse-checkout",
|
|
32
|
+
]);
|
|
33
|
+
// Options whose value is a file git reads.
|
|
34
|
+
const pathspecFile = ["--pathspec-from-file"];
|
|
35
|
+
const fileOptions: Record<string, string[]> = {
|
|
36
|
+
config: ["-f", "--file", "--blob"],
|
|
37
|
+
commit: ["-F", "--file", ...pathspecFile],
|
|
38
|
+
tag: ["-F", "--file"],
|
|
39
|
+
merge: ["-F", "--file"],
|
|
40
|
+
add: pathspecFile,
|
|
41
|
+
rm: pathspecFile,
|
|
42
|
+
restore: pathspecFile,
|
|
43
|
+
reset: pathspecFile,
|
|
44
|
+
checkout: pathspecFile,
|
|
45
|
+
stash: pathspecFile,
|
|
46
|
+
};
|
|
47
|
+
const valueOptions = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"];
|
|
48
|
+
|
|
49
|
+
// git grep takes its pattern from -e, or from the first operand; the pattern is text, not a path.
|
|
50
|
+
function grepOperands(operands: Word[], patterns: Word[]): Word[] {
|
|
51
|
+
const rest: Word[] = [];
|
|
52
|
+
let patterned = false;
|
|
53
|
+
let options = true;
|
|
54
|
+
for (let n = 0; n < operands.length; n++) {
|
|
55
|
+
const text = operands[n]!.text;
|
|
56
|
+
if (options && text === "--") options = false;
|
|
57
|
+
else if (options && text === "-e") {
|
|
58
|
+
patterned = true;
|
|
59
|
+
if (operands[n + 1]) patterns.push(operands[++n]!);
|
|
60
|
+
} else if (options && text === "-f") {
|
|
61
|
+
// The file holds the patterns; git reads it.
|
|
62
|
+
patterned = true;
|
|
63
|
+
if (operands[n + 1]) rest.push(operands[++n]!);
|
|
64
|
+
} else if (options && text.startsWith("-")) rest.push(operands[n]!);
|
|
65
|
+
else if (!patterned) {
|
|
66
|
+
patterned = true;
|
|
67
|
+
patterns.push(operands[n]!);
|
|
68
|
+
} else rest.push(operands[n]!);
|
|
69
|
+
}
|
|
70
|
+
return rest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function gitTargets({ words, make, claimed }: Context): Target[] {
|
|
74
|
+
const targets: Target[] = [];
|
|
75
|
+
let i = 0;
|
|
76
|
+
// Operands are relative to the directory the last -C or --work-tree names.
|
|
77
|
+
let base: string | undefined;
|
|
78
|
+
while (i < words.length && words[i]!.text.startsWith("-")) {
|
|
79
|
+
const glued = /^--work-tree=(.*)$/s.exec(words[i]!.text);
|
|
80
|
+
const takesValue = !glued && valueOptions.includes(words[i]!.text);
|
|
81
|
+
const value = glued ? words[i] : words[i + 1];
|
|
82
|
+
if ((glued || (takesValue && ["-C", "--work-tree"].includes(words[i]!.text))) && value) {
|
|
83
|
+
claimed.add(value);
|
|
84
|
+
const target = make(glued ? glued[1]! : value.text, value, "enter", { via: "option", base });
|
|
85
|
+
targets.push(target);
|
|
86
|
+
base = target.path;
|
|
87
|
+
}
|
|
88
|
+
i += takesValue ? 2 : 1;
|
|
89
|
+
}
|
|
90
|
+
const sub = words[i]?.text ?? "";
|
|
91
|
+
if (words[i]) claimed.add(words[i]!);
|
|
92
|
+
// Any subcommand git has no group for reads its operands like an unmodelled program.
|
|
93
|
+
const effect: Effect = printing.has(sub) ? "read" : metadata.has(sub) ? "meta" : names.has(sub) ? "name" : "read";
|
|
94
|
+
let operands = words.slice(i + 1);
|
|
95
|
+
if (sub === "grep") {
|
|
96
|
+
const patterns: Word[] = [];
|
|
97
|
+
operands = grepOperands(operands, patterns);
|
|
98
|
+
for (const pattern of patterns) claimed.add(pattern);
|
|
99
|
+
}
|
|
100
|
+
const keys = fileOptions[sub] ?? [];
|
|
101
|
+
// A pathspec holding a glob character is one git expands itself; git grep also reads a directory whole.
|
|
102
|
+
const pathspec = effect !== "name" && sub !== "grep";
|
|
103
|
+
const add = (path: string, word: Word, as: Effect, via: "operand" | "option") => {
|
|
104
|
+
claimed.add(word);
|
|
105
|
+
const glob = pathspec && /[*?[]/.test(path);
|
|
106
|
+
targets.push(make(path, word, as, { via, glob, base }));
|
|
107
|
+
// A rev:path operand names a file in a commit or the index.
|
|
108
|
+
if (as === "read" && path.includes(":")) targets.push(make(path.slice(path.indexOf(":") + 1), word, as, { via, glob, base }));
|
|
109
|
+
};
|
|
110
|
+
// `bundle create FILE` writes FILE; the other bundle subcommands read the bundle they are given.
|
|
111
|
+
const bundleAction = sub === "bundle" ? operands.find((word) => !word.text.startsWith("-")) : undefined;
|
|
112
|
+
const bundleFile = bundleAction?.text === "create" ? operands.filter((word) => !word.text.startsWith("-"))[1] : undefined;
|
|
113
|
+
for (let n = 0; n < operands.length; n++) {
|
|
114
|
+
const word = operands[n]!;
|
|
115
|
+
// A value is glued to a long option with = and to a short one directly.
|
|
116
|
+
const glued = keys.find((key) => (key.startsWith("--") ? word.text.startsWith(`${key}=`) : word.text.length > key.length && word.text.startsWith(key)));
|
|
117
|
+
if (glued) add(word.text.slice(glued.length + (glued.startsWith("--") ? 1 : 0)), word, "read", "option");
|
|
118
|
+
else if (keys.includes(word.text) && operands[n + 1]) {
|
|
119
|
+
const file = operands[++n]!;
|
|
120
|
+
add(file.text, file, "read", "option");
|
|
121
|
+
} else if (word === bundleAction) claimed.add(word);
|
|
122
|
+
else if (!word.text.startsWith("-")) add(word.text, word, word === bundleFile ? "write" : effect, "operand");
|
|
123
|
+
}
|
|
124
|
+
return targets;
|
|
125
|
+
}
|
package/src/guard.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// working directory, stands in when the event names none. Exit 0 means no
|
|
6
6
|
// objection and exit 2 denies with the reason on stderr. The wrapper turns
|
|
7
7
|
// every other outcome into a denial.
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { resolve } from "node:path";
|
|
10
11
|
import { parseArgs } from "node:util";
|
|
@@ -46,7 +47,9 @@ const [tool, field] = fields[(string(event.tool_name) ?? "Bash").toLowerCase()]
|
|
|
46
47
|
// Grep searches the working directory when it names no path.
|
|
47
48
|
const value = field && (tool === "grep" && input[field] === undefined ? "" : string(input[field]));
|
|
48
49
|
if (field && value === undefined) throw new Error(`tool_input.${field} is not a string`);
|
|
49
|
-
|
|
50
|
+
// The link walk reports physical paths, so a home directory spelled through a link (`/tmp`) is compared by the spelling the walk reports.
|
|
51
|
+
// The home directory is not inside App Data, so resolving it cannot search a protected tree.
|
|
52
|
+
const request = tool && value !== undefined ? buildRequest(runtime, tool, cwd, value, string(input["glob"]) ?? "", realpathSync(resolve(homedir()))) : undefined;
|
|
50
53
|
const reason = request && evaluate(request);
|
|
51
54
|
if (reason) {
|
|
52
55
|
console.error(runtime === "claude" ? `DENIED: ${reason} Do NOT bypass this restriction or retry the same blocked command.` : reason);
|