@loophubs/agent-guard 0.3.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 +35 -20
- package/src/core.ts +7 -5
- package/src/docker-targets.ts +87 -0
- package/src/frontend.ts +9 -3
- package/src/git-targets.ts +125 -0
- package/src/guard.ts +4 -1
- package/src/interpreters.ts +61 -0
- package/src/links.ts +66 -72
- package/src/paths.ts +6 -60
- package/src/pipeline.ts +3 -7
- package/src/probes.ts +5 -0
- package/src/programs.ts +341 -0
- package/src/reasons.ts +3 -1
- package/src/record.ts +32 -3
- package/src/rules/appdata.ts +29 -62
- package/src/rules/credentials.ts +35 -322
- 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/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,11 +1,12 @@
|
|
|
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
9
|
const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh"]);
|
|
8
|
-
const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
|
|
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]*$/;
|
|
11
12
|
const sudoValueOption = /^(-[A-Za-z]*[ughpCDRTrtU]|--(user|group|host|prompt|chdir|chroot|role|type|other-user|close-from|command-timeout))$/;
|
|
@@ -16,9 +17,14 @@ export function programName(text: string): string {
|
|
|
16
17
|
return name === "egrep" || name === "fgrep" ? "grep" : name;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export
|
|
20
|
+
export interface Child {
|
|
21
|
+
source: string;
|
|
22
|
+
items?: Items;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveCommand(cmd: Command, home: string): { children: Child[]; code: string[] } {
|
|
20
26
|
const w = cmd.argv;
|
|
21
|
-
const children:
|
|
27
|
+
const children: Child[] = [];
|
|
22
28
|
const code: string[] = [];
|
|
23
29
|
let i = 0;
|
|
24
30
|
let shell = true;
|
|
@@ -131,7 +137,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
131
137
|
} else if (["-u", "-P"].includes(arg)) i += 2;
|
|
132
138
|
else if (arg === "-S") {
|
|
133
139
|
// The split string is a command line of its own.
|
|
134
|
-
children.push(w[i + 1]?.text ?? "");
|
|
140
|
+
children.push({ source: w[i + 1]?.text ?? "" });
|
|
135
141
|
cmd.wrappers.push("env-S");
|
|
136
142
|
i = w.length;
|
|
137
143
|
} else if (arg.startsWith("-") || arg.includes("=")) i++;
|
|
@@ -166,18 +172,28 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
166
172
|
const rest = w.slice(i + 1);
|
|
167
173
|
if (["rg", "grep", "ag", "ack"].includes(name)) {
|
|
168
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
|
+
}
|
|
169
183
|
} else if (name === "fd") {
|
|
170
184
|
let pattern = true;
|
|
185
|
+
const roots: string[] = [];
|
|
171
186
|
for (let n = 0; n < rest.length; n++) {
|
|
172
187
|
const arg = rest[n]!.text;
|
|
173
188
|
if (["-x", "-X", "--exec", "--exec-batch"].includes(arg)) {
|
|
174
189
|
rest[n]!.role = "option";
|
|
175
|
-
children.push(
|
|
176
|
-
rest
|
|
190
|
+
children.push({
|
|
191
|
+
source: rest
|
|
177
192
|
.slice(n + 1)
|
|
178
193
|
.map((word) => word.raw)
|
|
179
194
|
.join(" "),
|
|
180
|
-
|
|
195
|
+
items: { root: absPath(roots[0] ?? ".", cmd.cwd, home), hidden: showsHidden(rest) },
|
|
196
|
+
});
|
|
181
197
|
break;
|
|
182
198
|
} else if ((/^(--search-path|--base-directory)(=|$)/.test(arg) || arg === "-C") && (arg.includes("=") || rest[n + 1])) {
|
|
183
199
|
const separate = !arg.includes("=");
|
|
@@ -197,7 +213,10 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
197
213
|
else if (pattern) {
|
|
198
214
|
rest[n]!.role = "pattern";
|
|
199
215
|
pattern = false;
|
|
200
|
-
} else
|
|
216
|
+
} else {
|
|
217
|
+
rest[n]!.role = "path";
|
|
218
|
+
roots.push(arg);
|
|
219
|
+
}
|
|
201
220
|
}
|
|
202
221
|
} else if (name === "du") {
|
|
203
222
|
for (let n = 0; n < rest.length; n++) {
|
|
@@ -211,16 +230,12 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
|
|
|
211
230
|
const flag = rest.findIndex((a) => shellCodeFlag.test(a.text));
|
|
212
231
|
if (flag >= 0) {
|
|
213
232
|
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
214
|
-
children.push(rest[flag + 1]?.text ?? "");
|
|
233
|
+
children.push({ source: rest[flag + 1]?.text ?? "" });
|
|
215
234
|
}
|
|
216
235
|
} else if (name === "eval" && (shell || cmd.wrappers.includes("command"))) {
|
|
217
|
-
children.push(rest.map((a) => a.text).join(" "));
|
|
218
|
-
} else if (
|
|
219
|
-
|
|
220
|
-
if (flag >= 0) {
|
|
221
|
-
if (rest[flag + 1]) rest[flag + 1]!.role = "code";
|
|
222
|
-
code.push(rest[flag + 1]?.text ?? "");
|
|
223
|
-
}
|
|
236
|
+
children.push({ source: rest.map((a) => a.text).join(" ") });
|
|
237
|
+
} else if (interpreterName(name)) {
|
|
238
|
+
code.push(...interpreterCode(interpreterName(name)!, rest));
|
|
224
239
|
}
|
|
225
240
|
return { children, code };
|
|
226
241
|
}
|
|
@@ -231,6 +246,6 @@ export function stdinKind(cmd: Command): "shell" | "code" | undefined {
|
|
|
231
246
|
const name = basename(program.text);
|
|
232
247
|
const args = cmd.argv.slice(cmd.program + 1);
|
|
233
248
|
if (shellPrograms.has(name) && !args.some((a) => shellCodeFlag.test(a.text))) return "shell";
|
|
234
|
-
if (
|
|
249
|
+
if (interpreterName(name)) return "code";
|
|
235
250
|
return undefined;
|
|
236
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
|
@@ -228,13 +228,19 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
228
228
|
const { children: sources, code } = resolveCommand(command, home);
|
|
229
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
230
|
for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd, argv: moved(cwd) });
|
|
231
|
-
for (const
|
|
232
|
-
|
|
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 });
|
|
233
239
|
for (const r of redirects) {
|
|
234
240
|
if (r.direction !== "heredoc" && r.direction !== "herestring") continue;
|
|
235
241
|
const stdin = stdinKind(command);
|
|
236
242
|
if (stdin === "shell") parse(r.target, command.cwd);
|
|
237
|
-
if (stdin === "code") script.uninspectable.push(r.target);
|
|
243
|
+
if (stdin === "code") script.uninspectable.push({ text: r.target, cwd: command.cwd });
|
|
238
244
|
if (command.wrappers.includes("xargs")) for (const item of xargsHereInput(command, r.target)) parse(item.source, item.cwd);
|
|
239
245
|
}
|
|
240
246
|
const called = command.wrappers.every((w) => w === "time") ? command.argv[command.program]?.text : undefined;
|
|
@@ -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);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Word } from "./record";
|
|
2
|
+
|
|
3
|
+
// Per interpreter: the short flags after which the code follows, in the rest of the cluster when `glued` and in the next word otherwise,
|
|
4
|
+
// and the flags whose value is the rest of the cluster, so a value such as `-rtime` does not read its last letter as a code flag.
|
|
5
|
+
const flags: Record<string, { code: string; value: string; glued: boolean }> = {
|
|
6
|
+
python: { code: "c", value: "WX", glued: true },
|
|
7
|
+
python3: { code: "c", value: "WX", glued: true },
|
|
8
|
+
node: { code: "ep", value: "", glued: false },
|
|
9
|
+
bun: { code: "ep", value: "", glued: true },
|
|
10
|
+
ruby: { code: "e", value: "rICEix", glued: true },
|
|
11
|
+
perl: { code: "eE", value: "MmIidDCFx", glued: true },
|
|
12
|
+
php: { code: "rR", value: "dcfz", glued: true },
|
|
13
|
+
osascript: { code: "e", value: "", glued: true },
|
|
14
|
+
lua: { code: "e", value: "l", glued: true },
|
|
15
|
+
deno: { code: "", value: "", glued: false },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// The table entry a program name selects, ignoring a version suffix such as `python3.14`.
|
|
19
|
+
export function interpreterName(name: string): string | undefined {
|
|
20
|
+
return [name, name.replace(/[\d.]+$/, "")].find((candidate) => candidate in flags);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The code an interpreter runs from its command line: the word after a code flag, the value glued to `--eval=`, or deno's eval operand.
|
|
24
|
+
export function interpreterCode(name: string, args: Word[]): string[] {
|
|
25
|
+
const { code, value, glued } = flags[name]!;
|
|
26
|
+
const found: string[] = [];
|
|
27
|
+
const take = (word: Word | undefined) => {
|
|
28
|
+
if (!word) return;
|
|
29
|
+
word.role = "code";
|
|
30
|
+
found.push(word.text);
|
|
31
|
+
};
|
|
32
|
+
if (name === "deno") {
|
|
33
|
+
// Flags such as `-p` and `--ext=ts` sit between `eval` and the code.
|
|
34
|
+
// `eval` follows the global flags and the value of a flag such as `--log-level debug`.
|
|
35
|
+
const operands = args.filter((word) => !word.text.startsWith("-"));
|
|
36
|
+
const at = operands.slice(0, 2).findIndex((word) => word.text === "eval");
|
|
37
|
+
if (at >= 0) for (const word of operands.slice(at + 1)) take(word);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const text = args[i]!.text;
|
|
42
|
+
// node's --run names a package script; only php's takes code.
|
|
43
|
+
const long = (name === "php" ? /^--(eval|print|run)(=(.*))?$/s : /^--(eval|print)(=(.*))?$/s).exec(text);
|
|
44
|
+
if (long) {
|
|
45
|
+
if (long[2] === undefined) take(args[++i]);
|
|
46
|
+
else found.push(long[3]!);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!/^-[^-]/.test(text)) continue;
|
|
50
|
+
for (let k = 1; k < text.length; k++) {
|
|
51
|
+
if (value.includes(text[k]!)) break;
|
|
52
|
+
if (code.includes(text[k]!)) {
|
|
53
|
+
if (k < text.length - 1 && !glued) continue;
|
|
54
|
+
if (k < text.length - 1) found.push(text.slice(k + 1));
|
|
55
|
+
else take(args[++i]);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
package/src/links.ts
CHANGED
|
@@ -1,77 +1,71 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
7
|
-
import { dataPrograms } from "./rules/appdata";
|
|
4
|
+
import { isAppdata, isSensitive, unfirmlink } from "./paths";
|
|
5
|
+
import { probes } from "./probes";
|
|
6
|
+
import type { Target } from "./record";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
8
|
+
// Errors that mean the path is not a link: it names a regular file or directory, or nothing (a component past NAME_MAX cannot exist). Any other
|
|
9
|
+
// failure means the check did not run.
|
|
10
|
+
export const notALink = ["EINVAL", "ENOENT", "ENOTDIR", "ENAMETOOLONG"];
|
|
11
|
+
|
|
12
|
+
// The directories of the Data volume that are also reached from the root, in the kernel's own table. The kernel resolves `..` from one of them
|
|
13
|
+
// in the root tree, and from any other directory of the Data volume in place. The table is a fixed system file, not a path taken from a command;
|
|
14
|
+
// if it is missing the import fails and the guard denies.
|
|
15
|
+
const firmlinks = new Set(
|
|
16
|
+
readFileSync("/usr/share/firmlinks", "utf8")
|
|
17
|
+
.split("\n")
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((line) => `/system/volumes/data/${line.split("\t")[1]!.toLowerCase()}`),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Follow the symlinks in an absolute path with readlink alone, so a link into a protected tree is judged by where it leads. A path for which
|
|
23
|
+
// `stopAt` holds is returned at the point it is reached, because the filesystem is not asked about anything beyond it.
|
|
24
|
+
export function followLinks(absolute: string, home: string, stopAt = (path: string) => isAppdata(path, home) || isSensitive(path)): string {
|
|
25
|
+
let path = "/";
|
|
26
|
+
let parts = absolute.split("/").filter(Boolean);
|
|
27
|
+
let followed = false;
|
|
28
|
+
let depth = 0;
|
|
29
|
+
while (parts.length) {
|
|
30
|
+
const part = parts.shift()!;
|
|
31
|
+
// The walk keeps the physical spelling, because `..` after the firmlink prefix leaves it, and only the check and the result use the plain one.
|
|
32
|
+
path = part === ".." ? dirname(firmlinks.has(path.toLowerCase()) ? unfirmlink(path) : path) : resolve(path, part);
|
|
33
|
+
if (stopAt(unfirmlink(path))) return unfirmlink(path);
|
|
34
|
+
let target: string;
|
|
35
|
+
try {
|
|
36
|
+
target = probes.readlink(path);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (notALink.includes((error as NodeJS.ErrnoException).code ?? "")) continue;
|
|
39
|
+
throw error;
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
),
|
|
69
|
-
redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
|
|
70
|
-
};
|
|
41
|
+
if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
|
|
42
|
+
const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
|
|
43
|
+
followed = true;
|
|
44
|
+
parts = [...targetPath.split("/").filter(Boolean), ...parts];
|
|
45
|
+
path = "/";
|
|
46
|
+
}
|
|
47
|
+
return followed ? unfirmlink(path) : absolute;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The path a glob target names up to its first wildcard segment: only that prefix exists as a path before the shell expands the rest.
|
|
51
|
+
function followGlob(absolute: string, home: string): string {
|
|
52
|
+
const segments = absolute.split("/");
|
|
53
|
+
const wildcard = segments.findIndex((segment) => /[*?[{]/.test(segment));
|
|
54
|
+
if (wildcard < 0) return followLinks(absolute, home);
|
|
55
|
+
const prefix = segments.slice(0, wildcard).join("/") || "/";
|
|
56
|
+
const followed = followLinks(prefix, home);
|
|
57
|
+
return followed === prefix ? absolute : [followed === "/" ? "" : followed, ...segments.slice(wildcard)].join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Returns the same targets with followed paths replaced, or undefined when no path changed.
|
|
61
|
+
export function linkedTargets(targets: Target[], home: string): Target[] | undefined {
|
|
62
|
+
let changed = false;
|
|
63
|
+
const result = targets.map((target) => {
|
|
64
|
+
if (target.expands || target.effect === "name" || (target.via === "tool" && target.glob)) return target;
|
|
65
|
+
const path = target.glob ? followGlob(target.unresolved, home) : followLinks(target.unresolved, home);
|
|
66
|
+
if (path === target.unresolved) return target;
|
|
67
|
+
changed = true;
|
|
68
|
+
return { ...target, path, unresolved: path };
|
|
71
69
|
});
|
|
72
|
-
|
|
73
|
-
const cwd = checkedCwd === req.inputCwd ? req.cwd : checkedCwd;
|
|
74
|
-
const target = req.target ? linked(req.pathInput, req.inputCwd) : req.target;
|
|
75
|
-
const searchRoot = req.searchRoot ? linked(req.pathInput || req.inputCwd, req.inputCwd) : req.searchRoot;
|
|
76
|
-
return changed ? { ...req, cwd, target, searchRoot, commands } : undefined;
|
|
70
|
+
return changed ? result : undefined;
|
|
77
71
|
}
|