@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.
@@ -3,18 +3,35 @@
3
3
  // Clients that consume a credential file themselves (dotenvx, node --env-file,
4
4
  // ssh -i) are not readers and stay allowed.
5
5
  import { basename } from "node:path";
6
- import { absPath, isSensitive, sshPrivate, sshScopeDenied } from "../paths.ts";
7
- import { reasons } from "../reasons.ts";
8
- import type { Command, Request } from "../record.ts";
6
+
7
+ import { programName } from "../argv";
8
+ import { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
9
+ import { showsHidden } from "../pipeline";
10
+ import { reasons } from "../reasons";
11
+ import type { Command, Request, Word } from "../record";
9
12
 
10
13
  const readerPrograms = new Set(
11
- ("cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
12
- "uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack").split(" "),
14
+ (
15
+ "cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
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"
17
+ ).split(" "),
13
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"]);
14
23
  const {
15
- file: fileReason, dump: dumpReason, variable: varReason, token: tokenReason,
16
- keychain: keychainReason, trace: traceReason, upload: uploadReason,
17
- ssh: sshReason, grepSsh: grepSshReason,
24
+ file: fileReason,
25
+ dump: dumpReason,
26
+ variable: varReason,
27
+ token: tokenReason,
28
+ keychain: keychainReason,
29
+ trace: traceReason,
30
+ upload: uploadReason,
31
+ ssh: sshReason,
32
+ grepSsh: grepSshReason,
33
+ hiddenSearch: hiddenSearchReason,
34
+ secretPrint: secretReason,
18
35
  } = reasons;
19
36
 
20
37
  const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
@@ -24,7 +41,7 @@ export function credentialRules(req: Request): string[] {
24
41
  if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
25
42
  if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
26
43
  if (req.operation === "search") {
27
- if (isSensitive(req.searchRoot)) denials.push(fileReason);
44
+ if (isSensitiveRoot(req.searchRoot, req.home)) denials.push(fileReason);
28
45
  // A positive glob selects files even past ignore rules.
29
46
  if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
30
47
  }
@@ -37,19 +54,38 @@ export function credentialRules(req: Request): string[] {
37
54
  export function credentialFilesystemRules(req: Request): string[] {
38
55
  if ((req.operation === "read" || req.operation === "write") && sshScopeDenied(req.target, req.home, false)) return [sshReason];
39
56
  if (req.operation === "search" && sshScopeDenied(req.searchRoot, req.home, true)) return [grepSshReason];
57
+ for (const cmd of req.commands) {
58
+ for (const redirect of cmd.redirects) {
59
+ if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target && sshScopeDenied(absPath(redirect.target, cmd.cwd, req.home), req.home, false)) return [sshReason];
60
+ }
61
+ if (cmd.program < 0) continue;
62
+ const name = programName(cmd.argv[cmd.program]!.text);
63
+ const readsPath = ["ls", "find", "tree", "rg", "grep", "ag", "ack", "fd"].includes(name);
64
+ if (!readsPath && !readerPrograms.has(name)) continue;
65
+ const words = cmd.argv.slice(cmd.program + 1);
66
+ // A search with no path operand reads the working directory.
67
+ const searches = ["rg", "ag", "ack"].includes(name) || (name === "grep" && cmd.flags.has("recursive"));
68
+ if (searches && !cmd.flags.has("help") && !words.some((word) => word.role === "path") && sshScopeDenied(cmd.cwd, req.home, true)) return [grepSshReason];
69
+ const filter = name === "jq" ? jqFilter(words) : undefined;
70
+ for (const word of words) {
71
+ if (word === filter) continue;
72
+ if (!["arg", "path", "patfile", "option:patfile"].includes(word.role) || word.value.startsWith("-")) continue;
73
+ if (sshScopeDenied(absPath(word.value, cmd.cwd, req.home, /^['"]/.test(word.raw)), req.home, false)) return [sshReason];
74
+ }
75
+ }
40
76
  return [];
41
77
  }
42
78
 
43
79
  function credentialCommand(cmd: Command, home: string): string[] {
44
80
  const denials: string[] = [];
45
81
  const sensitive = (word: string, glob = false, quoted = false) => isSensitive(absPath(word, cmd.cwd, home, quoted), glob);
46
- 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);
47
83
  if (cmd.program < 0) {
48
84
  if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
49
85
  return denials;
50
86
  }
51
- const program = cmd.argv[cmd.program].text;
52
- const name = basename(program);
87
+ const program = cmd.argv[cmd.program]!.text;
88
+ const name = programName(program);
53
89
  const words = cmd.argv.slice(cmd.program + 1);
54
90
  const args = words.map((w) => w.text);
55
91
  // Shell variables exist only in the shell, so a path or a wrapper that
@@ -58,10 +94,40 @@ function credentialCommand(cmd: Command, home: string): string[] {
58
94
  if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
59
95
  if (name === "set" && !args.length) denials.push(dumpReason);
60
96
  if (name === "typeset" || name === "declare") {
61
- if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]))) denials.push(dumpReason);
97
+ if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
62
98
  for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
63
99
  }
64
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
+ };
65
131
  let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
66
132
  const operands = args.filter((a) => !a.startsWith("-"));
67
133
  switch (name) {
@@ -80,51 +146,125 @@ function credentialCommand(cmd: Command, home: string): string[] {
80
146
  case "ag":
81
147
  case "ack":
82
148
  if (cmd.flags.has("help")) break;
149
+ if ((name === "grep" && cmd.flags.has("recursive")) || (["rg", "ag"].includes(name) && cmd.flags.has("hidden"))) denials.push(hiddenSearchReason);
83
150
  for (const w of words) {
84
151
  if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
85
152
  }
86
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);
87
156
  // Search operands, pattern files and positive globs are read targets;
88
157
  // the pattern is not.
89
158
  for (const w of words) {
90
- if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
159
+ const quoted = /^['"]/.test(w.raw);
160
+ if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, quoted)) denials.push(fileReason);
161
+ if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted), home)) denials.push(fileReason);
91
162
  if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
92
163
  }
93
164
  break;
94
165
  case "gh":
95
166
  if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
96
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());
97
170
  break;
98
171
  case "glab":
99
172
  if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
100
173
  break;
101
174
  case "security":
102
- 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);
103
196
  break;
104
197
  case "curl":
105
198
  denials.push(...curlRules(args, sensitive));
106
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;
107
215
  default:
108
216
  if (!readerPrograms.has(name)) return denials;
109
- let jqFilter = name === "jq" && !args.includes("-f") && !args.includes("--from-file");
110
- for (const w of words) {
111
- if (jqFilter && !w.text.startsWith("-")) {
112
- jqFilter = false;
113
- continue;
114
- }
115
- const target = /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
116
- if (target && !target.startsWith("-") && sensitive(target, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
117
- }
217
+ denials.push(...readTargets(name === "jq" ? jqFilter(words) : undefined));
118
218
  }
119
219
  // A heredoc or here-string body is standard input the command prints.
120
220
  if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
121
221
  return denials;
122
222
  }
123
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
+
259
+ function jqFilter(words: Word[]): Word | undefined {
260
+ if (words.some((word) => word.text === "-f" || word.text === "--from-file")) return undefined;
261
+ return words.find((word) => !word.text.startsWith("-"));
262
+ }
263
+
124
264
  // A token-display flag counts even when a later flag cancels it.
125
265
  function ghShowsToken(args: string[]): boolean {
126
266
  for (let i = 0; i < args.length; i++) {
127
- const a = args[i];
267
+ const a = args[i]!;
128
268
  if (a === "--") return false;
129
269
  if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
130
270
  else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
@@ -138,19 +278,20 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
138
278
  const denials: string[] = [];
139
279
  const long = /^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)(=|$)/s;
140
280
  for (let i = 0; i < args.length; i++) {
141
- const word = args[i];
281
+ const word = args[i]!;
142
282
  let key = "";
143
283
  let value = "";
144
284
  if (word === "--") break;
285
+ if (/^file:\/\//i.test(word) && sensitive(word)) denials.push(fileReason);
145
286
  if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) denials.push(traceReason);
146
287
  else if (long.test(word)) {
147
- key = word.slice(2).split("=")[0];
288
+ key = word.slice(2).split("=")[0]!;
148
289
  value = word.includes("=") ? word.slice(word.indexOf("=") + 1) : (args[++i] ?? "");
149
290
  } else if (/^-[^-]/.test(word)) {
150
291
  for (let k = 1; k < word.length; k++) {
151
292
  if (word[k] === "v") denials.push(traceReason);
152
- if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k])) continue;
153
- key = word[k];
293
+ if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k]!)) continue;
294
+ key = word[k]!;
154
295
  value = word.slice(k + 1) || (args[++i] ?? "");
155
296
  break;
156
297
  }
@@ -159,7 +300,10 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
159
300
  if (!/@./s.test(value)) continue;
160
301
  value = value.slice(value.indexOf("@") + 1);
161
302
  } else if (key === "F" || key === "form") {
162
- value = value.slice(value.indexOf("=") + 1).replace(/^[@<]/, "").split(";")[0];
303
+ value = value
304
+ .slice(value.indexOf("=") + 1)
305
+ .replace(/^[@<]/, "")
306
+ .split(";")[0]!;
163
307
  } else if (!["T", "upload-file", "K", "config"].includes(key)) continue;
164
308
  if (sensitive(value)) denials.push(uploadReason);
165
309
  }
@@ -167,8 +311,7 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
167
311
  }
168
312
 
169
313
  const readers = [...readerPrograms].filter((p) => p !== ".").join("|");
170
- const names =
171
- "\\.env(\\.(local|production|staging|development))?|\\.npmrc|\\.zsh_history|\\.zprofile|private-keys-v1\\.d|\\S*\\.(pem|key)|auth\\.json|\\.credentials\\.json|\\.aws/credentials|\\.ssh/\\S*";
314
+ const names = "\\.env(?:\\.[A-Za-z0-9_.-]+)?|\\.npmrc|\\.zsh_history|\\.zprofile|private-keys-v1\\.d|\\S*\\.(pem|key)|auth\\.json|\\.credentials\\.json|\\.aws/credentials|\\.ssh/\\S*";
172
315
  const readerSignature = new RegExp(`(^|[^A-Za-z0-9_])(${readers})([^A-Za-z0-9_-].*)?[\\s/"'=](${names})($|[\\s/"'>|&)\`])`, "s");
173
316
 
174
317
  // Text the front end could not structure: interpreter code, case bodies, or
@@ -183,7 +326,7 @@ function credentialSignatures(fragment: string): string[] {
183
326
  )
184
327
  denials.push(dumpReason);
185
328
  const reader = segment.match(readerSignature);
186
- if (reader && !(reader[4].startsWith(".ssh/") && !sshPrivate(`/${reader[4]}`))) denials.push(fileReason);
329
+ if (reader) denials.push(fileReason);
187
330
  if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
188
331
  if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
189
332
  if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
@@ -1,38 +1,22 @@
1
- // Workflow rules differ by runtime; the safety rules do not.
2
- // Codex: run claude through the provider-aware launcher function, which picks
3
- // the persisted provider mode before gateway credentials are expanded.
4
1
  import { basename } from "node:path";
5
- import { reasons } from "../reasons.ts";
6
- import type { Request } from "../record.ts";
7
2
 
8
- const {
9
- find: findReason, replace: replaceReason, include: includeReason,
10
- bre: breReason, launcher: launcherReason,
11
- } = reasons;
3
+ import { reasons } from "../reasons";
4
+ import type { Request } from "../record";
5
+
6
+ const { replace: replaceReason, include: includeReason, bre: breReason } = reasons;
12
7
 
13
8
  export function claudeWorkflowRules(req: Request): string[] {
14
- const denials: string[] = [];
9
+ const advice: string[] = [];
15
10
  for (const cmd of req.commands) {
16
11
  if (cmd.program < 0) continue;
17
- const name = basename(cmd.argv[cmd.program].text);
18
- if (name === "find") denials.push(findReason);
12
+ const name = basename(cmd.argv[cmd.program]!.text);
19
13
  if (name !== "rg") continue;
20
- if (cmd.flags.has("replace")) denials.push(replaceReason);
21
- if (cmd.flags.has("include")) denials.push(includeReason);
14
+ if (cmd.flags.has("replace")) advice.push(replaceReason);
15
+ if (cmd.flags.has("include")) advice.push(includeReason);
22
16
  if (cmd.flags.has("fixed")) continue;
23
17
  for (const w of cmd.argv.slice(cmd.program + 1)) {
24
- if ((w.role === "pattern" || w.role === "option:pattern") && /(^|[^\\])\\\|/.test(w.value)) denials.push(breReason);
18
+ if ((w.role === "pattern" || w.role === "option:pattern") && /(^|[^\\])\\\|/.test(w.value)) advice.push(breReason);
25
19
  }
26
20
  }
27
- return denials;
28
- }
29
-
30
- export function codexWorkflowRules(req: Request): string[] {
31
- const denials: string[] = [];
32
- for (const cmd of req.commands) {
33
- if (cmd.program < 0) continue;
34
- const program = cmd.argv[cmd.program].text;
35
- if (basename(program) === "claude" && (program.includes("/") || cmd.wrappers.length)) denials.push(launcherReason);
36
- }
37
- return denials;
21
+ return advice;
38
22
  }
package/src/runner.ts ADDED
@@ -0,0 +1,32 @@
1
+ // A separate process group lets the deadline stop children inheriting hook pipes.
2
+ const child = Bun.spawn({
3
+ cmd: [process.execPath, `${import.meta.dir}/guard.ts`, ...process.argv.slice(2)],
4
+ stdin: "inherit",
5
+ stdout: "pipe",
6
+ stderr: "pipe",
7
+ detached: true,
8
+ });
9
+ process.stdout.write(`AGENT_GUARD_PGID=${child.pid}\n`);
10
+ const forward = async (stream: ReadableStream<Uint8Array>) => {
11
+ for await (const chunk of stream) process.stdout.write(chunk);
12
+ };
13
+ const output = Promise.all([forward(child.stdout), forward(child.stderr)]);
14
+
15
+ const stopGroup = () => {
16
+ try {
17
+ process.kill(-child.pid, "SIGKILL");
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
20
+ }
21
+ };
22
+
23
+ let expired = false;
24
+ const deadline = setTimeout(() => {
25
+ expired = true;
26
+ stopGroup();
27
+ }, 2800);
28
+ const exit = await child.exited;
29
+ clearTimeout(deadline);
30
+ stopGroup();
31
+ await output;
32
+ process.exitCode = expired ? 1 : exit;
@@ -1,17 +1,16 @@
1
- import type { Command, ValueRole, Word } from "./record.ts";
1
+ import type { Command, ValueRole, Word } from "./record";
2
2
 
3
3
  const rgShortValues = "efgtTEABCmMjrd";
4
4
  const rgLongValues = new Set(
5
- ("regexp file glob iglob type type-not encoding replace color colors sort sortr max-depth max-filesize pre pre-glob " +
5
+ (
6
+ "regexp file glob iglob type type-not encoding replace color colors sort sortr max-depth max-filesize pre pre-glob " +
6
7
  "engine threads max-columns type-add type-clear path-separator context-separator field-context-separator " +
7
8
  "field-match-separator after-context before-context context max-count ignore-file dfa-size-limit " +
8
- "regex-size-limit hyperlink-format").split(" "),
9
+ "regex-size-limit hyperlink-format"
10
+ ).split(" "),
9
11
  );
10
12
  const grepShortValues = "efABCmdD";
11
- const grepLongValues = new Set(
12
- ("regexp file include exclude exclude-dir exclude-from label context after-context before-context max-count " +
13
- "binary-files devices directories").split(" "),
14
- );
13
+ const grepLongValues = new Set(("regexp file include exclude exclude-dir exclude-from label context after-context before-context max-count " + "binary-files devices directories").split(" "));
15
14
 
16
15
  export function searchRoles(cmd: Command, args: Word[], prog: string) {
17
16
  const rg = prog === "rg";
@@ -20,52 +19,74 @@ export function searchRoles(cmd: Command, args: Word[], prog: string) {
20
19
  const flags = cmd.flags;
21
20
  const operands: Word[] = [];
22
21
  let options = true;
22
+ let unrestricted = 0;
23
+ let noHidden = false;
23
24
 
24
25
  const value = (at: number, key: string, text: string) => {
25
26
  const target = args[at];
26
27
  if (!target) return;
28
+ if (!rg && (key === "d" || key === "directories") && text === "recurse") flags.add("recursive");
27
29
  let role: ValueRole = "optarg";
28
- if (key === "e" || key === "regexp") (role = "pattern"), flags.add("explicit");
29
- else if (key === "f" || key === "file") (role = "patfile"), flags.add("explicit");
30
- else if (["g", "glob", "iglob", "include"].includes(key)) role = text.startsWith("!") ? "nglob" : "glob";
30
+ if (key === "e" || key === "regexp") {
31
+ role = "pattern";
32
+ flags.add("explicit");
33
+ } else if (key === "f" || key === "file") {
34
+ role = "patfile";
35
+ flags.add("explicit");
36
+ } else if (["g", "glob", "iglob", "include"].includes(key)) role = text.startsWith("!") ? "nglob" : "glob";
31
37
  target.role = target.role === "option" ? `option:${role}` : role;
32
38
  target.value = text;
33
39
  };
34
40
 
35
41
  for (let i = 0; i < args.length; i++) {
36
- const word = args[i].text;
42
+ const word = args[i]!.text;
37
43
  if (options && word === "--") {
38
44
  options = false;
39
- args[i].role = "option";
45
+ args[i]!.role = "option";
40
46
  } else if (options && /^--./.test(word)) {
41
- args[i].role = "option";
42
- const key = word.slice(2).split("=")[0];
47
+ args[i]!.role = "option";
48
+ const key = word.slice(2).split("=")[0]!;
43
49
  if (key === "files") flags.add("files");
50
+ else if (rg && key === "hidden") {
51
+ noHidden = false;
52
+ flags.add("hidden");
53
+ } else if (rg && key === "no-hidden") {
54
+ noHidden = true;
55
+ flags.delete("hidden");
56
+ } else if (rg && key === "unrestricted" && ++unrestricted >= 2 && !noHidden) flags.add("hidden");
57
+ else if (prog === "ag" && (key === "hidden" || key === "unrestricted")) flags.add("hidden");
44
58
  else if (key === "help" || key === "version") flags.add("help");
45
- else if (key.startsWith("no-ignore") || key === "unrestricted") flags.add("noignore");
46
59
  else if (key === "fixed-strings") flags.add("fixed");
47
60
  else if (key === "recursive") flags.add("recursive");
48
61
  else if (key === "include") flags.add("include");
49
62
  if (word.includes("=")) value(i, key, word.slice(word.indexOf("=") + 1));
50
- else if (longValues.has(key)) i++, value(i, key, args[i]?.text ?? "");
63
+ else if (longValues.has(key)) {
64
+ i++;
65
+ value(i, key, args[i]?.text ?? "");
66
+ }
51
67
  } else if (options && /^-./.test(word)) {
52
- args[i].role = "option";
68
+ args[i]!.role = "option";
53
69
  for (let k = 1; k < word.length; k++) {
54
- const c = word[k];
70
+ const c = word[k]!;
55
71
  if ((rg || prog === "grep") && c === "F") flags.add("fixed");
56
- if (rg && c === "u") flags.add("noignore");
57
72
  if (prog === "grep" && (c === "r" || c === "R")) flags.add("recursive");
58
73
  if ((rg && c === "h") || c === "V") flags.add("help");
59
74
  if (rg && c === "r") flags.add("replace");
75
+ if (rg && c === ".") flags.add("hidden");
76
+ if (rg && c === "u" && ++unrestricted >= 2 && !noHidden) flags.add("hidden");
77
+ if (prog === "ag" && c === "u") flags.add("hidden");
60
78
  if (!shortValues.includes(c)) continue;
61
79
  let text = word.slice(k + 1);
62
80
  if (rg) text = text.replace(/^=/, "");
63
- if (!text) i++, (text = args[i]?.text ?? "");
81
+ if (!text) {
82
+ i++;
83
+ text = args[i]?.text ?? "";
84
+ }
64
85
  value(i, c, text);
65
86
  break;
66
87
  }
67
88
  } else {
68
- operands.push(args[i]);
89
+ operands.push(args[i]!);
69
90
  }
70
91
  }
71
92
  let explicit = flags.has("explicit") || flags.has("files");
package/src/words.ts CHANGED
@@ -1,24 +1,21 @@
1
- import sh from "mvdan-sh";
2
1
  import type { DblQuoted, Lit, Node, ParamExp, SglQuoted, Word as WordNode } from "mvdan-sh";
3
- import { expandHome } from "./paths.ts";
4
- import type { Word } from "./record.ts";
2
+ import sh from "mvdan-sh";
3
+
4
+ import { expandHome } from "./paths";
5
+ import type { Word } from "./record";
5
6
 
6
7
  const type = sh.syntax.NodeType;
7
8
 
8
- export function readWord(
9
- node: WordNode,
10
- slice: (start: number, end: number) => string,
11
- vars: Map<string, string>,
12
- home: string,
13
- visit: (part: Node, names: string[]) => void,
14
- ): 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 {
15
10
  const text = (part: Node) => slice(part.Pos().Offset(), part.End().Offset());
16
- 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 };
17
12
  const expansion = (part: Node) => {
18
13
  const param = part as ParamExp;
19
- const plain = type(part) === "ParamExp" &&
20
- !(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
21
- const known = plain ? (param.Param.Value === "HOME" ? home : vars.get(param.Param.Value)) : undefined;
14
+ const plain = type(part) === "ParamExp" && !(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
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;
22
19
  out.text += known ?? text(part);
23
20
  out.expands ||= known === undefined;
24
21
  visit(part, out.vars);
@@ -27,20 +24,29 @@ export function readWord(
27
24
  const kind = type(part);
28
25
  if (kind === "Lit") {
29
26
  let value = (part as Lit).Value;
30
- 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
+ }
31
37
  if (Bun.$.braces(value).length > 1) out.globs = true;
32
38
  for (let i = 0; i < value.length; i++) {
33
39
  if (value[i] === "\\") {
34
40
  out.text += value[++i] ?? "";
35
41
  continue;
36
42
  }
37
- if ("*?[".includes(value[i])) out.globs = true;
43
+ if ("*?[".includes(value[i]!)) out.globs = true;
38
44
  out.text += value[i];
39
45
  }
40
46
  } else if (kind === "SglQuoted") {
41
47
  const quoted = part as SglQuoted;
42
48
  out.text += quoted.Dollar
43
- ? quoted.Value.replace(/\\(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{4})|([0-7]{1,3})|(.))/gs, (match, hex, unicode, octal, char) => {
49
+ ? quoted.Value.replace(/\\(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{4})|([0-7]{1,3})|(.))/gs, (_match, hex, unicode, octal, char) => {
44
50
  if (hex || unicode || octal) return String.fromCodePoint(parseInt(hex ?? unicode ?? octal, octal ? 8 : 16));
45
51
  return ({ a: "\x07", b: "\b", e: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" } as Record<string, string>)[char] ?? char;
46
52
  })
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleResolution": "bundler",
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "noImplicitOverride": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "noPropertyAccessFromIndexSignature": true,
17
+ "allowUnreachableCode": false,
18
+ "allowUnusedLabels": false,
19
+ "verbatimModuleSyntax": true,
20
+ "isolatedModules": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "skipLibCheck": false,
23
+ "types": ["node", "bun"]
24
+ },
25
+ "include": ["src", "tests"]
26
+ }