@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.
@@ -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,64 +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 { absPath, expandHome, isAppdata, isSensitive } from "./paths";
5
- import type { Request, Word } from "./record";
6
- import { dataPrograms } from "./rules/appdata";
4
+ import { isAppdata, isSensitive, unfirmlink } from "./paths";
5
+ import { probes } from "./probes";
6
+ import type { Target } from "./record";
7
7
 
8
- export function linkedRequest(req: Request): Request | undefined {
9
- let changed = false;
10
- const protectedPath = (path: string) => isAppdata(path, req.home) || isSensitive(path);
11
- const linked = (value: string, cwd: string, quoted = false): string => {
12
- const input = quoted ? value : expandHome(value, req.home);
13
- const absolute = input.startsWith("/") ? input : `${cwd}/${input}`;
14
- let path = "/";
15
- let parts = absolute.split("/").filter(Boolean);
16
- let followed = false;
17
- let depth = 0;
18
- while (parts.length) {
19
- const part = parts.shift()!;
20
- path = part === ".." ? dirname(path) : resolve(path, part);
21
- // Do not ask the filesystem about a path inside a protected tree.
22
- if (protectedPath(path)) {
23
- changed = true;
24
- return path;
25
- }
26
- let target: string;
27
- try {
28
- target = readlinkSync(path);
29
- } catch {
30
- continue;
31
- }
32
- if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
33
- const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
34
- followed = true;
35
- changed = true;
36
- parts = [...targetPath.split("/").filter(Boolean), ...parts];
37
- 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;
38
40
  }
39
- return followed ? path : value;
40
- };
41
- const word = (w: Word, cwd: string): Word => {
42
- if (w.expands || w.globs || !w.value || w.value.startsWith("-")) return w;
43
- if (w.role === "program" && !w.value.includes("/")) return w;
44
- if (!["arg", "path", "patfile", "option:patfile", "program"].includes(w.role)) return w;
45
- const value = linked(w.value, cwd, /^['"]/.test(w.raw));
46
- return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
47
- };
48
- const commands = req.commands.map((cmd) => {
49
- const checkedCwd = linked(cmd.cwd, "/");
50
- const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
51
- if (cwd !== cmd.cwd) changed = true;
52
- return {
53
- ...cmd,
54
- cwd,
55
- argv: cmd.argv.map((w, i) => (dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : word(w, cmd.cwd))),
56
- redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
57
- };
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 };
58
69
  });
59
- const checkedCwd = linked(req.inputCwd, "/");
60
- const cwd = checkedCwd === req.inputCwd ? req.cwd : checkedCwd;
61
- const target = req.target ? linked(req.pathInput, req.inputCwd) : req.target;
62
- const searchRoot = req.searchRoot ? linked(req.pathInput || req.inputCwd, req.inputCwd) : req.searchRoot;
63
- return changed ? { ...req, cwd, target, searchRoot, commands } : undefined;
70
+ return changed ? result : undefined;
64
71
  }
package/src/paths.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  // Filesystem checks run after lexical denials to avoid touching protected trees.
2
- import { realpathSync, statSync } from "node:fs";
3
2
  import { userInfo } from "node:os";
4
3
  import { basename, dirname, resolve } from "node:path";
5
4
 
@@ -17,6 +16,14 @@ export const sensitivePaths = [
17
16
  "**/auth.json*",
18
17
  "**/.credentials.json*",
19
18
  "**/.aws/credentials*",
19
+ "**/.netrc",
20
+ "**/.git-credentials",
21
+ "**/.docker/config.json",
22
+ "**/.kube/config",
23
+ "**/.pypirc",
24
+ "**/.pgpass",
25
+ "**/.cargo/credentials*",
26
+ "**/.config/gh/hosts.yml",
20
27
  "**/private-keys-v1.d",
21
28
  "**/private-keys-v1.d/**",
22
29
  ];
@@ -27,6 +34,11 @@ const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
27
34
  // ~/.ssh has its own inode-based check and reason.
28
35
  const credentialRoots = [".aws", ".gnupg"];
29
36
  const credentialDirectories = [".ssh", ...credentialRoots];
37
+ // The directory part of each listed file that sits in a named directory, such as .kube for .kube/config.
38
+ const listedDirectories = sensitivePaths.flatMap((listed) => {
39
+ const tail = listed.replace(/^\*\*\//, "").split("/");
40
+ return tail.length > 1 && !tail.slice(0, -1).some((part) => part.includes("*")) ? [tail.slice(0, -1).join("/")] : [];
41
+ });
30
42
 
31
43
  export function expandHome(path: string, home: string): string {
32
44
  for (const prefix of ["~", `~${userInfo().username}`]) {
@@ -35,10 +47,14 @@ export function expandHome(path: string, home: string): string {
35
47
  return path;
36
48
  }
37
49
 
50
+ // /System/Volumes/Data is the same tree as the root through firmlinks, so a path spelled through it is judged as the plain path. Case is ignored
51
+ // because the default APFS volume ignores it.
52
+ export const unfirmlink = (path: string): string => path.replace(/^\/System\/Volumes\/Data(?=\/|$)/i, "") || "/";
53
+
38
54
  // curl, open and git read a file:// URL as the path it names.
39
55
  export function absPath(path: string, cwd: string, home: string, quoted = false): string {
40
56
  if (!quoted) path = expandHome(path, home);
41
- return resolve(cwd, path.replace(/^file:\/\//i, ""));
57
+ return unfirmlink(resolve(cwd, path.replace(/^file:\/\//i, "")));
42
58
  }
43
59
 
44
60
  // Whether the shell could expand the glob's leading segments to `directory`, so
@@ -88,7 +104,7 @@ export function isLibrary(path: string, home: string): boolean {
88
104
  return path.toLowerCase() === `${home}/library`.toLowerCase() || isAppdata(path, home);
89
105
  }
90
106
 
91
- const sshPublic = (name: string) => /^(config|config\..*|.*\.pub|allowed_signers|known_hosts.*)$/s.test(name);
107
+ export const sshPublic = (name: string) => /^(config|config\..*|.*\.pub|allowed_signers|known_hosts.*)$/s.test(name);
92
108
 
93
109
  // Under ~/.ssh only a top-level client config, public key, allowed_signers or
94
110
  // known_hosts file is public.
@@ -112,14 +128,66 @@ export function isSensitive(path: string, glob = false): boolean {
112
128
  if (!glob) return false;
113
129
  if (globDirectory(path)) return true;
114
130
  const base = basename(lower);
115
- if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower)));
116
- const pattern = new Bun.Glob(base);
131
+ // A bare wildcard reaches every file in the directory, so a directory that holds a listed file counts.
132
+ if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower))) || listedDirectories.some((dir) => dirname(lower).endsWith(`/${dir}`));
133
+ // A listed file inside a named directory needs the glob's directory to match too.
134
+ const segments = lower.split("/");
117
135
  return sensitivePaths.some((listed) => {
118
- const name = basename(listed).replaceAll("*", "x");
119
- return !/^x*$/.test(name) && pattern.match(name);
136
+ const tail = listed.replace(/^\*\*\//, "").split("/");
137
+ if (tail.length > segments.length) return false;
138
+ if (/^\**$/.test(tail.at(-1)!)) return false;
139
+ const names = tail.map((part) => part.replaceAll("*", "x"));
140
+ const offset = segments.length - tail.length;
141
+ if (!names.slice(0, -1).every((name, i) => new Bun.Glob(segments[offset + i]!).match(name))) return false;
142
+ // A named directory already narrows the match, so a glob that could reach any listed name in it counts.
143
+ return tail.length > 1 ? globsIntersect(segments.at(-1)!, tail.at(-1)!) : new Bun.Glob(segments.at(-1)!).match(names.at(-1)!);
120
144
  });
121
145
  }
122
146
 
147
+ type GlobToken = "*" | ((char: string) => boolean);
148
+
149
+ // Reads `*`, `?`, `[set]` and literal characters; a backslash escapes the next one.
150
+ function globTokens(glob: string): GlobToken[] {
151
+ const tokens: GlobToken[] = [];
152
+ for (let i = 0; i < glob.length; i++) {
153
+ const char = glob[i]!;
154
+ const close = char === "[" ? glob.indexOf("]", i + 2) : -1;
155
+ if (char === "*") tokens.push("*");
156
+ else if (char === "?") tokens.push(() => true);
157
+ else if (close > 0) {
158
+ const matcher = new Bun.Glob(glob.slice(i, close + 1));
159
+ tokens.push((c) => matcher.match(c));
160
+ i = close;
161
+ } else {
162
+ const literal = char === "\\" ? (glob[++i] ?? char) : char;
163
+ tokens.push((c) => c === literal);
164
+ }
165
+ }
166
+ return tokens;
167
+ }
168
+
169
+ // Whether some file name matches both wildcard patterns.
170
+ function globsIntersect(a: string, b: string): boolean {
171
+ const [left, right] = [globTokens(a), globTokens(b)];
172
+ const probes = Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i));
173
+ const seen = new Set<string>();
174
+ const walk = (i: number, j: number): boolean => {
175
+ const key = `${i},${j}`;
176
+ if (seen.has(key)) return false;
177
+ seen.add(key);
178
+ const [x, y] = [left[i], right[j]];
179
+ if (x === undefined && y === undefined) return true;
180
+ if (x === "*" && walk(i + 1, j)) return true;
181
+ if (y === "*" && walk(i, j + 1)) return true;
182
+ if (x === "*" && y === "*") return false;
183
+ if (x === "*") return y !== undefined && walk(i, j + 1);
184
+ if (y === "*") return x !== undefined && walk(i + 1, j);
185
+ if (x === undefined || y === undefined) return false;
186
+ return probes.some((c) => x(c) && y(c)) && walk(i + 1, j + 1);
187
+ };
188
+ return walk(0, 0);
189
+ }
190
+
123
191
  // A wildcard directory segment such as `.s*` may expand to a credential
124
192
  // directory. The shell's wildcards skip a leading dot, so only a segment that
125
193
  // starts with one counts.
@@ -134,63 +202,14 @@ function globDirectory(path: string): boolean {
134
202
 
135
203
  // A search reads everything under its root, so a credential directory counts as
136
204
  // its listed files do.
137
- export function isSensitiveRoot(path: string): boolean {
138
- return isSensitive(path) || credentialRoots.includes(basename(path.toLowerCase()));
139
- }
140
-
141
- // A path that does not exist or cannot be searched has no inode to compare.
142
- function stat(path: string) {
143
- try {
144
- return statSync(path);
145
- } catch {
146
- return undefined;
147
- }
148
- }
149
-
150
- function sameFile(a: string, b: string): boolean {
151
- if (a === b) return true;
152
- const x = stat(a);
153
- const y = stat(b);
154
- return x !== undefined && y !== undefined && x.dev === y.dev && x.ino === y.ino;
155
- }
156
-
157
- function real(path: string): string {
158
- try {
159
- return realpathSync(path);
160
- } catch {
161
- return path;
162
- }
163
- }
164
-
165
- function kind(path: string): "dir" | "file" | "other" {
166
- const st = stat(path);
167
- return st?.isDirectory() ? "dir" : st?.isFile() ? "file" : "other";
168
- }
169
-
170
- // Filesystem check for a file tool target, or a search root when search is
171
- // set. It follows symlinks and compares inodes so a link or a case alias of
172
- // ~/.ssh resolves to the directory it names.
173
- export function sshScopeDenied(target: string, home: string, search: boolean): boolean {
174
- const ssh = `${home}/.ssh`;
175
- const candidates = [...new Set([target, real(target)])];
176
- const roots = [...new Set([ssh, real(ssh)])];
177
- for (const candidate of candidates) {
178
- for (const root of roots) {
179
- if (sameFile(candidate, root)) return true;
180
- if (search) {
181
- for (let parent = root; parent !== "/";) {
182
- parent = dirname(parent);
183
- if (sameFile(candidate, parent)) return true;
184
- }
185
- }
186
- for (let parent = candidate; parent !== "/";) {
187
- parent = dirname(parent);
188
- if (!sameFile(parent, root)) continue;
189
- if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
190
- if (kind(target) === "dir") return true;
191
- if (search && kind(target) !== "file") return true;
192
- }
193
- }
194
- }
195
- return false;
205
+ export function isSensitiveRoot(path: string, home: string): boolean {
206
+ const lower = path.toLowerCase();
207
+ // A search from the home directory's .config reaches .config/gh because gh is not hidden.
208
+ const ancestors = listedDirectories.flatMap((dir) =>
209
+ dir
210
+ .split("/")
211
+ .slice(0, -1)
212
+ .map((_, i, parts) => `${home.toLowerCase()}/${parts.slice(0, i + 1).join("/")}`),
213
+ );
214
+ return isSensitive(path) || credentialRoots.includes(basename(lower)) || listedDirectories.some((dir) => lower.endsWith(`/${dir}`)) || ancestors.includes(lower);
196
215
  }
package/src/pipeline.ts CHANGED
@@ -1,15 +1,80 @@
1
1
  import { basename } from "node:path";
2
2
 
3
- import type { Command } from "./record";
3
+ import { stdinKind } from "./argv";
4
+ import type { Command, Word } from "./record";
5
+ import { showsHidden } from "./search-roles";
4
6
 
5
- // A literal printf value is known before xargs substitutes it into a command.
6
- export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
7
- const printf = left.find((cmd) => cmd.program >= 0 && basename(cmd.argv[cmd.program]!.text) === "printf");
8
- const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
9
- if (!printf || !xargs) return [];
10
- const args = printf.argv.slice(printf.program + 1);
11
- if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0]!.text)) return [];
7
+ // A dotfile name, or a glob the shell expands to dotfiles; `.` and `..` are directories.
8
+ const hiddenName = (text: string) => /^\.(?!\.?$)/.test(basename(text));
9
+
10
+ // xargs passes the names a walk printed to its command, so mark that command.
11
+ export function markWalkedInput(left: Command[], right: Command[]) {
12
+ const walker = left.find((cmd) => {
13
+ const name = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
14
+ const args = cmd.argv.slice(cmd.program + 1);
15
+ const operands = args.filter((word) => !word.text.startsWith("-"));
16
+ if (name === "ls") return args.some((word) => /^(--all|--almost-all|-[A-Za-z0-9]*[aA][A-Za-z0-9]*)$/.test(word.text)) || operands.some((word) => hiddenName(word.text));
17
+ if (name === "echo" || name === "printf") return operands.some((word) => word.globs && hiddenName(word.text));
18
+ return name === "find" || (name === "fd" && showsHidden(args));
19
+ });
20
+ if (walker) for (const cmd of right) if (cmd.wrappers.includes("xargs")) cmd.items = { root: walker.cwd, hidden: true };
21
+ }
12
22
 
23
+ // printf and zsh's echo decode these escapes and stop at `\c`; a NUL separates xargs -0 items, so it reads as a line break.
24
+ // A printf format takes up to three octal digits after the backslash; %b and echo take a leading 0 as well.
25
+ const escapes: Record<string, string> = { n: "\n", t: "\t", "\\": "\\" };
26
+ const unescape = (text: string, operand = false) =>
27
+ text
28
+ .split("\\c", 1)[0]!
29
+ .replace(
30
+ new RegExp(`\\\\(?:x([0-9a-fA-F]{1,2})|(${operand ? "0[0-7]{0,3}|[1-7][0-7]{0,2}" : "[0-7]{1,3}"})|([nt\\\\])|u([0-9a-fA-F]{1,4})|U([0-9a-fA-F]{1,8}))`, "g"),
31
+ (_, hex?: string, octal?: string, named?: string, unicode?: string, wide?: string) => {
32
+ const hexadecimal = hex ?? unicode ?? wide;
33
+ const code = hexadecimal !== undefined ? parseInt(hexadecimal, 16) : octal !== undefined ? parseInt(octal, 8) : undefined;
34
+ return code === undefined ? escapes[named!]! : code === 0 ? "\n" : String.fromCharCode(code);
35
+ },
36
+ );
37
+
38
+ // What printf writes for a format built from literal text, %s, %b and %%; another conversion is unknown.
39
+ function printfOutput(args: Word[]): string | undefined {
40
+ const [format, ...operands] = (args[0]?.text === "--" ? args.slice(1) : args).map((word) => word.text);
41
+ if (format === undefined) return "";
42
+ const pieces = format.split(/(%[sb%])/);
43
+ if (pieces.some((piece) => piece.includes("%") && !/^%[sb%]$/.test(piece))) return undefined;
44
+ const converts = pieces.some((piece) => /^%[sb]$/.test(piece));
45
+ let out = "";
46
+ do {
47
+ for (const piece of pieces) {
48
+ if (piece === "%%") out += "%";
49
+ else if (piece === "%s") out += operands.shift() ?? "";
50
+ else if (piece === "%b") out += unescape(operands.shift() ?? "", true);
51
+ else out += unescape(piece);
52
+ }
53
+ } while (converts && operands.length);
54
+ return out;
55
+ }
56
+
57
+ // A literal echo or printf value piped into a shell is the command line it runs.
58
+ export function shellInput(left: Command[], right: Command[]): { source: string; cwd: string }[] {
59
+ const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
60
+ const shell = right.find((cmd) => cmd.program >= 0 && stdinKind(cmd) === "shell");
61
+ if (!producer || !shell) return [];
62
+ const args = producer.argv.slice(producer.program + 1);
63
+ const source =
64
+ basename(producer.argv[producer.program]!.text) === "printf"
65
+ ? printfOutput(args)
66
+ : unescape(
67
+ args
68
+ .filter((word) => !word.text.startsWith("-"))
69
+ .map((word) => word.text)
70
+ .join(" "),
71
+ true,
72
+ );
73
+ return source === undefined ? [] : [{ source, cwd: shell.cwd }];
74
+ }
75
+
76
+ // xargs runs its command once with every item, or once per item with -I.
77
+ function xargsCommands(xargs: Command, items: string[]): { source: string; cwd: string }[] {
13
78
  const options = xargs.argv.slice(0, xargs.program);
14
79
  let marker = "";
15
80
  for (let i = 0; i < options.length; i++) {
@@ -18,11 +83,52 @@ export function xargsReplacements(left: Command[], right: Command[]): { source:
18
83
  else if (option.startsWith("-I")) marker = option.slice(2);
19
84
  else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
20
85
  }
21
- if (!marker) return [];
86
+ // xargs strips quotes and backslashes from what it reads, with or without -I.
87
+ const stripped = (value: string) => value.replace(/["'\\]/g, "");
22
88
  const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
23
- const source = xargs.argv
24
- .slice(xargs.program)
25
- .map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, args[1]!.text)) : word.raw))
26
- .join(" ");
27
- return [{ source, cwd: xargs.cwd }];
89
+ const command = xargs.argv.slice(xargs.program);
90
+ // Without -I, xargs splits its input on blanks, so a printed line can become several arguments.
91
+ if (!marker)
92
+ return [
93
+ {
94
+ source: [
95
+ ...command.map((word) => word.raw),
96
+ ...items
97
+ .flatMap((item) => item.split(/\s+/))
98
+ .filter(Boolean)
99
+ .flatMap((item) => [item, stripped(item)])
100
+ .map(quote),
101
+ ].join(" "),
102
+ cwd: xargs.cwd,
103
+ },
104
+ ];
105
+ return items
106
+ .flatMap((line) => [line, stripped(line)])
107
+ .map((item) => ({ source: command.map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, item)) : word.raw)).join(" "), cwd: xargs.cwd }));
108
+ }
109
+
110
+ // Literal printf and echo values are known before xargs passes them to a command.
111
+ export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
112
+ const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
113
+ const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
114
+ if (!producer || !xargs) return [];
115
+ const args = producer.argv.slice(producer.program + 1);
116
+ if (basename(producer.argv[producer.program]!.text) !== "printf")
117
+ return xargsCommands(
118
+ xargs,
119
+ args.filter((word) => !word.text.startsWith("-")).map((word) => unescape(word.text, true)),
120
+ );
121
+ const output = printfOutput(args);
122
+ return output === undefined ? [] : xargsCommands(xargs, output.split("\n").filter(Boolean));
123
+ }
124
+
125
+ // A here-string or here-document body is the item list xargs reads.
126
+ export function xargsHereInput(xargs: Command, body: string): { source: string; cwd: string }[] {
127
+ return xargsCommands(
128
+ xargs,
129
+ body
130
+ .split("\n")
131
+ .flatMap((line) => line.split(/\s+/))
132
+ .filter(Boolean),
133
+ );
28
134
  }
package/src/probes.ts ADDED
@@ -0,0 +1,5 @@
1
+ // The only filesystem calls the guard makes about a path taken from a command. Tests replace these to record each probed path and check that
2
+ // none of them resolves into App Data, so every caller must reach the filesystem through here.
3
+ import { readlinkSync, statSync } from "node:fs";
4
+
5
+ export const probes = { readlink: readlinkSync, stat: statSync };