@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loophubs/agent-guard",
3
- "version": "0.2.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 { expandHome } from "./paths";
4
- import type { Command, Word } from "./record";
5
- import { searchRoles } from "./search-roles";
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
- export function resolveCommand(cmd: Command, home: string): { children: string[]; code: string[] } {
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: string[] = [];
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 = basename(w[i]!.text);
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 rest[n]!.role = "path";
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 (codePrograms.has(name)) {
187
- const flag = rest.findIndex((a) => /^(-[ceE]|--eval)$/.test(a.text));
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 (codePrograms.has(name)) return "code";
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 { linkedRequest } from "./links";
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 denials = [...appdataRules(req), ...credentialRules(req)];
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 = linkedRequest(req);
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
- statement(cmd.Y!, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
101
- if (op === "|") for (const item of xargsReplacements(script.commands.slice(start, middle), script.commands.slice(middle))) parse(item.source, item.cwd);
102
- if (op === "||") {
103
- scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), failed.dir.cwd, ...(failed.dir.alternatives ?? [])], home);
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") scope = isolated(scope);
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
- for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd });
212
- for (const src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
213
- script.uninspectable.push(...code);
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 && (!args.length || (args.length === 1 && args[0]?.text === "--")) && program.text === "cd") target = home;
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
- const request = tool && value !== undefined ? buildRequest(runtime, tool, cwd, value, string(input["glob"]) ?? "", resolve(homedir())) : undefined;
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);