@loophubs/agent-guard 0.1.0 → 0.3.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/README.md +8 -84
- package/bin/agent-guard +14 -10
- package/package.json +29 -8
- package/src/argv.ts +99 -59
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +107 -56
- package/src/guard.ts +32 -16
- package/src/links.ts +38 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +140 -20
- package/src/pipeline.ts +126 -14
- package/src/reasons.ts +6 -3
- package/src/record.ts +7 -1
- package/src/rules/appdata.ts +18 -23
- package/src/rules/credentials.ts +176 -33
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +23 -17
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/src/paths.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Filesystem checks run after lexical denials to avoid touching protected trees.
|
|
2
|
-
import {
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { userInfo } from "node:os";
|
|
4
4
|
import { basename, dirname, resolve } from "node:path";
|
|
5
5
|
|
|
@@ -7,15 +7,40 @@ export const appdataTrees = ["Containers", "Group Containers", "Mobile Documents
|
|
|
7
7
|
|
|
8
8
|
// The one list of credential-bearing paths, matched against absolute paths.
|
|
9
9
|
export const sensitivePaths = [
|
|
10
|
-
"**/.env",
|
|
11
|
-
"**/.env
|
|
12
|
-
"**/.npmrc",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
10
|
+
"**/.env",
|
|
11
|
+
"**/.env.*",
|
|
12
|
+
"**/.npmrc",
|
|
13
|
+
"**/.zprofile*",
|
|
14
|
+
"**/.zsh_history*",
|
|
15
|
+
"**/*.pem",
|
|
16
|
+
"**/*.key",
|
|
17
|
+
"**/auth.json*",
|
|
18
|
+
"**/.credentials.json*",
|
|
19
|
+
"**/.aws/credentials*",
|
|
20
|
+
"**/.netrc",
|
|
21
|
+
"**/.git-credentials",
|
|
22
|
+
"**/.docker/config.json",
|
|
23
|
+
"**/.kube/config",
|
|
24
|
+
"**/.pypirc",
|
|
25
|
+
"**/.pgpass",
|
|
26
|
+
"**/.cargo/credentials*",
|
|
27
|
+
"**/.config/gh/hosts.yml",
|
|
28
|
+
"**/private-keys-v1.d",
|
|
29
|
+
"**/private-keys-v1.d/**",
|
|
15
30
|
];
|
|
16
31
|
|
|
17
32
|
const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
|
|
18
33
|
|
|
34
|
+
// Directories that hold listed files, so a search rooted at one reads them.
|
|
35
|
+
// ~/.ssh has its own inode-based check and reason.
|
|
36
|
+
const credentialRoots = [".aws", ".gnupg"];
|
|
37
|
+
const credentialDirectories = [".ssh", ...credentialRoots];
|
|
38
|
+
// The directory part of each listed file that sits in a named directory, such as .kube for .kube/config.
|
|
39
|
+
const listedDirectories = sensitivePaths.flatMap((listed) => {
|
|
40
|
+
const tail = listed.replace(/^\*\*\//, "").split("/");
|
|
41
|
+
return tail.length > 1 && !tail.slice(0, -1).some((part) => part.includes("*")) ? [tail.slice(0, -1).join("/")] : [];
|
|
42
|
+
});
|
|
43
|
+
|
|
19
44
|
export function expandHome(path: string, home: string): string {
|
|
20
45
|
for (const prefix of ["~", `~${userInfo().username}`]) {
|
|
21
46
|
if (path === prefix || path.startsWith(`${prefix}/`)) return home + path.slice(prefix.length);
|
|
@@ -23,22 +48,34 @@ export function expandHome(path: string, home: string): string {
|
|
|
23
48
|
return path;
|
|
24
49
|
}
|
|
25
50
|
|
|
51
|
+
// curl, open and git read a file:// URL as the path it names.
|
|
26
52
|
export function absPath(path: string, cwd: string, home: string, quoted = false): string {
|
|
27
53
|
if (!quoted) path = expandHome(path, home);
|
|
28
|
-
return resolve(cwd, path);
|
|
54
|
+
return resolve(cwd, path.replace(/^file:\/\//i, ""));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Whether the shell could expand the glob's leading segments to `directory`, so
|
|
58
|
+
// `~/Lib*/Cont*/x` reads inside `~/Library/Containers`. A pattern that stops at
|
|
59
|
+
// the directory only names it and is left to the callers' other checks.
|
|
60
|
+
function globReaches(path: string, directory: string): boolean {
|
|
61
|
+
const pattern = path.split("/");
|
|
62
|
+
const target = directory.split("/");
|
|
63
|
+
if (pattern.length <= target.length) return false;
|
|
64
|
+
return target.every((segment, i) => i === 0 || pattern[i] === "**" || new Bun.Glob(pattern[i]!.toLowerCase()).match(segment.toLowerCase()));
|
|
29
65
|
}
|
|
30
66
|
|
|
31
67
|
export function isAppdata(path: string, home: string, glob = false): boolean {
|
|
32
68
|
if (glob) {
|
|
33
69
|
const expanded = Bun.$.braces(path);
|
|
34
70
|
if (expanded.length > 1) return expanded.some((each) => isAppdata(each, home, true));
|
|
71
|
+
if (appdataTrees.some((tree) => globReaches(path, `${home}/Library/${tree}`))) return true;
|
|
35
72
|
}
|
|
36
73
|
const library = `${home}/Library/`.toLowerCase();
|
|
37
74
|
if (!path.toLowerCase().startsWith(library)) return false;
|
|
38
75
|
const rest = path.slice(library.length).toLowerCase();
|
|
39
76
|
if (appdataTrees.some((tree) => rest === tree.toLowerCase() || rest.startsWith(`${tree.toLowerCase()}/`))) return true;
|
|
40
77
|
if (!glob) return false;
|
|
41
|
-
const fixed = rest.split(/[*?[]/)[0]
|
|
78
|
+
const fixed = rest.split(/[*?[]/)[0]!.replace(/\/$/, "");
|
|
42
79
|
return fixed !== "" && appdataTrees.some((tree) => tree.toLowerCase().startsWith(fixed));
|
|
43
80
|
}
|
|
44
81
|
|
|
@@ -54,12 +91,12 @@ export function isBroad(path: string, home: string, glob = false): boolean {
|
|
|
54
91
|
if (trimmed === home || trimmed === `${home}/library` || home.startsWith(`${trimmed}/`)) return true;
|
|
55
92
|
if (!glob) return false;
|
|
56
93
|
const pattern = new Bun.Glob(path);
|
|
57
|
-
if ([home, `${home}/library`, ...appdataTrees.flatMap((tree) => [`${home}/library/${tree.toLowerCase()}`, `${home}/library/${tree.toLowerCase()}/x`])].some((candidate) => pattern.match(candidate)))
|
|
58
|
-
|
|
94
|
+
if ([home, `${home}/library`, ...appdataTrees.flatMap((tree) => [`${home}/library/${tree.toLowerCase()}`, `${home}/library/${tree.toLowerCase()}/x`])].some((candidate) => pattern.match(candidate)))
|
|
95
|
+
return true;
|
|
96
|
+
const prefix = path.split(/[*?[]/, 1)[0]!.replace(/\/$/, "");
|
|
59
97
|
return path.includes("**") && (prefix === home || prefix === `${home}/library` || home.startsWith(`${prefix}/`));
|
|
60
98
|
}
|
|
61
99
|
|
|
62
|
-
// ~/.ignore keeps rg and fd out of ~/Library only when they start above it.
|
|
63
100
|
export function isLibrary(path: string, home: string): boolean {
|
|
64
101
|
return path.toLowerCase() === `${home}/library`.toLowerCase() || isAppdata(path, home);
|
|
65
102
|
}
|
|
@@ -77,20 +114,103 @@ export function sshPrivate(path: string): boolean {
|
|
|
77
114
|
|
|
78
115
|
// A credential-bearing absolute path, after brace expansion. For an unquoted
|
|
79
116
|
// glob, a pattern counts when it could match a listed name; a bare wildcard
|
|
80
|
-
// does not.
|
|
117
|
+
// does not, unless it sits in a credential directory. Names match regardless of
|
|
118
|
+
// case because the default APFS volume ignores it.
|
|
81
119
|
export function isSensitive(path: string, glob = false): boolean {
|
|
82
120
|
const expanded = Bun.$.braces(path);
|
|
83
121
|
if (expanded.length > 1) return expanded.some((each) => isSensitive(each, glob));
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
122
|
+
const lower = path.toLowerCase();
|
|
123
|
+
if ([".env.example", ".env.age"].includes(basename(lower))) return false;
|
|
124
|
+
if (sshPrivate(path) || sensitiveGlobs.some((listed) => listed.match(lower))) return true;
|
|
125
|
+
if (!glob) return false;
|
|
126
|
+
if (globDirectory(path)) return true;
|
|
127
|
+
const base = basename(lower);
|
|
128
|
+
// A bare wildcard reaches every file in the directory, so a directory that holds a listed file counts.
|
|
129
|
+
if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower))) || listedDirectories.some((dir) => dirname(lower).endsWith(`/${dir}`));
|
|
130
|
+
// A listed file inside a named directory needs the glob's directory to match too.
|
|
131
|
+
const segments = lower.split("/");
|
|
88
132
|
return sensitivePaths.some((listed) => {
|
|
89
|
-
const
|
|
90
|
-
|
|
133
|
+
const tail = listed.replace(/^\*\*\//, "").split("/");
|
|
134
|
+
if (tail.length > segments.length) return false;
|
|
135
|
+
if (/^\**$/.test(tail.at(-1)!)) return false;
|
|
136
|
+
const names = tail.map((part) => part.replaceAll("*", "x"));
|
|
137
|
+
const offset = segments.length - tail.length;
|
|
138
|
+
if (!names.slice(0, -1).every((name, i) => new Bun.Glob(segments[offset + i]!).match(name))) return false;
|
|
139
|
+
// A named directory already narrows the match, so a glob that could reach any listed name in it counts.
|
|
140
|
+
return tail.length > 1 ? globsIntersect(segments.at(-1)!, tail.at(-1)!) : new Bun.Glob(segments.at(-1)!).match(names.at(-1)!);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type GlobToken = "*" | ((char: string) => boolean);
|
|
145
|
+
|
|
146
|
+
// Reads `*`, `?`, `[set]` and literal characters; a backslash escapes the next one.
|
|
147
|
+
function globTokens(glob: string): GlobToken[] {
|
|
148
|
+
const tokens: GlobToken[] = [];
|
|
149
|
+
for (let i = 0; i < glob.length; i++) {
|
|
150
|
+
const char = glob[i]!;
|
|
151
|
+
const close = char === "[" ? glob.indexOf("]", i + 2) : -1;
|
|
152
|
+
if (char === "*") tokens.push("*");
|
|
153
|
+
else if (char === "?") tokens.push(() => true);
|
|
154
|
+
else if (close > 0) {
|
|
155
|
+
const matcher = new Bun.Glob(glob.slice(i, close + 1));
|
|
156
|
+
tokens.push((c) => matcher.match(c));
|
|
157
|
+
i = close;
|
|
158
|
+
} else {
|
|
159
|
+
const literal = char === "\\" ? (glob[++i] ?? char) : char;
|
|
160
|
+
tokens.push((c) => c === literal);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return tokens;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Whether some file name matches both wildcard patterns.
|
|
167
|
+
function globsIntersect(a: string, b: string): boolean {
|
|
168
|
+
const [left, right] = [globTokens(a), globTokens(b)];
|
|
169
|
+
const probes = Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i));
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
const walk = (i: number, j: number): boolean => {
|
|
172
|
+
const key = `${i},${j}`;
|
|
173
|
+
if (seen.has(key)) return false;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
const [x, y] = [left[i], right[j]];
|
|
176
|
+
if (x === undefined && y === undefined) return true;
|
|
177
|
+
if (x === "*" && walk(i + 1, j)) return true;
|
|
178
|
+
if (y === "*" && walk(i, j + 1)) return true;
|
|
179
|
+
if (x === "*" && y === "*") return false;
|
|
180
|
+
if (x === "*") return y !== undefined && walk(i, j + 1);
|
|
181
|
+
if (y === "*") return x !== undefined && walk(i + 1, j);
|
|
182
|
+
if (x === undefined || y === undefined) return false;
|
|
183
|
+
return probes.some((c) => x(c) && y(c)) && walk(i + 1, j + 1);
|
|
184
|
+
};
|
|
185
|
+
return walk(0, 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A wildcard directory segment such as `.s*` may expand to a credential
|
|
189
|
+
// directory. The shell's wildcards skip a leading dot, so only a segment that
|
|
190
|
+
// starts with one counts.
|
|
191
|
+
function globDirectory(path: string): boolean {
|
|
192
|
+
const segments = path.split("/");
|
|
193
|
+
return segments.slice(0, -1).some((segment, i) => {
|
|
194
|
+
if (!segment.startsWith(".") || !/[*?[]/.test(segment)) return false;
|
|
195
|
+
const pattern = new Bun.Glob(segment.toLowerCase());
|
|
196
|
+
return credentialDirectories.some((dir) => pattern.match(dir) && isSensitive([...segments.slice(0, i), dir, ...segments.slice(i + 1)].join("/"), true));
|
|
91
197
|
});
|
|
92
198
|
}
|
|
93
199
|
|
|
200
|
+
// A search reads everything under its root, so a credential directory counts as
|
|
201
|
+
// its listed files do.
|
|
202
|
+
export function isSensitiveRoot(path: string, home: string): boolean {
|
|
203
|
+
const lower = path.toLowerCase();
|
|
204
|
+
// A search from the home directory's .config reaches .config/gh because gh is not hidden.
|
|
205
|
+
const ancestors = listedDirectories.flatMap((dir) =>
|
|
206
|
+
dir
|
|
207
|
+
.split("/")
|
|
208
|
+
.slice(0, -1)
|
|
209
|
+
.map((_, i, parts) => `${home.toLowerCase()}/${parts.slice(0, i + 1).join("/")}`),
|
|
210
|
+
);
|
|
211
|
+
return isSensitive(path) || credentialRoots.includes(basename(lower)) || listedDirectories.some((dir) => lower.endsWith(`/${dir}`)) || ancestors.includes(lower);
|
|
212
|
+
}
|
|
213
|
+
|
|
94
214
|
// A path that does not exist or cannot be searched has no inode to compare.
|
|
95
215
|
function stat(path: string) {
|
|
96
216
|
try {
|
|
@@ -131,12 +251,12 @@ export function sshScopeDenied(target: string, home: string, search: boolean): b
|
|
|
131
251
|
for (const root of roots) {
|
|
132
252
|
if (sameFile(candidate, root)) return true;
|
|
133
253
|
if (search) {
|
|
134
|
-
for (let parent = root; parent !== "/";
|
|
254
|
+
for (let parent = root; parent !== "/";) {
|
|
135
255
|
parent = dirname(parent);
|
|
136
256
|
if (sameFile(candidate, parent)) return true;
|
|
137
257
|
}
|
|
138
258
|
}
|
|
139
|
-
for (let parent = candidate; parent !== "/";
|
|
259
|
+
for (let parent = candidate; parent !== "/";) {
|
|
140
260
|
parent = dirname(parent);
|
|
141
261
|
if (!sameFile(parent, root)) continue;
|
|
142
262
|
if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
|
package/src/pipeline.ts
CHANGED
|
@@ -1,26 +1,138 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import type { Command } from "./record.ts";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { stdinKind } from "./argv";
|
|
4
|
+
import type { Command, Word } from "./record";
|
|
5
|
+
|
|
6
|
+
// fd and ls skip hidden files unless asked; find never does.
|
|
7
|
+
export function showsHidden(words: Word[]): boolean {
|
|
8
|
+
return words.some((word) => /^(--hidden|--unrestricted)$/.test(word.text) || /^-[A-Za-z]*[Hu][A-Za-z]*$/.test(word.text));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A dotfile name, or a glob the shell expands to dotfiles; `.` and `..` are directories.
|
|
12
|
+
const hiddenName = (text: string) => /^\.(?!\.?$)/.test(basename(text));
|
|
13
|
+
|
|
14
|
+
// xargs passes the names a walk printed to its command, so mark that command.
|
|
15
|
+
export function markWalkedInput(left: Command[], right: Command[]) {
|
|
16
|
+
const walks = left.some((cmd) => {
|
|
17
|
+
const name = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
|
|
18
|
+
const args = cmd.argv.slice(cmd.program + 1);
|
|
19
|
+
const operands = args.filter((word) => !word.text.startsWith("-"));
|
|
20
|
+
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));
|
|
21
|
+
if (name === "echo" || name === "printf") return operands.some((word) => word.globs && hiddenName(word.text));
|
|
22
|
+
return name === "find" || (name === "fd" && showsHidden(args));
|
|
23
|
+
});
|
|
24
|
+
if (walks) for (const cmd of right) if (cmd.wrappers.includes("xargs")) cmd.flags.add("walked");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 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.
|
|
28
|
+
// A printf format takes up to three octal digits after the backslash; %b and echo take a leading 0 as well.
|
|
29
|
+
const escapes: Record<string, string> = { n: "\n", t: "\t", "\\": "\\" };
|
|
30
|
+
const unescape = (text: string, operand = false) =>
|
|
31
|
+
text
|
|
32
|
+
.split("\\c", 1)[0]!
|
|
33
|
+
.replace(
|
|
34
|
+
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"),
|
|
35
|
+
(_, hex?: string, octal?: string, named?: string, unicode?: string, wide?: string) => {
|
|
36
|
+
const hexadecimal = hex ?? unicode ?? wide;
|
|
37
|
+
const code = hexadecimal !== undefined ? parseInt(hexadecimal, 16) : octal !== undefined ? parseInt(octal, 8) : undefined;
|
|
38
|
+
return code === undefined ? escapes[named!]! : code === 0 ? "\n" : String.fromCharCode(code);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// What printf writes for a format built from literal text, %s, %b and %%; another conversion is unknown.
|
|
43
|
+
function printfOutput(args: Word[]): string | undefined {
|
|
44
|
+
const [format, ...operands] = (args[0]?.text === "--" ? args.slice(1) : args).map((word) => word.text);
|
|
45
|
+
if (format === undefined) return "";
|
|
46
|
+
const pieces = format.split(/(%[sb%])/);
|
|
47
|
+
if (pieces.some((piece) => piece.includes("%") && !/^%[sb%]$/.test(piece))) return undefined;
|
|
48
|
+
const converts = pieces.some((piece) => /^%[sb]$/.test(piece));
|
|
49
|
+
let out = "";
|
|
50
|
+
do {
|
|
51
|
+
for (const piece of pieces) {
|
|
52
|
+
if (piece === "%%") out += "%";
|
|
53
|
+
else if (piece === "%s") out += operands.shift() ?? "";
|
|
54
|
+
else if (piece === "%b") out += unescape(operands.shift() ?? "", true);
|
|
55
|
+
else out += unescape(piece);
|
|
56
|
+
}
|
|
57
|
+
} while (converts && operands.length);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A literal echo or printf value piped into a shell is the command line it runs.
|
|
62
|
+
export function shellInput(left: Command[], right: Command[]): { source: string; cwd: string }[] {
|
|
63
|
+
const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
|
|
64
|
+
const shell = right.find((cmd) => cmd.program >= 0 && stdinKind(cmd) === "shell");
|
|
65
|
+
if (!producer || !shell) return [];
|
|
66
|
+
const args = producer.argv.slice(producer.program + 1);
|
|
67
|
+
const source =
|
|
68
|
+
basename(producer.argv[producer.program]!.text) === "printf"
|
|
69
|
+
? printfOutput(args)
|
|
70
|
+
: unescape(
|
|
71
|
+
args
|
|
72
|
+
.filter((word) => !word.text.startsWith("-"))
|
|
73
|
+
.map((word) => word.text)
|
|
74
|
+
.join(" "),
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
return source === undefined ? [] : [{ source, cwd: shell.cwd }];
|
|
78
|
+
}
|
|
11
79
|
|
|
80
|
+
// xargs runs its command once with every item, or once per item with -I.
|
|
81
|
+
function xargsCommands(xargs: Command, items: string[]): { source: string; cwd: string }[] {
|
|
12
82
|
const options = xargs.argv.slice(0, xargs.program);
|
|
13
83
|
let marker = "";
|
|
14
84
|
for (let i = 0; i < options.length; i++) {
|
|
15
|
-
const option = options[i]
|
|
85
|
+
const option = options[i]!.text;
|
|
16
86
|
if (option === "-I" || option === "--replace") marker = options[i + 1]?.text ?? "";
|
|
17
87
|
else if (option.startsWith("-I")) marker = option.slice(2);
|
|
18
88
|
else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
|
|
19
89
|
}
|
|
20
|
-
|
|
90
|
+
// xargs strips quotes and backslashes from what it reads, with or without -I.
|
|
91
|
+
const stripped = (value: string) => value.replace(/["'\\]/g, "");
|
|
21
92
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
93
|
+
const command = xargs.argv.slice(xargs.program);
|
|
94
|
+
// Without -I, xargs splits its input on blanks, so a printed line can become several arguments.
|
|
95
|
+
if (!marker)
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
source: [
|
|
99
|
+
...command.map((word) => word.raw),
|
|
100
|
+
...items
|
|
101
|
+
.flatMap((item) => item.split(/\s+/))
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.flatMap((item) => [item, stripped(item)])
|
|
104
|
+
.map(quote),
|
|
105
|
+
].join(" "),
|
|
106
|
+
cwd: xargs.cwd,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
return items
|
|
110
|
+
.flatMap((line) => [line, stripped(line)])
|
|
111
|
+
.map((item) => ({ source: command.map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, item)) : word.raw)).join(" "), cwd: xargs.cwd }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Literal printf and echo values are known before xargs passes them to a command.
|
|
115
|
+
export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
|
|
116
|
+
const producer = left.find((cmd) => cmd.program >= 0 && ["printf", "echo"].includes(basename(cmd.argv[cmd.program]!.text)));
|
|
117
|
+
const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
|
|
118
|
+
if (!producer || !xargs) return [];
|
|
119
|
+
const args = producer.argv.slice(producer.program + 1);
|
|
120
|
+
if (basename(producer.argv[producer.program]!.text) !== "printf")
|
|
121
|
+
return xargsCommands(
|
|
122
|
+
xargs,
|
|
123
|
+
args.filter((word) => !word.text.startsWith("-")).map((word) => unescape(word.text, true)),
|
|
124
|
+
);
|
|
125
|
+
const output = printfOutput(args);
|
|
126
|
+
return output === undefined ? [] : xargsCommands(xargs, output.split("\n").filter(Boolean));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A here-string or here-document body is the item list xargs reads.
|
|
130
|
+
export function xargsHereInput(xargs: Command, body: string): { source: string; cwd: string }[] {
|
|
131
|
+
return xargsCommands(
|
|
132
|
+
xargs,
|
|
133
|
+
body
|
|
134
|
+
.split("\n")
|
|
135
|
+
.flatMap((line) => line.split(/\s+/))
|
|
136
|
+
.filter(Boolean),
|
|
137
|
+
);
|
|
26
138
|
}
|
package/src/reasons.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
export const reasons = {
|
|
2
|
-
|
|
2
|
+
syntax: "The agent guard cannot inspect this shell syntax. Rewrite it as a Bash-compatible command with explicit paths, or run a narrower command that the guard can inspect.",
|
|
3
|
+
appdata:
|
|
4
|
+
"This reads a protected macOS app-data directory. Name a specific non-sensitive file under ~/Library/Application Support instead, or ask the user to inspect the protected file and share the needed fact.",
|
|
3
5
|
broad: "A scan rooted at the home directory or ~/Library reaches every app-data entry. Scope the scan to a project path.",
|
|
4
6
|
file: "This reads a credential or environment file. Read a non-sensitive config file instead; if a fact from this file is needed, ask the user to inspect it and share only that fact.",
|
|
7
|
+
hiddenSearch: "A recursive search that includes hidden files can read credentials. Use default rg on a project path, or search an exact non-sensitive file without recursive or hidden-file flags.",
|
|
5
8
|
dump: "This dumps environment or shell variables, including secrets. Name the non-sensitive variable needed and read only that variable.",
|
|
6
9
|
variable: "This prints the value of a credential variable. Ask the user for the specific non-sensitive fact needed, or let the authorized client consume the credential without printing it.",
|
|
7
10
|
token: "This prints a Git hosting token. Use auth status without token-display flags; if authentication needs repair, ask the user to update the credential in their terminal.",
|
|
8
11
|
keychain: "This extracts a password from the macOS Keychain. State the intended use and run the authorized client that consumes it without printing it.",
|
|
12
|
+
secretPrint:
|
|
13
|
+
"This prints a stored secret or access token. Run the command that uses the credential without printing it, or ask the user to run it in their own terminal and share only the non-secret fact needed.",
|
|
9
14
|
trace: "curl verbose or trace output can print HTTP headers including Authorization. Drop -v and --trace; use a normal curl request for the needed result.",
|
|
10
15
|
upload: "This sends the contents of a credential file. Send only the required non-sensitive fields explicitly, and let the client obtain authentication from its normal credential source.",
|
|
11
16
|
ssh: "This reads private material under ~/.ssh. Search public material in the project or request the exact public key or client-config path; ask the user to inspect private material locally if a specific non-sensitive fact is needed.",
|
|
12
17
|
grepSsh: "Grep would search private ~/.ssh material. Narrow the search to a project directory or an exact public key, client config, allowed_signers, or known_hosts file.",
|
|
13
18
|
symlink: "The agent guard could not complete its symlink check. Name the direct non-sensitive file outside protected trees, or ask the user to inspect the target locally.",
|
|
14
|
-
find: "find is harder to scope for this repository's searches. Use fd with a project-root path instead.",
|
|
15
19
|
replace: "rg -r means --replace. Drop -r; use -n for line numbers, or spell --replace VALUE for an intentional replacement.",
|
|
16
20
|
include: "rg has no --include flag. Filter files with -g GLOB (for example -g '*.ts') or a type filter such as -t ts.",
|
|
17
21
|
bre: "rg regex is not grep BRE: a\\|b matches a literal pipe. Write alternation as a|b; for a literal pipe, use [|] or -F.",
|
|
18
|
-
launcher: "An explicit path or wrapper bypasses the provider-aware Claude launcher. Run Claude as `claude ...` to use the persisted mode.",
|
|
19
22
|
} as const;
|
package/src/record.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A search option's value keeps the option's word as option:ROLE.
|
|
2
2
|
export type ValueRole = "pattern" | "patfile" | "glob" | "nglob" | "optarg";
|
|
3
3
|
export type Role = "arg" | "assign" | "precommand" | "namespace" | "program" | "code" | "option" | "path" | ValueRole | `option:${ValueRole}`;
|
|
4
|
-
export type Flag = "explicit" | "files" | "help" | "
|
|
4
|
+
export type Flag = "explicit" | "files" | "help" | "fixed" | "recursive" | "include" | "replace" | "hidden" | "walked";
|
|
5
5
|
|
|
6
6
|
export interface Word {
|
|
7
7
|
text: string; // quotes removed; ~, $HOME and ${HOME} expanded
|
|
@@ -11,11 +11,13 @@ export interface Word {
|
|
|
11
11
|
vars: string[]; // parameter names the shell expands
|
|
12
12
|
role: Role; // set by argv.ts
|
|
13
13
|
value: string; // an option's value without its flag, set by argv.ts
|
|
14
|
+
pwd: boolean; // holds $PWD, $(pwd) or ~+, expanded to the directory the command was read in
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface Redirect {
|
|
17
18
|
direction: "in" | "out" | "herestring" | "heredoc";
|
|
18
19
|
target: string; // file, word, or heredoc body
|
|
20
|
+
globs: boolean; // the target holds an unquoted glob character
|
|
19
21
|
vars: string[]; // parameters the shell expands in the target or body
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -32,6 +34,7 @@ export interface Command {
|
|
|
32
34
|
export interface Script {
|
|
33
35
|
commands: Command[];
|
|
34
36
|
uninspectable: string[];
|
|
37
|
+
parseFailed: boolean;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export type Runtime = "claude" | "codex" | "pi";
|
|
@@ -42,10 +45,13 @@ export interface Request {
|
|
|
42
45
|
tool: Tool;
|
|
43
46
|
home: string;
|
|
44
47
|
cwd: string;
|
|
48
|
+
inputCwd: string;
|
|
49
|
+
pathInput: string;
|
|
45
50
|
operation: "" | "read" | "write" | "search";
|
|
46
51
|
target: string;
|
|
47
52
|
searchRoot: string;
|
|
48
53
|
glob: string;
|
|
49
54
|
commands: Command[];
|
|
50
55
|
uninspectable: string[];
|
|
56
|
+
parseFailed: boolean;
|
|
51
57
|
}
|
package/src/rules/appdata.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// App Data rules. macOS records a Files & Folders App Data entry whenever a
|
|
2
2
|
// process reads or enumerates another app's ~/Library data tree, so these deny
|
|
3
3
|
// those reads and the broad walks that reach them.
|
|
4
|
-
import {
|
|
5
|
-
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths
|
|
6
|
-
import { reasons } from "../reasons
|
|
7
|
-
import type { Command, Request } from "../record
|
|
4
|
+
import { programName } from "../argv";
|
|
5
|
+
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
|
|
6
|
+
import { reasons } from "../reasons";
|
|
7
|
+
import type { Command, Request } from "../record";
|
|
8
8
|
|
|
9
9
|
const { appdata: appdataReason, broad: broadReason } = reasons;
|
|
10
10
|
|
|
@@ -26,31 +26,34 @@ export function appdataRules(req: Request): string[] {
|
|
|
26
26
|
if (reason) denials.push(reason);
|
|
27
27
|
}
|
|
28
28
|
const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
-
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})
|
|
29
|
+
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
|
|
30
30
|
for (const fragment of req.uninspectable) if (signature.test(fragment)) denials.push(appdataReason);
|
|
31
31
|
return denials;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
35
35
|
const cwd = cmd.cwd;
|
|
36
|
-
const prog = cmd.program >= 0 ?
|
|
36
|
+
const prog = cmd.program >= 0 ? programName(cmd.argv[cmd.program]!.text) : "";
|
|
37
37
|
const data = dataPrograms.has(prog);
|
|
38
38
|
// cd reads its target but walks nothing.
|
|
39
|
-
const recursive = cmd.argv.slice(cmd.program + 1).some((word) =>
|
|
40
|
-
(prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
39
|
+
const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
41
40
|
const gitConfig = prog === "git" && cmd.argv.slice(cmd.program + 1).some((word) => word.text === "config");
|
|
42
41
|
const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
|
|
43
42
|
const paths: { path: string; glob: boolean }[] = [];
|
|
44
43
|
for (const [i, word] of cmd.argv.entries()) {
|
|
45
|
-
if (!word.value || ["pattern", "code", "option:pattern"
|
|
44
|
+
if (!word.value || ["pattern", "code", "option:pattern"].includes(word.role)) continue;
|
|
46
45
|
if (word.role === "program" && !word.value.includes("/")) continue;
|
|
47
46
|
if (data && !word.globs && i > cmd.program) continue;
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// A value glued to its option, as in --env-file=PATH, is a path too.
|
|
48
|
+
const values = word.value.startsWith("-") && word.value.includes("=") ? [word.value, word.value.slice(word.value.indexOf("=") + 1)] : [word.value];
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
// An expansion the front end cannot resolve may well be $HOME.
|
|
51
|
+
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(value)) return appdataReason;
|
|
52
|
+
paths.push({ path: absPath(value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
|
|
53
|
+
}
|
|
51
54
|
}
|
|
52
55
|
for (const r of cmd.redirects) {
|
|
53
|
-
if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob:
|
|
56
|
+
if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob: r.globs });
|
|
54
57
|
}
|
|
55
58
|
if (paths.some(({ path, glob }) => isAppdata(path, home, glob))) return appdataReason;
|
|
56
59
|
// Shell glob expansion touches directories even when the command does not walk them.
|
|
@@ -60,23 +63,15 @@ function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
|
60
63
|
// Path operands scope a walk away from the current directory; a search
|
|
61
64
|
// tool's pattern is not one of them.
|
|
62
65
|
let scoped = false;
|
|
63
|
-
let noignore = cmd.flags.has("noignore");
|
|
64
66
|
for (const word of cmd.argv.slice(cmd.program + 1)) {
|
|
65
67
|
const text = word.text;
|
|
66
68
|
if ((word.role === "arg" || word.role === "path") && !text.startsWith("-")) scoped = true;
|
|
67
|
-
if (/^--(unrestricted|no-ignore)/.test(text)) noignore = true;
|
|
68
|
-
if (prog === "fd" && /^-[^-]*[uI]/.test(text)) noignore = true;
|
|
69
|
-
// A positive glob reaching Library overrides the ignore filter.
|
|
70
|
-
if (word.role === "glob" || word.role === "option:glob") {
|
|
71
|
-
const glob = new Bun.Glob(word.value.toLowerCase());
|
|
72
|
-
if (["library", ...appdataTrees.map((tree) => `library/${tree.toLowerCase()}/x`)].some((path) => glob.match(path))) noignore = true;
|
|
73
|
-
}
|
|
74
69
|
}
|
|
75
70
|
if (prog === "ls" && !scoped && isAppdata(cwd, home)) return appdataReason;
|
|
76
71
|
// rg and fd also read ignore files from cwd when given a path operand.
|
|
77
72
|
let denied = false;
|
|
78
|
-
if (
|
|
73
|
+
if (["find", "du", "tree"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isAppdata(cwd, home));
|
|
79
74
|
else if (prog === "ls" || prog === "grep") denied = !scoped && recursive && isBroad(cwd, home);
|
|
80
|
-
else if (
|
|
75
|
+
else if (["rg", "fd", "ag", "ack"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isLibrary(cwd, home));
|
|
81
76
|
return denied ? broadReason : undefined;
|
|
82
77
|
}
|