@loophubs/agent-guard 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,77 +1,44 @@
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 { programName } from "../argv";
5
- import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
4
+ import { appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
6
5
  import { reasons } from "../reasons";
7
- import type { Command, Request } from "../record";
6
+ import type { Request, Target } from "../record";
8
7
 
9
8
  const { appdata: appdataReason, broad: broadReason } = reasons;
10
9
 
11
10
  const trees = appdataTrees.join("|");
12
- // These print or assign their arguments; only a glob the shell expands before
13
- // they run reads a directory.
14
- export const dataPrograms = new Set(["echo", "printf", "print", ":", "true", "false", "export", "set", "unset", "typeset", "declare", "local"]);
15
- const noWalkPrograms = new Set(["mv", "stat", "test", "[", "mkdir"]);
16
11
 
17
- export function appdataRules(req: Request): string[] {
18
- const denials: string[] = [];
19
- if ((req.operation === "read" || req.operation === "write") && isAppdata(req.target, req.home)) denials.push(appdataReason);
20
- if (req.operation === "search") {
21
- if (isAppdata(req.searchRoot, req.home)) denials.push(appdataReason);
22
- else if (isLibrary(req.searchRoot, req.home)) denials.push(broadReason);
23
- }
24
- for (const cmd of req.commands) {
25
- const reason = appdataCommand(cmd, req.home);
26
- if (reason) denials.push(reason);
27
- }
28
- const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
- const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
30
- for (const fragment of req.uninspectable) if (signature.test(fragment)) denials.push(appdataReason);
31
- return denials;
12
+ // A name is text, not a file, unless the shell expands it as a glob.
13
+ const touches = (target: Target) => target.effect !== "name" || target.glob;
14
+
15
+ function appdataHit(target: Target, home: string): boolean {
16
+ if (!touches(target) || target.via === "scan") return false;
17
+ // An expansion the front end cannot resolve may well be $HOME.
18
+ return (target.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(target.path)) || isAppdata(target.path, home, target.glob);
32
19
  }
33
20
 
34
- function appdataCommand(cmd: Command, home: string): string | undefined {
35
- const cwd = cmd.cwd;
36
- const prog = cmd.program >= 0 ? programName(cmd.argv[cmd.program]!.text) : "";
37
- const data = dataPrograms.has(prog);
38
- // cd reads its target but walks nothing.
39
- const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
40
- const gitConfig = prog === "git" && cmd.argv.slice(cmd.program + 1).some((word) => word.text === "config");
41
- const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
42
- const paths: { path: string; glob: boolean }[] = [];
43
- for (const [i, word] of cmd.argv.entries()) {
44
- if (!word.value || ["pattern", "code", "option:pattern"].includes(word.role)) continue;
45
- if (word.role === "program" && !word.value.includes("/")) continue;
46
- if (data && !word.globs && i > cmd.program) continue;
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
- }
54
- }
55
- for (const r of cmd.redirects) {
56
- if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob: r.globs });
57
- }
58
- if (paths.some(({ path, glob }) => isAppdata(path, home, glob))) return appdataReason;
21
+ function broadHit(target: Target, home: string): boolean {
22
+ if (target.via === "scan") return isBroad(target.path, home) || isAppdata(target.path, home);
23
+ if (!touches(target)) return false;
24
+ if (target.via === "tool") return target.search && isLibrary(target.path, home);
59
25
  // Shell glob expansion touches directories even when the command does not walk them.
60
- if (paths.some(({ path, glob }) => isBroad(path, home, glob) && (walk || glob))) return broadReason;
61
- if (cmd.program < 0) return undefined;
26
+ return isBroad(target.path, home, target.glob) && (target.walk !== "none" || target.glob);
27
+ }
62
28
 
63
- // Path operands scope a walk away from the current directory; a search
64
- // tool's pattern is not one of them.
65
- let scoped = false;
66
- for (const word of cmd.argv.slice(cmd.program + 1)) {
67
- const text = word.text;
68
- if ((word.role === "arg" || word.role === "path") && !text.startsWith("-")) scoped = true;
29
+ export function appdataRules(req: Request, targets: Target[]): string[] {
30
+ const denials: string[] = [];
31
+ const groups = Map.groupBy(
32
+ // The producer of a walk that feeds another command was judged on its own targets.
33
+ targets.filter((target) => target.via !== "items" && !(target.via === "tool" && target.glob)),
34
+ (target) => target.command,
35
+ );
36
+ for (const group of groups.values()) {
37
+ if (group.some((target) => appdataHit(target, req.home))) denials.push(appdataReason);
38
+ else if (group.some((target) => broadHit(target, req.home))) denials.push(broadReason);
69
39
  }
70
- if (prog === "ls" && !scoped && isAppdata(cwd, home)) return appdataReason;
71
- // rg and fd also read ignore files from cwd when given a path operand.
72
- let denied = false;
73
- if (["find", "du", "tree"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isAppdata(cwd, home));
74
- else if (prog === "ls" || prog === "grep") denied = !scoped && recursive && isBroad(cwd, home);
75
- else if (["rg", "fd", "ag", "ack"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isLibrary(cwd, home));
76
- return denied ? broadReason : undefined;
40
+ const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+ const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
42
+ for (const fragment of req.uninspectable) if (signature.test(fragment.text)) denials.push(appdataReason);
43
+ return denials;
77
44
  }
@@ -1,336 +1,49 @@
1
- // Credential rules: keep credential files, environment and shell-variable
2
- // dumps, stored tokens and verbose HTTP traces out of what the model reads.
3
- // Clients that consume a credential file themselves (dotenvx, node --env-file,
4
- // ssh -i) are not readers and stay allowed.
5
- import { basename } from "node:path";
6
-
7
- import { programName } from "../argv";
8
- import { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
9
- import { showsHidden } from "../pipeline";
1
+ // Credential rules: keep credential files and private keys out of what the
2
+ // model reads. Clients the program table models as consuming a credential file
3
+ // themselves (dotenvx, node --env-file, ssh -i) are not readers and stay
4
+ // allowed; the guard does not control what they do with the contents.
5
+ import { isSensitive, isSensitiveRoot, sshPrivate } from "../paths";
10
6
  import { reasons } from "../reasons";
11
- import type { Command, Request, Word } from "../record";
12
-
13
- const readerPrograms = new Set(
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(" "),
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"]);
23
- const {
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,
35
- } = reasons;
7
+ import type { Request, Target } from "../record";
8
+ import { sshScopeDenied } from "../ssh";
9
+ import { secretReasons, secretSignatures } from "./secrets";
36
10
 
37
- const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
11
+ const { file: fileReason, codeFile: codeFileReason, upload: uploadReason, ssh: sshReason, grepSsh: grepSshReason, hiddenSearch: hiddenSearchReason } = reasons;
38
12
 
39
- export function credentialRules(req: Request): string[] {
40
- const denials: string[] = [];
41
- if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
42
- if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
43
- if (req.operation === "search") {
44
- if (isSensitiveRoot(req.searchRoot, req.home)) denials.push(fileReason);
45
- // A positive glob selects files even past ignore rules.
46
- if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
47
- }
48
- for (const cmd of req.commands) denials.push(...credentialCommand(cmd, req.home));
49
- for (const fragment of req.uninspectable) denials.push(...credentialSignatures(fragment));
50
- return denials;
13
+ function targetReason(target: Target, home: string): string | undefined {
14
+ const reason = target.via === "code" ? codeFileReason : target.sends ? uploadReason : fileReason;
15
+ if (target.effect === "write") return sshPrivate(target.path) ? sshReason : undefined;
16
+ if (target.effect !== "read") return undefined;
17
+ if (target.walk === "hidden") return hiddenSearchReason;
18
+ // A directory that holds a listed file is read whole.
19
+ return isSensitive(target.path, target.glob) || (!target.glob && isSensitiveRoot(target.path, home)) ? reason : undefined;
51
20
  }
52
21
 
53
- // The core runs this last: it follows symlinks, so it touches the filesystem.
54
- export function credentialFilesystemRules(req: Request): string[] {
55
- if ((req.operation === "read" || req.operation === "write") && sshScopeDenied(req.target, req.home, false)) return [sshReason];
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
- }
76
- return [];
77
- }
78
-
79
- function credentialCommand(cmd: Command, home: string): string[] {
22
+ export function credentialRules(req: Request, targets: Target[]): string[] {
80
23
  const denials: string[] = [];
81
- const sensitive = (word: string, glob = false, quoted = false) => isSensitive(absPath(word, cmd.cwd, home, quoted), glob);
82
- for (const r of cmd.redirects) if (r.direction === "in" && sensitive(r.target, r.globs)) denials.push(fileReason);
83
- if (cmd.program < 0) {
84
- if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
85
- return denials;
86
- }
87
- const program = cmd.argv[cmd.program]!.text;
88
- const name = programName(program);
89
- const words = cmd.argv.slice(cmd.program + 1);
90
- const args = words.map((w) => w.text);
91
- // Shell variables exist only in the shell, so a path or a wrapper that
92
- // execs a program never reaches these builtins.
93
- if (cmd.shell && !program.includes("/")) {
94
- if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
95
- if (name === "set" && !args.length) denials.push(dumpReason);
96
- if (name === "typeset" || name === "declare") {
97
- if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
98
- for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
24
+ const groups = Map.groupBy(targets, (target) => target.command);
25
+ const judge = (command: number) => {
26
+ for (const target of groups.get(command) ?? []) {
27
+ const reason = targetReason(target, req.home);
28
+ if (reason) denials.push(reason);
99
29
  }
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
30
  };
131
- let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
132
- const operands = args.filter((a) => !a.startsWith("-"));
133
- switch (name) {
134
- case "printenv":
135
- if (!operands.length) denials.push(dumpReason);
136
- for (const a of operands) if (secretName(a)) denials.push(varReason);
137
- break;
138
- case "echo":
139
- case "printf":
140
- case "print":
141
- if (name !== "echo" && args[0] === "-v") display = false;
142
- if (display && words.some((w) => w.vars.some(secretName))) denials.push(varReason);
143
- break;
144
- case "rg":
145
- case "grep":
146
- case "ag":
147
- case "ack":
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);
150
- for (const w of words) {
151
- if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
152
- }
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);
156
- // Search operands, pattern files and positive globs are read targets;
157
- // the pattern is not.
158
- for (const w of words) {
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);
162
- if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
163
- }
164
- break;
165
- case "gh":
166
- if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
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());
170
- break;
171
- case "glab":
172
- if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
173
- break;
174
- case "security":
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);
196
- break;
197
- case "curl":
198
- denials.push(...curlRules(args, sensitive));
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;
215
- default:
216
- if (!readerPrograms.has(name)) return denials;
217
- denials.push(...readTargets(name === "jq" ? jqFilter(words) : undefined));
218
- }
219
- // A heredoc or here-string body is standard input the command prints.
220
- if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
221
- return denials;
222
- }
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
-
264
- // A token-display flag counts even when a later flag cancels it.
265
- function ghShowsToken(args: string[]): boolean {
266
- for (let i = 0; i < args.length; i++) {
267
- const a = args[i]!;
268
- if (a === "--") return false;
269
- if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
270
- else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
271
- }
272
- return false;
273
- }
274
-
275
- // curl reads ~/.curlrc and may add credentials the command line never shows,
276
- // so any trace is denied; @file data and uploads name files it sends.
277
- function curlRules(args: string[], sensitive: (word: string) => boolean): string[] {
278
- const denials: string[] = [];
279
- const long = /^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)(=|$)/s;
280
- for (let i = 0; i < args.length; i++) {
281
- const word = args[i]!;
282
- let key = "";
283
- let value = "";
284
- if (word === "--") break;
285
- if (/^file:\/\//i.test(word) && sensitive(word)) denials.push(fileReason);
286
- if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) denials.push(traceReason);
287
- else if (long.test(word)) {
288
- key = word.slice(2).split("=")[0]!;
289
- value = word.includes("=") ? word.slice(word.indexOf("=") + 1) : (args[++i] ?? "");
290
- } else if (/^-[^-]/.test(word)) {
291
- for (let k = 1; k < word.length; k++) {
292
- if (word[k] === "v") denials.push(traceReason);
293
- if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k]!)) continue;
294
- key = word[k]!;
295
- value = word.slice(k + 1) || (args[++i] ?? "");
296
- break;
297
- }
298
- }
299
- if (key === "d" || key.startsWith("data") || key === "json" || key === "H" || key === "header") {
300
- if (!/@./s.test(value)) continue;
301
- value = value.slice(value.indexOf("@") + 1);
302
- } else if (key === "F" || key === "form") {
303
- value = value
304
- .slice(value.indexOf("=") + 1)
305
- .replace(/^[@<]/, "")
306
- .split(";")[0]!;
307
- } else if (!["T", "upload-file", "K", "config"].includes(key)) continue;
308
- if (sensitive(value)) denials.push(uploadReason);
31
+ judge(-1);
32
+ for (const [i, cmd] of req.commands.entries()) {
33
+ judge(i);
34
+ denials.push(...secretReasons(cmd));
309
35
  }
36
+ for (const fragment of req.uninspectable) denials.push(...secretSignatures(fragment.text));
310
37
  return denials;
311
38
  }
312
39
 
313
- const readers = [...readerPrograms].filter((p) => p !== ".").join("|");
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*";
315
- const readerSignature = new RegExp(`(^|[^A-Za-z0-9_])(${readers})([^A-Za-z0-9_-].*)?[\\s/"'=](${names})($|[\\s/"'>|&)\`])`, "s");
316
-
317
- // Text the front end could not structure: interpreter code, case bodies, or
318
- // source the parser rejects. Match the commands that would expose a credential.
319
- function credentialSignatures(fragment: string): string[] {
320
- const denials: string[] = [];
321
- for (const segment of fragment.replace(/&&|\|\||\n/g, ";").split(";")) {
322
- if (
323
- /(^|[^A-Za-z0-9_-])printenv([^A-Za-z0-9_-]|$)/.test(segment) ||
324
- /(^|[|(])\s*(env|export|set|typeset|declare)\s*($|[|>)])/.test(segment) ||
325
- /(declare|typeset|export)\s+-[a-z]*[px]/.test(segment)
326
- )
327
- denials.push(dumpReason);
328
- const reader = segment.match(readerSignature);
329
- if (reader) denials.push(fileReason);
330
- if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
331
- if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
332
- if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
333
- if (/(ECHO|PRINTF|PRINT)\s.*\$\{?[A-Z0-9_]*(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/s.test(segment.toUpperCase())) denials.push(varReason);
40
+ // The core runs this last: it compares inodes, so it touches the filesystem.
41
+ export function credentialFilesystemRules(req: Request, targets: Target[]): string[] {
42
+ for (const target of targets) {
43
+ if (!["read", "write", "list"].includes(target.effect) || target.via === "items" || (target.via === "tool" && target.glob)) continue;
44
+ // A working directory is checked only for a search that reads what is under it.
45
+ if ((target.via === "cwd" || target.via === "scan") && !target.search) continue;
46
+ if (sshScopeDenied(target.path, req.home, target.search)) return [target.search ? grepSshReason : sshReason];
334
47
  }
335
- return denials;
48
+ return [];
336
49
  }
@@ -0,0 +1,143 @@
1
+ // Secrets that print without a path: environment and variable dumps, stored
2
+ // tokens, verbose HTTP traces. No target exists to check, so these name the
3
+ // command that prints them. Each is a workaround that stays until the operating
4
+ // system's read restrictions cover the store; do not extend the list.
5
+ import { programName } from "../argv";
6
+ import { curlValueLetters, readers } from "../programs";
7
+ import { reasons } from "../reasons";
8
+ import type { Command } from "../record";
9
+
10
+ const { dump: dumpReason, variable: varReason, token: tokenReason, keychain: keychainReason, trace: traceReason, secretPrint: secretReason } = reasons;
11
+
12
+ const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
13
+
14
+ export function secretReasons(cmd: Command): string[] {
15
+ const denials: string[] = [];
16
+ if (cmd.program < 0) {
17
+ if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
18
+ return denials;
19
+ }
20
+ const program = cmd.argv[cmd.program]!.text;
21
+ const name = programName(program);
22
+ const words = cmd.argv.slice(cmd.program + 1);
23
+ const args = words.map((w) => w.text);
24
+ // Shell variables exist only in the shell, so a path or a wrapper that
25
+ // execs a program never reaches these builtins.
26
+ if (cmd.shell && !program.includes("/")) {
27
+ if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
28
+ if (name === "set" && !args.length) denials.push(dumpReason);
29
+ if (name === "typeset" || name === "declare") {
30
+ if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
31
+ for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
32
+ }
33
+ }
34
+ let display = readers.includes(name) || ["echo", "printf", "print"].includes(name);
35
+ const operands = args.filter((a) => !a.startsWith("-"));
36
+ switch (name) {
37
+ case "printenv":
38
+ if (!operands.length) denials.push(dumpReason);
39
+ for (const a of operands) if (secretName(a)) denials.push(varReason);
40
+ break;
41
+ case "echo":
42
+ case "printf":
43
+ case "print":
44
+ if (name !== "echo" && args[0] === "-v") display = false;
45
+ if (display && words.some((w) => w.vars.some(secretName))) denials.push(varReason);
46
+ break;
47
+ case "gh":
48
+ if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
49
+ if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
50
+ break;
51
+ case "glab":
52
+ if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
53
+ break;
54
+ case "security":
55
+ if (args.some((a) => /^-[A-Za-z]*[wg]/.test(a) && !a.startsWith("--")) || ["dump-keychain", "export"].includes(args[0] ?? "")) denials.push(keychainReason);
56
+ break;
57
+ case "gcloud":
58
+ if (args.includes("print-access-token") || args.includes("print-identity-token")) denials.push(secretReason);
59
+ break;
60
+ case "az":
61
+ if (args[0] === "account" && args[1] === "get-access-token") denials.push(secretReason);
62
+ break;
63
+ case "aws":
64
+ if (args[0] === "configure" && args[1] === "get" && secretName(args[2] ?? "")) denials.push(secretReason);
65
+ break;
66
+ case "npm":
67
+ if (args[0] === "config" && args[1] === "get" && /auth|token|password/i.test(args[2] ?? "")) denials.push(secretReason);
68
+ break;
69
+ case "kubectl":
70
+ if (args[0] === "config" && args[1] === "view" && args.includes("--raw")) denials.push(secretReason);
71
+ break;
72
+ case "gpg":
73
+ if (args.some((a) => /^--export-secret-(sub)?keys$/.test(a))) denials.push(secretReason);
74
+ break;
75
+ case "curl":
76
+ if (curlTraces(args)) denials.push(traceReason);
77
+ break;
78
+ case "git":
79
+ if (gitCredentialFill(args)) denials.push(secretReason);
80
+ break;
81
+ }
82
+ // A heredoc or here-string body is standard input the command prints.
83
+ if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
84
+ return denials;
85
+ }
86
+
87
+ // git credential fill prints the stored credential; the subcommand follows any global options.
88
+ function gitCredentialFill(args: string[]): boolean {
89
+ let i = 0;
90
+ while (i < args.length && args[i]!.startsWith("-")) i += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(args[i]!) ? 2 : 1;
91
+ return args[i] === "credential" && args[i + 1] === "fill";
92
+ }
93
+
94
+ // A token-display flag counts even when a later flag cancels it.
95
+ function ghShowsToken(args: string[]): boolean {
96
+ for (let i = 0; i < args.length; i++) {
97
+ const a = args[i]!;
98
+ if (a === "--") return false;
99
+ if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
100
+ else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // curl reads ~/.curlrc and may add credentials the command line never shows,
106
+ // so any trace is denied. A value letter takes the rest of its cluster, so the
107
+ // v in -uvictor:secret is part of a user name.
108
+ function curlTraces(args: string[]): boolean {
109
+ for (let i = 0; i < args.length; i++) {
110
+ const word = args[i]!;
111
+ if (word === "--") break;
112
+ if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) return true;
113
+ // A separate value is not an option.
114
+ if (/^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)$/.test(word)) i++;
115
+ if (!/^-[^-]/.test(word)) continue;
116
+ for (let k = 1; k < word.length; k++) {
117
+ if (word[k] === "v") return true;
118
+ if (!curlValueLetters.includes(word[k]!)) continue;
119
+ if (k === word.length - 1) i++;
120
+ break;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+
126
+ // Text the front end could not structure: interpreter code, case bodies, or
127
+ // source the parser rejects. Match the commands that would expose a secret.
128
+ export function secretSignatures(fragment: string): string[] {
129
+ const denials: string[] = [];
130
+ for (const segment of fragment.replace(/&&|\|\||\n/g, ";").split(";")) {
131
+ if (
132
+ /(^|[^A-Za-z0-9_-])printenv([^A-Za-z0-9_-]|$)/.test(segment) ||
133
+ /(^|[|(])\s*(env|export|set|typeset|declare)\s*($|[|>)])/.test(segment) ||
134
+ /(declare|typeset|export)\s+-[a-z]*[px]/.test(segment)
135
+ )
136
+ denials.push(dumpReason);
137
+ if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
138
+ if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
139
+ if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
140
+ if (/(ECHO|PRINTF|PRINT)\s.*\$\{?[A-Z0-9_]*(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/s.test(segment.toUpperCase())) denials.push(varReason);
141
+ }
142
+ return denials;
143
+ }
@@ -1,5 +1,10 @@
1
1
  import type { Command, ValueRole, Word } from "./record";
2
2
 
3
+ // fd and ls skip hidden files unless asked; find never does.
4
+ export function showsHidden(words: Word[]): boolean {
5
+ return words.some((word) => /^(--hidden|--unrestricted)$/.test(word.text) || /^-[A-Za-z]*[Hu][A-Za-z]*$/.test(word.text));
6
+ }
7
+
3
8
  const rgShortValues = "efgtTEABCmMjrd";
4
9
  const rgLongValues = new Set(
5
10
  (