@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/src/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Filesystem checks run after lexical denials to avoid touching protected trees.
2
- import { statSync, realpathSync } from "node:fs";
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", "**/.env.local*", "**/.env.*.local",
11
- "**/.env.production", "**/.env.staging", "**/.env.development",
12
- "**/.npmrc", "**/.zprofile*", "**/.zsh_history*", "**/*.pem", "**/*.key",
13
- "**/auth.json*", "**/.credentials.json*", "**/.aws/credentials*",
14
- "**/private-keys-v1.d", "**/private-keys-v1.d/**",
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].replace(/\/$/, "");
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))) return true;
58
- const prefix = path.split(/[*?[]/, 1)[0].replace(/\/$/, "");
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
- if (sshPrivate(path) || sensitiveGlobs.some((listed) => listed.match(path))) return true;
85
- const base = basename(path);
86
- if (!glob || /^[*?]*$/.test(base)) return false;
87
- const pattern = new Bun.Glob(base);
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 name = basename(listed).replaceAll("*", "x");
90
- return !/^x*$/.test(name) && pattern.match(name);
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
- // A literal printf value is known before xargs substitutes it into a command.
5
- export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
6
- const printf = left.find((cmd) => cmd.program >= 0 && basename(cmd.argv[cmd.program].text) === "printf");
7
- const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
8
- if (!printf || !xargs) return [];
9
- const args = printf.argv.slice(printf.program + 1);
10
- if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0].text)) return [];
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].text;
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
- if (!marker) return [];
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 source = xargs.argv.slice(xargs.program).map((word) =>
23
- word.text.includes(marker) ? quote(word.text.replaceAll(marker, args[1].text)) : word.raw,
24
- ).join(" ");
25
- return [{ source, cwd: xargs.cwd }];
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
- appdata: "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.",
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" | "noignore" | "fixed" | "recursive" | "include" | "replace";
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
  }
@@ -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 { basename } from "node:path";
5
- import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths.ts";
6
- import { reasons } from "../reasons.ts";
7
- import type { Command, Request } from "../record.ts";
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 ? basename(cmd.argv[cmd.program].text) : "";
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", "optarg"].includes(word.role)) continue;
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
- // An expansion the front end cannot resolve may well be $HOME.
49
- if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "s").test(word.value)) return appdataReason;
50
- paths.push({ path: absPath(word.value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
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: false });
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 (prog === "find" || prog === "du") denied = !scoped && (isBroad(cwd, home) || isAppdata(cwd, home));
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 (prog === "rg" || prog === "fd") denied = !scoped && (noignore ? isBroad(cwd, home) : isLibrary(cwd, home));
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
  }