@loophubs/agent-guard 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loophubs/agent-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Blocks filesystem scans and credential reads that macOS records as TCC App Data access, and points the agent at a safe alternative.",
5
5
  "homepage": "https://github.com/LoopHubs/agent-guard#readme",
6
6
  "bugs": {
package/src/argv.ts CHANGED
@@ -4,10 +4,17 @@ import { expandHome } from "./paths";
4
4
  import type { Command, Word } from "./record";
5
5
  import { searchRoles } from "./search-roles";
6
6
 
7
- const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
7
+ const shellPrograms = new Set(["sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh"]);
8
8
  const codePrograms = new Set(["python", "python3", "node", "bun", "deno", "ruby", "perl", "php", "osascript", "lua"]);
9
9
  const zshBuiltins = new Set(["echo", "printf", "print", "export", "typeset", "declare", "set", "command", "eval", "source", "."]);
10
10
  const shellCodeFlag = /^-[a-z]*c[a-z]*$/;
11
+ const sudoValueOption = /^(-[A-Za-z]*[ughpCDRTrtU]|--(user|group|host|prompt|chdir|chroot|role|type|other-user|close-from|command-timeout))$/;
12
+
13
+ // egrep and fgrep are grep with a fixed matcher.
14
+ export function programName(text: string): string {
15
+ const name = basename(text);
16
+ return name === "egrep" || name === "fgrep" ? "grep" : name;
17
+ }
11
18
 
12
19
  export function resolveCommand(cmd: Command, home: string): { children: string[]; code: string[] } {
13
20
  const w = cmd.argv;
@@ -58,6 +65,31 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
58
65
  if (w[i]?.text === "-n" || w[i]?.text === "--adjustment") i += 2;
59
66
  else if (/^(-n|--adjustment=|-\d)/.test(w[i]?.text ?? "")) i++;
60
67
  break;
68
+ case "sudo":
69
+ case "doas":
70
+ w[i++]!.role = "precommand";
71
+ while (i < w.length && w[i]!.text !== "--" && (w[i]!.text.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]!.text))) {
72
+ if (sudoValueOption.test(w[i]!.text)) w[++i]!.role = "precommand";
73
+ w[i++]!.role = "precommand";
74
+ }
75
+ if (w[i]?.text === "--") w[i++]!.role = "precommand";
76
+ break;
77
+ case "script":
78
+ // script [-adkpqr] [-F pipe] [-t time] [file [command ...]]
79
+ w[i++]!.role = "precommand";
80
+ while (w[i]?.text?.startsWith("-")) {
81
+ if (["-F", "-t"].includes(w[i]!.text)) i++;
82
+ i++;
83
+ }
84
+ if (w[i]) w[i++]!.role = "precommand";
85
+ break;
86
+ case "arch":
87
+ w[i++]!.role = "precommand";
88
+ while (w[i]?.text?.startsWith("-")) {
89
+ if (["-e", "-d", "-arch"].includes(w[i]!.text)) i++;
90
+ i++;
91
+ }
92
+ break;
61
93
  case "stdbuf":
62
94
  w[i++]!.role = "precommand";
63
95
  while (/^-[ioe]/.test(w[i]?.text ?? "")) {
@@ -130,7 +162,7 @@ export function resolveCommand(cmd: Command, home: string): { children: string[]
130
162
  cmd.program = i;
131
163
  w[i]!.role = "program";
132
164
  if (w[i]!.text.startsWith("=") && w[i]!.text.length > 1) w[i]!.text = w[i]!.text.slice(1);
133
- const name = basename(w[i]!.text);
165
+ const name = programName(w[i]!.text);
134
166
  const rest = w.slice(i + 1);
135
167
  if (["rg", "grep", "ag", "ack"].includes(name)) {
136
168
  searchRoles(cmd, rest, name);
package/src/frontend.ts CHANGED
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
- import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
8
+ import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, FuncDecl, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
9
9
  import sh from "mvdan-sh";
10
10
 
11
11
  import { resolveCommand, stdinKind } from "./argv";
@@ -13,7 +13,7 @@ import { misreadComment } from "./comments";
13
13
  import { boundedDirectories } from "./cwd";
14
14
  import { movedOnSuccess } from "./moves";
15
15
  import { expandHome } from "./paths";
16
- import { xargsReplacements } from "./pipeline";
16
+ import { markWalkedInput, shellInput, xargsHereInput, xargsReplacements } from "./pipeline";
17
17
  import type { Command, Redirect, Script, Word } from "./record";
18
18
  import { readWord } from "./words";
19
19
 
@@ -40,6 +40,10 @@ interface Scope {
40
40
 
41
41
  const type = (node: Node) => syntax.NodeType(node);
42
42
 
43
+ // A function call runs the body in the caller's shell; bound the work a chain of
44
+ // calls can multiply.
45
+ const maxFunctionRuns = 256;
46
+
43
47
  export function parseScript(source: string, cwd: string, home: string): Script {
44
48
  const script: Script = { commands: [], uninspectable: [], parseFailed: false };
45
49
 
@@ -60,6 +64,10 @@ export function parseScript(source: string, cwd: string, home: string): Script {
60
64
  for (const stmt of file.Stmts) statement(stmt!, scope);
61
65
  }
62
66
 
67
+ const functions = new Map<string, { body: Stmt; slice: Scope["slice"] }>();
68
+ const running = new Set<string>();
69
+ let functionRuns = 0;
70
+
63
71
  const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
64
72
  // A cd inside runs in a separate shell and leaves this scope's cwd alone.
65
73
  const isolated = (scope: Scope): Scope => ({ ...scope, dir: { ...scope.dir }, vars: new Map(scope.vars) });
@@ -97,16 +105,26 @@ export function parseScript(source: string, cwd: string, home: string): Script {
97
105
  scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
98
106
  return;
99
107
  }
100
- statement(cmd.Y!, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
101
- if (op === "|") for (const item of xargsReplacements(script.commands.slice(start, middle), script.commands.slice(middle))) parse(item.source, item.cwd);
102
- if (op === "||") {
103
- scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), failed.dir.cwd, ...(failed.dir.alternatives ?? [])], home);
108
+ // zsh runs the last element of a pipeline in this shell and bash forks it,
109
+ // so a cd there may or may not move the commands after it.
110
+ const right = op === "||" ? failed : op === "&&" ? scope : isolated(scope);
111
+ statement(cmd.Y!, right);
112
+ if (op === "|" || op === "|&") {
113
+ const left = script.commands.slice(start, middle);
114
+ const rest = script.commands.slice(middle);
115
+ markWalkedInput(left, rest);
116
+ for (const item of [...xargsReplacements(left, rest), ...shellInput(left, rest)]) parse(item.source, item.cwd);
104
117
  }
118
+ if (op !== "&&") scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), right.dir.cwd, ...(right.dir.alternatives ?? [])], home);
105
119
  }
106
120
 
107
121
  // Compound words may hold substitutions; branch bodies may not run.
108
122
  function children(node: Node, scope: Scope) {
109
- if (type(node) === "FuncDecl") scope = isolated(scope);
123
+ if (type(node) === "FuncDecl") {
124
+ const func = node as FuncDecl;
125
+ if (func.Name && func.Body) functions.set(func.Name.Value, { body: func.Body, slice: scope.slice });
126
+ scope = isolated(scope);
127
+ }
110
128
  if (type(node) === "IfClause") return conditional(node as IfClause, scope);
111
129
  const outer = scope;
112
130
  const loop = type(node) === "WhileClause" || type(node) === "ForClause";
@@ -157,7 +175,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
157
175
  }
158
176
 
159
177
  function word(node: WordNode, scope: Scope): Word {
160
- return readWord(node, scope.slice, scope.vars, home, (part, names) => expansions(part, scope, names));
178
+ return readWord(node, scope.slice, scope.vars, home, scope.dir.cwd, (part, names) => expansions(part, scope, names));
161
179
  }
162
180
 
163
181
  function assign(node: Assign, scope: Scope, role: "assign" | "arg"): Word {
@@ -171,7 +189,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
171
189
  return type(child) !== "Word";
172
190
  });
173
191
  return {
174
- ...(value ?? { expands: false, globs: false, vars: [] }),
192
+ ...(value ?? { expands: false, globs: false, vars: [], pwd: false }),
175
193
  text: `${name}=${value?.text ?? ""}`,
176
194
  raw: text(node, scope),
177
195
  role,
@@ -180,7 +198,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
180
198
  }
181
199
 
182
200
  function literal(value: string): Word {
183
- return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
201
+ return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value, pwd: false };
184
202
  }
185
203
 
186
204
  function simple(cmd: Node | null, redirs: (RedirectNode | null)[], scope: Scope) {
@@ -208,7 +226,8 @@ export function parseScript(source: string, cwd: string, home: string): Script {
208
226
  };
209
227
  script.commands.push(command);
210
228
  const { children: sources, code } = resolveCommand(command, home);
211
- for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd });
229
+ const moved = (cwd: string) => command.argv.map((w) => (w.pwd ? { ...w, text: w.text.replaceAll(scope.dir.cwd, cwd), value: w.value.replaceAll(scope.dir.cwd, cwd) } : w));
230
+ for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd, argv: moved(cwd) });
212
231
  for (const src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
213
232
  script.uninspectable.push(...code);
214
233
  for (const r of redirects) {
@@ -216,6 +235,17 @@ export function parseScript(source: string, cwd: string, home: string): Script {
216
235
  const stdin = stdinKind(command);
217
236
  if (stdin === "shell") parse(r.target, command.cwd);
218
237
  if (stdin === "code") script.uninspectable.push(r.target);
238
+ if (command.wrappers.includes("xargs")) for (const item of xargsHereInput(command, r.target)) parse(item.source, item.cwd);
239
+ }
240
+ const called = command.wrappers.every((w) => w === "time") ? command.argv[command.program]?.text : undefined;
241
+ const func = called === undefined ? undefined : functions.get(called);
242
+ if (func && !running.has(called!)) {
243
+ if (++functionRuns > maxFunctionRuns) script.parseFailed = true;
244
+ else {
245
+ running.add(called!);
246
+ statement(func.body, { ...scope, slice: func.slice });
247
+ running.delete(called!);
248
+ }
219
249
  }
220
250
  track(command, scope);
221
251
  }
@@ -226,13 +256,13 @@ export function parseScript(source: string, cwd: string, home: string): Script {
226
256
  const quoted = node.Word!.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
227
257
  const vars: string[] = [];
228
258
  if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
229
- return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
259
+ return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", globs: false, vars };
230
260
  }
231
261
  const target = word(node.Word!, scope);
232
- if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
262
+ if (op === "<<<") return { direction: "herestring", target: target.text, globs: false, vars: target.vars };
233
263
  if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
234
264
  const direction = op === "<" || op === "<>" ? "in" : "out";
235
- return { direction, target: target.text, vars: [] };
265
+ return { direction, target: target.text, globs: target.globs, vars: [] };
236
266
  }
237
267
 
238
268
  function track(command: Command, scope: Scope) {
@@ -245,7 +275,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
245
275
  if (!program || !command.shell || !["cd", "pushd"].includes(program.text)) return;
246
276
  const args = command.argv.slice(command.program + 1);
247
277
  let target = args.find((w) => !w.text.startsWith("-"))?.text;
248
- if (target === undefined && (!args.length || (args.length === 1 && args[0]?.text === "--")) && program.text === "cd") target = home;
278
+ if (target === undefined && args.every((w) => /^(--|-[PLqs]+)$/.test(w.text)) && program.text === "cd") target = home;
249
279
  if (target !== undefined) {
250
280
  scope.dir.alternatives = boundedDirectories(
251
281
  resolve(scope.dir.cwd, target),
package/src/links.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readlinkSync } from "node:fs";
2
2
  import { basename, dirname, resolve } from "node:path";
3
3
 
4
+ import { programName } from "./argv";
4
5
  import { absPath, expandHome, isAppdata, isSensitive } from "./paths";
5
6
  import type { Request, Word } from "./record";
6
7
  import { dataPrograms } from "./rules/appdata";
@@ -45,14 +46,26 @@ export function linkedRequest(req: Request): Request | undefined {
45
46
  const value = linked(w.value, cwd, /^['"]/.test(w.raw));
46
47
  return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
47
48
  };
49
+ // curl reads the file after @ or < in a data or form value, or one glued to -T, -K, --upload-file or --config.
50
+ const curlOperand = /^((?:-[A-Za-z]|--[a-z-]+=|[^=@<-][^=@<]*=)?[@<])([^;]+)(.*)$/s;
51
+ const curlFile = /^(-[TK]|--(?:upload-file|config)=)([^;]+)()$/s;
52
+ const curlWord = (w: Word, cwd: string): Word | undefined => {
53
+ const operand = curlOperand.exec(w.text) ?? curlFile.exec(w.text);
54
+ if (!operand || w.expands) return undefined;
55
+ const value = linked(operand[2]!, cwd);
56
+ return value === operand[2] ? w : { ...w, text: `${operand[1]}${value}${operand[3]}` };
57
+ };
48
58
  const commands = req.commands.map((cmd) => {
59
+ const curl = cmd.program >= 0 && programName(cmd.argv[cmd.program]!.text) === "curl";
49
60
  const checkedCwd = linked(cmd.cwd, "/");
50
61
  const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
51
62
  if (cwd !== cmd.cwd) changed = true;
52
63
  return {
53
64
  ...cmd,
54
65
  cwd,
55
- argv: cmd.argv.map((w, i) => (dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : word(w, cmd.cwd))),
66
+ argv: cmd.argv.map((w, i) =>
67
+ dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : curl && i > cmd.program ? (curlWord(w, cmd.cwd) ?? word(w, cmd.cwd)) : word(w, cmd.cwd),
68
+ ),
56
69
  redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
57
70
  };
58
71
  });
package/src/paths.ts CHANGED
@@ -17,6 +17,14 @@ export const sensitivePaths = [
17
17
  "**/auth.json*",
18
18
  "**/.credentials.json*",
19
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",
20
28
  "**/private-keys-v1.d",
21
29
  "**/private-keys-v1.d/**",
22
30
  ];
@@ -27,6 +35,11 @@ const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
27
35
  // ~/.ssh has its own inode-based check and reason.
28
36
  const credentialRoots = [".aws", ".gnupg"];
29
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
+ });
30
43
 
31
44
  export function expandHome(path: string, home: string): string {
32
45
  for (const prefix of ["~", `~${userInfo().username}`]) {
@@ -112,14 +125,66 @@ export function isSensitive(path: string, glob = false): boolean {
112
125
  if (!glob) return false;
113
126
  if (globDirectory(path)) return true;
114
127
  const base = basename(lower);
115
- if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower)));
116
- const pattern = new Bun.Glob(base);
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("/");
117
132
  return sensitivePaths.some((listed) => {
118
- const name = basename(listed).replaceAll("*", "x");
119
- 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)!);
120
141
  });
121
142
  }
122
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
+
123
188
  // A wildcard directory segment such as `.s*` may expand to a credential
124
189
  // directory. The shell's wildcards skip a leading dot, so only a segment that
125
190
  // starts with one counts.
@@ -134,8 +199,16 @@ function globDirectory(path: string): boolean {
134
199
 
135
200
  // A search reads everything under its root, so a credential directory counts as
136
201
  // its listed files do.
137
- export function isSensitiveRoot(path: string): boolean {
138
- return isSensitive(path) || credentialRoots.includes(basename(path.toLowerCase()));
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);
139
212
  }
140
213
 
141
214
  // A path that does not exist or cannot be searched has no inode to compare.
package/src/pipeline.ts CHANGED
@@ -1,15 +1,84 @@
1
1
  import { basename } from "node:path";
2
2
 
3
- import type { Command } from "./record";
3
+ import { stdinKind } from "./argv";
4
+ import type { Command, Word } from "./record";
4
5
 
5
- // A literal printf value is known before xargs substitutes it into a command.
6
- export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
7
- const printf = left.find((cmd) => cmd.program >= 0 && basename(cmd.argv[cmd.program]!.text) === "printf");
8
- const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
9
- if (!printf || !xargs) return [];
10
- const args = printf.argv.slice(printf.program + 1);
11
- if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0]!.text)) return [];
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
+ }
12
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
+ }
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 }[] {
13
82
  const options = xargs.argv.slice(0, xargs.program);
14
83
  let marker = "";
15
84
  for (let i = 0; i < options.length; i++) {
@@ -18,11 +87,52 @@ export function xargsReplacements(left: Command[], right: Command[]): { source:
18
87
  else if (option.startsWith("-I")) marker = option.slice(2);
19
88
  else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
20
89
  }
21
- 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, "");
22
92
  const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
23
- const source = xargs.argv
24
- .slice(xargs.program)
25
- .map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, args[1]!.text)) : word.raw))
26
- .join(" ");
27
- return [{ source, cwd: xargs.cwd }];
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
+ );
28
138
  }
package/src/reasons.ts CHANGED
@@ -9,6 +9,8 @@ export const reasons = {
9
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.",
10
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.",
11
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.",
12
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.",
13
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.",
14
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.",
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" | "fixed" | "recursive" | "include" | "replace" | "hidden";
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
 
@@ -1,8 +1,7 @@
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
-
4
+ import { programName } from "../argv";
6
5
  import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
7
6
  import { reasons } from "../reasons";
8
7
  import type { Command, Request } from "../record";
@@ -34,7 +33,7 @@ export function appdataRules(req: Request): string[] {
34
33
 
35
34
  function appdataCommand(cmd: Command, home: string): string | undefined {
36
35
  const cwd = cmd.cwd;
37
- const prog = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
36
+ const prog = cmd.program >= 0 ? programName(cmd.argv[cmd.program]!.text) : "";
38
37
  const data = dataPrograms.has(prog);
39
38
  // cd reads its target but walks nothing.
40
39
  const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
@@ -42,15 +41,19 @@ function appdataCommand(cmd: Command, home: string): string | undefined {
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})(/.*)?$`, "is").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.
@@ -4,16 +4,22 @@
4
4
  // ssh -i) are not readers and stay allowed.
5
5
  import { basename } from "node:path";
6
6
 
7
+ import { programName } from "../argv";
7
8
  import { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
9
+ import { showsHidden } from "../pipeline";
8
10
  import { reasons } from "../reasons";
9
11
  import type { Command, Request, Word } from "../record";
10
12
 
11
13
  const readerPrograms = new Set(
12
14
  (
13
15
  "cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
14
- "uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack"
16
+ "uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack tac column pr vim vi nvim view perl ruby dd scp rsync zip ed ex hg svn sh bash zsh dash ksh wget php zgrep zless zmore"
15
17
  ).split(" "),
16
18
  );
19
+ // Programs a find or fd -exec command can run to print or forward what the walk found.
20
+ const execReaders = new Set([...readerPrograms, "rg", "grep", "sh", "bash", "zsh", "dash", "ksh", "env", "sudo", "doas", "xargs", "command", "nohup", "timeout", "nice", "time", "stdbuf", "arch"]);
21
+ // git subcommands that print the content of a file, a commit, or the index.
22
+ const gitReaders = new Set(["show", "diff", "log", "cat-file", "blame", "annotate", "grep", "archive", "format-patch", "whatchanged", "difftool", "diff-index", "diff-tree"]);
17
23
  const {
18
24
  file: fileReason,
19
25
  dump: dumpReason,
@@ -25,6 +31,7 @@ const {
25
31
  ssh: sshReason,
26
32
  grepSsh: grepSshReason,
27
33
  hiddenSearch: hiddenSearchReason,
34
+ secretPrint: secretReason,
28
35
  } = reasons;
29
36
 
30
37
  const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
@@ -34,7 +41,7 @@ export function credentialRules(req: Request): string[] {
34
41
  if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
35
42
  if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
36
43
  if (req.operation === "search") {
37
- if (isSensitiveRoot(req.searchRoot)) denials.push(fileReason);
44
+ if (isSensitiveRoot(req.searchRoot, req.home)) denials.push(fileReason);
38
45
  // A positive glob selects files even past ignore rules.
39
46
  if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
40
47
  }
@@ -52,7 +59,7 @@ export function credentialFilesystemRules(req: Request): string[] {
52
59
  if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target && sshScopeDenied(absPath(redirect.target, cmd.cwd, req.home), req.home, false)) return [sshReason];
53
60
  }
54
61
  if (cmd.program < 0) continue;
55
- const name = basename(cmd.argv[cmd.program]!.text);
62
+ const name = programName(cmd.argv[cmd.program]!.text);
56
63
  const readsPath = ["ls", "find", "tree", "rg", "grep", "ag", "ack", "fd"].includes(name);
57
64
  if (!readsPath && !readerPrograms.has(name)) continue;
58
65
  const words = cmd.argv.slice(cmd.program + 1);
@@ -72,13 +79,13 @@ export function credentialFilesystemRules(req: Request): string[] {
72
79
  function credentialCommand(cmd: Command, home: string): string[] {
73
80
  const denials: string[] = [];
74
81
  const sensitive = (word: string, glob = false, quoted = false) => isSensitive(absPath(word, cmd.cwd, home, quoted), glob);
75
- for (const r of cmd.redirects) if (r.direction === "in" && sensitive(r.target)) denials.push(fileReason);
82
+ for (const r of cmd.redirects) if (r.direction === "in" && sensitive(r.target, r.globs)) denials.push(fileReason);
76
83
  if (cmd.program < 0) {
77
84
  if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
78
85
  return denials;
79
86
  }
80
87
  const program = cmd.argv[cmd.program]!.text;
81
- const name = basename(program);
88
+ const name = programName(program);
82
89
  const words = cmd.argv.slice(cmd.program + 1);
83
90
  const args = words.map((w) => w.text);
84
91
  // Shell variables exist only in the shell, so a path or a wrapper that
@@ -91,6 +98,36 @@ function credentialCommand(cmd: Command, home: string): string[] {
91
98
  for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
92
99
  }
93
100
  }
101
+ // A hidden-file walk piped into xargs hands the reader every dotfile it finds.
102
+ if (cmd.flags.has("walked") && execReaders.has(name)) denials.push(hiddenSearchReason);
103
+ const options = cmd.argv.slice(0, cmd.program);
104
+ if (cmd.wrappers.includes("xargs")) {
105
+ // xargs reads its arguments from the -a file.
106
+ for (const [i, word] of options.entries()) {
107
+ const file = word.text === "-a" || word.text === "--arg-file" ? options[i + 1]?.text : word.text.startsWith("--arg-file=") ? word.text.slice("--arg-file=".length) : undefined;
108
+ if (file && sensitive(file)) denials.push(fileReason);
109
+ }
110
+ }
111
+ // Every operand, and the value of an option glued with `=`, is a file the program may read.
112
+ // A destination is written and a tar -C directory is entered; later operands are read relative to it.
113
+ const operandWords = words.filter((w) => !w.text.startsWith("-"));
114
+ const destination = ["cp", "rsync", "scp"].includes(name) ? operandWords.at(-1) : undefined;
115
+ const enters = (w: Word, i: number) => name === "tar" && (["-C", "--directory"].includes(words[i - 1]?.text ?? "") || w.text.startsWith("--directory="));
116
+ const readTargets = (filter?: Word) => {
117
+ const found: string[] = [];
118
+ let base = cmd.cwd;
119
+ for (const [i, w] of words.entries()) {
120
+ if (w === filter || /^--exclude=/.test(w.text)) continue;
121
+ const target = name === "dd" ? (w.text.startsWith("if=") ? w.text.slice(3) : "") : /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
122
+ if (!target || target.startsWith("-")) continue;
123
+ const quoted = /^['"]/.test(w.raw);
124
+ if (enters(w, i)) base = absPath(target, base, home, quoted);
125
+ // A directory that holds a listed file is read whole by tar, zip, scp and rsync.
126
+ if (sensitive(target, w.globs, quoted) || (!w.globs && w !== destination && !enters(w, i) && isSensitiveRoot(absPath(target, base, home, quoted), home)))
127
+ found.push(name === "scp" || name === "rsync" ? uploadReason : fileReason);
128
+ }
129
+ return found;
130
+ };
94
131
  let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
95
132
  const operands = args.filter((a) => !a.startsWith("-"));
96
133
  switch (name) {
@@ -114,42 +151,111 @@ function credentialCommand(cmd: Command, home: string): string[] {
114
151
  if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
115
152
  }
116
153
  if (cmd.flags.has("files")) break;
154
+ // A search with no path operand reads the working directory.
155
+ if ((name !== "grep" || cmd.flags.has("recursive")) && !words.some((w) => w.role === "path") && isSensitiveRoot(cmd.cwd, home)) denials.push(fileReason);
117
156
  // Search operands, pattern files and positive globs are read targets;
118
157
  // the pattern is not.
119
158
  for (const w of words) {
120
159
  const quoted = /^['"]/.test(w.raw);
121
160
  if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, quoted)) denials.push(fileReason);
122
- if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted))) denials.push(fileReason);
161
+ if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted), home)) denials.push(fileReason);
123
162
  if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
124
163
  }
125
164
  break;
126
165
  case "gh":
127
166
  if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
128
167
  if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
168
+ // gist create and --body-file upload the files they are given.
169
+ denials.push(...readTargets());
129
170
  break;
130
171
  case "glab":
131
172
  if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
132
173
  break;
133
174
  case "security":
134
- if (args.includes("-w") || args.includes("-g")) denials.push(keychainReason);
175
+ if (args.some((a) => /^-[A-Za-z]*[wg]/.test(a) && !a.startsWith("--")) || ["dump-keychain", "export"].includes(args[0] ?? "")) denials.push(keychainReason);
176
+ break;
177
+ // A print command gives the guard no target path, so these name the subcommand. Each is a
178
+ // workaround that stays until the operating system's read restrictions cover the store; do not extend the list.
179
+ case "gcloud":
180
+ if (args.includes("print-access-token") || args.includes("print-identity-token")) denials.push(secretReason);
181
+ break;
182
+ case "az":
183
+ if (args[0] === "account" && args[1] === "get-access-token") denials.push(secretReason);
184
+ break;
185
+ case "aws":
186
+ if (args[0] === "configure" && args[1] === "get" && secretName(args[2] ?? "")) denials.push(secretReason);
187
+ break;
188
+ case "npm":
189
+ if (args[0] === "config" && args[1] === "get" && /auth|token|password/i.test(args[2] ?? "")) denials.push(secretReason);
190
+ break;
191
+ case "kubectl":
192
+ if (args[0] === "config" && args[1] === "view" && args.includes("--raw")) denials.push(secretReason);
193
+ break;
194
+ case "gpg":
195
+ if (args.some((a) => /^--export-secret-(sub)?keys$/.test(a))) denials.push(secretReason);
135
196
  break;
136
197
  case "curl":
137
198
  denials.push(...curlRules(args, sensitive));
138
199
  break;
200
+ case "git": {
201
+ const git = gitCommand(words);
202
+ // A rev:path operand names a file in a commit or the index; pathspecs may be globs.
203
+ if (git.sub === "credential" && git.operands[0]?.text === "fill") denials.push(secretReason);
204
+ for (const w of git.operands) {
205
+ if (w.text.startsWith("-")) continue;
206
+ if (git.sub === "grep" && isSensitiveRoot(absPath(w.text, cmd.cwd, home, /^['"]/.test(w.raw)), home)) denials.push(fileReason);
207
+ if (sensitive(w.text, true) || (w.text.includes(":") && sensitive(w.text.slice(w.text.indexOf(":") + 1), true))) denials.push(fileReason);
208
+ }
209
+ break;
210
+ }
211
+ case "find":
212
+ case "fd":
213
+ if (execPrograms(name, words).some((exec) => execReaders.has(programName(exec))) && (name === "find" || showsHidden(words))) denials.push(hiddenSearchReason);
214
+ break;
139
215
  default:
140
216
  if (!readerPrograms.has(name)) return denials;
141
- const filter = name === "jq" ? jqFilter(words) : undefined;
142
- for (const w of words) {
143
- if (w === filter) continue;
144
- const target = /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
145
- if (target && !target.startsWith("-") && sensitive(target, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
146
- }
217
+ denials.push(...readTargets(name === "jq" ? jqFilter(words) : undefined));
147
218
  }
148
219
  // A heredoc or here-string body is standard input the command prints.
149
220
  if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
150
221
  return denials;
151
222
  }
152
223
 
224
+ // The subcommand and the operands that can name a file; git grep's pattern is not one.
225
+ function gitCommand(words: Word[]): { sub: string; operands: Word[] } {
226
+ let i = 0;
227
+ while (i < words.length && words[i]!.text.startsWith("-")) i += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(words[i]!.text) ? 2 : 1;
228
+ const sub = words[i]?.text ?? "";
229
+ let operands = words.slice(i + 1);
230
+ if (sub === "grep") {
231
+ const rest: Word[] = [];
232
+ let patterned = false;
233
+ let options = true;
234
+ for (let n = 0; n < operands.length; n++) {
235
+ const text = operands[n]!.text;
236
+ if (options && text === "--") options = false;
237
+ else if (options && text === "-e") {
238
+ patterned = true;
239
+ n++;
240
+ } else if (options && text === "-f") {
241
+ // The file holds the patterns; git reads it.
242
+ patterned = true;
243
+ if (operands[n + 1]) rest.push(operands[++n]!);
244
+ } else if (options && text.startsWith("-")) rest.push(operands[n]!);
245
+ else if (!patterned) patterned = true;
246
+ else rest.push(operands[n]!);
247
+ }
248
+ operands = rest;
249
+ }
250
+ return { sub, operands: sub === "credential" || gitReaders.has(sub) ? operands : [] };
251
+ }
252
+
253
+ // The commands a find -exec or an fd -x runs on the matches; find takes several.
254
+ function execPrograms(name: string, words: Word[]): string[] {
255
+ const flags = name === "find" ? ["-exec", "-execdir", "-ok", "-okdir"] : ["-x", "-X", "--exec", "--exec-batch"];
256
+ return words.flatMap((word, i) => (flags.includes(word.text) && words[i + 1] ? [words[i + 1]!.text] : []));
257
+ }
258
+
153
259
  function jqFilter(words: Word[]): Word | undefined {
154
260
  if (words.some((word) => word.text === "-f" || word.text === "--from-file")) return undefined;
155
261
  return words.find((word) => !word.text.startsWith("-"));
package/src/words.ts CHANGED
@@ -6,13 +6,16 @@ import type { Word } from "./record";
6
6
 
7
7
  const type = sh.syntax.NodeType;
8
8
 
9
- export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, visit: (part: Node, names: string[]) => void): Word {
9
+ export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, pwd: string, visit: (part: Node, names: string[]) => void): Word {
10
10
  const text = (part: Node) => slice(part.Pos().Offset(), part.End().Offset());
11
- const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "" };
11
+ const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "", pwd: false };
12
12
  const expansion = (part: Node) => {
13
13
  const param = part as ParamExp;
14
14
  const plain = type(part) === "ParamExp" && !(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
15
- const known = plain ? (param.Param!.Value === "HOME" ? home : vars.get(param.Param!.Value)) : undefined;
15
+ const name = plain ? param.Param!.Value : "";
16
+ const printsPwd = name === "PWD" || (type(part) === "CmdSubst" && /^(\$\(|`)\s*pwd(\s+-[LP])?\s*(\)|`)$/.test(text(part)));
17
+ const known = printsPwd ? pwd : name === "HOME" ? home : plain ? vars.get(name) : undefined;
18
+ out.pwd ||= printsPwd;
16
19
  out.text += known ?? text(part);
17
20
  out.expands ||= known === undefined;
18
21
  visit(part, out.vars);
@@ -21,7 +24,16 @@ export function readWord(node: WordNode, slice: (start: number, end: number) =>
21
24
  const kind = type(part);
22
25
  if (kind === "Lit") {
23
26
  let value = (part as Lit).Value;
24
- if (index === 0) value = expandHome(value, home);
27
+ if (index === 0 && /^~\+(\/|$)/.test(value)) {
28
+ value = pwd + value.slice(2);
29
+ out.pwd = true;
30
+ } else if (index === 0) value = expandHome(value, home);
31
+ // Bun expands {a,b} but not the sequence {a..z}, so read a sequence as a wildcard.
32
+ const sequence = /(?<!\\)\{(?:-?\d+|[A-Za-z])\.\.(?:-?\d+|[A-Za-z])(?:\.\.-?\d+)?\}/g;
33
+ if (sequence.test(value)) {
34
+ value = value.replace(sequence, "*");
35
+ out.globs = true;
36
+ }
25
37
  if (Bun.$.braces(value).length > 1) out.globs = true;
26
38
  for (let i = 0; i < value.length; i++) {
27
39
  if (value[i] === "\\") {