@loophubs/agent-guard 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/argv.ts +69 -22
- package/src/core.ts +7 -5
- package/src/docker-targets.ts +87 -0
- package/src/frontend.ts +54 -18
- package/src/git-targets.ts +125 -0
- package/src/guard.ts +4 -1
- package/src/interpreters.ts +61 -0
- package/src/links.ts +66 -59
- package/src/paths.ts +85 -66
- package/src/pipeline.ts +120 -14
- package/src/probes.ts +5 -0
- package/src/programs.ts +341 -0
- package/src/reasons.ts +5 -1
- package/src/record.ts +33 -2
- package/src/rules/appdata.ts +29 -59
- package/src/rules/credentials.ts +38 -219
- package/src/rules/secrets.ts +143 -0
- package/src/search-roles.ts +5 -0
- package/src/search-targets.ts +33 -0
- package/src/ssh-targets.ts +46 -0
- package/src/ssh.ts +65 -0
- package/src/targets.ts +151 -0
- package/src/words.ts +16 -4
|
@@ -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 {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
4
|
+
import { isAppdata, isSensitive, unfirmlink } from "./paths";
|
|
5
|
+
import { probes } from "./probes";
|
|
6
|
+
import type { Target } from "./record";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
3
|
+
import { stdinKind } from "./argv";
|
|
4
|
+
import type { Command, Word } from "./record";
|
|
5
|
+
import { showsHidden } from "./search-roles";
|
|
4
6
|
|
|
5
|
-
// A
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 };
|