@loophubs/agent-guard 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -84
- package/bin/agent-guard +14 -10
- package/package.json +29 -8
- package/src/argv.ts +99 -59
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +107 -56
- package/src/guard.ts +32 -16
- package/src/links.ts +38 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +140 -20
- package/src/pipeline.ts +126 -14
- package/src/reasons.ts +6 -3
- package/src/record.ts +7 -1
- package/src/rules/appdata.ts +18 -23
- package/src/rules/credentials.ts +176 -33
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +23 -17
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/src/rules/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
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
|
-
(
|
|
12
|
-
"
|
|
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,
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
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]
|
|
52
|
-
const name =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
package/src/rules/workflow.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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]
|
|
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"))
|
|
21
|
-
if (cmd.flags.has("include"))
|
|
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))
|
|
18
|
+
if ((w.role === "pattern" || w.role === "option:pattern") && /(^|[^\\])\\\|/.test(w.value)) advice.push(breReason);
|
|
25
19
|
}
|
|
26
20
|
}
|
|
27
|
-
return
|
|
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;
|
package/src/search-roles.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import type { Command, ValueRole, Word } from "./record
|
|
1
|
+
import type { Command, ValueRole, Word } from "./record";
|
|
2
2
|
|
|
3
3
|
const rgShortValues = "efgtTEABCmMjrd";
|
|
4
4
|
const rgLongValues = new Set(
|
|
5
|
-
(
|
|
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"
|
|
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")
|
|
29
|
-
|
|
30
|
-
|
|
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]
|
|
42
|
+
const word = args[i]!.text;
|
|
37
43
|
if (options && word === "--") {
|
|
38
44
|
options = false;
|
|
39
|
-
args[i]
|
|
45
|
+
args[i]!.role = "option";
|
|
40
46
|
} else if (options && /^--./.test(word)) {
|
|
41
|
-
args[i]
|
|
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))
|
|
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]
|
|
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)
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
21
|
-
const
|
|
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
|
|
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, (
|
|
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
|
+
}
|