@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loophubs/agent-guard",
3
- "version": "0.3.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 { 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
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 function resolveCommand(cmd: Command, home: string): { children: string[]; code: string[] } {
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: string[] = [];
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 rest[n]!.role = "path";
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 (codePrograms.has(name)) {
219
- const flag = rest.findIndex((a) => /^(-[ceE]|--eval)$/.test(a.text));
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 (codePrograms.has(name)) return "code";
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 { 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
@@ -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 src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
232
- script.uninspectable.push(...code);
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
- 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);
@@ -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 { readlinkSync } from "node:fs";
2
- import { basename, dirname, resolve } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
3
 
4
- import { programName } from "./argv";
5
- import { absPath, expandHome, isAppdata, isSensitive } from "./paths";
6
- import type { Request, Word } from "./record";
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
- export function linkedRequest(req: Request): Request | undefined {
10
- let changed = false;
11
- const protectedPath = (path: string) => isAppdata(path, req.home) || isSensitive(path);
12
- const linked = (value: string, cwd: string, quoted = false): string => {
13
- const input = quoted ? value : expandHome(value, req.home);
14
- const absolute = input.startsWith("/") ? input : `${cwd}/${input}`;
15
- let path = "/";
16
- let parts = absolute.split("/").filter(Boolean);
17
- let followed = false;
18
- let depth = 0;
19
- while (parts.length) {
20
- const part = parts.shift()!;
21
- path = part === ".." ? dirname(path) : resolve(path, part);
22
- // Do not ask the filesystem about a path inside a protected tree.
23
- if (protectedPath(path)) {
24
- changed = true;
25
- return path;
26
- }
27
- let target: string;
28
- try {
29
- target = readlinkSync(path);
30
- } catch {
31
- continue;
32
- }
33
- if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
34
- const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
35
- followed = true;
36
- changed = true;
37
- parts = [...targetPath.split("/").filter(Boolean), ...parts];
38
- path = "/";
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
- return followed ? path : value;
41
- };
42
- const word = (w: Word, cwd: string): Word => {
43
- if (w.expands || w.globs || !w.value || w.value.startsWith("-")) return w;
44
- if (w.role === "program" && !w.value.includes("/")) return w;
45
- if (!["arg", "path", "patfile", "option:patfile", "program"].includes(w.role)) return w;
46
- const value = linked(w.value, cwd, /^['"]/.test(w.raw));
47
- return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
48
- };
49
- // curl reads the file after @ or < in a data or form value, or one glued to -T, -K, --upload-file or --config.
50
- const curlOperand = /^((?:-[A-Za-z]|--[a-z-]+=|[^=@<-][^=@<]*=)?[@<])([^;]+)(.*)$/s;
51
- const curlFile = /^(-[TK]|--(?:upload-file|config)=)([^;]+)()$/s;
52
- const curlWord = (w: Word, cwd: string): Word | undefined => {
53
- const operand = curlOperand.exec(w.text) ?? curlFile.exec(w.text);
54
- if (!operand || w.expands) return undefined;
55
- const value = linked(operand[2]!, cwd);
56
- return value === operand[2] ? w : { ...w, text: `${operand[1]}${value}${operand[3]}` };
57
- };
58
- const commands = req.commands.map((cmd) => {
59
- const curl = cmd.program >= 0 && programName(cmd.argv[cmd.program]!.text) === "curl";
60
- const checkedCwd = linked(cmd.cwd, "/");
61
- const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
62
- if (cwd !== cmd.cwd) changed = true;
63
- return {
64
- ...cmd,
65
- cwd,
66
- argv: cmd.argv.map((w, i) =>
67
- dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : curl && i > cmd.program ? (curlWord(w, cmd.cwd) ?? word(w, cmd.cwd)) : word(w, cmd.cwd),
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
- const checkedCwd = linked(req.inputCwd, "/");
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
  }