@loophubs/agent-guard 0.1.0 → 0.2.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 +66 -58
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +66 -45
- package/src/guard.ts +32 -16
- package/src/links.ts +25 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +64 -17
- package/src/pipeline.ts +9 -7
- package/src/reasons.ts +4 -3
- package/src/record.ts +5 -1
- package/src/rules/appdata.ts +10 -18
- package/src/rules/credentials.ts +63 -26
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +9 -15
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/src/rules/appdata.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
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
4
|
import { basename } from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
5
|
+
|
|
6
|
+
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths";
|
|
7
|
+
import { reasons } from "../reasons";
|
|
8
|
+
import type { Command, Request } from "../record";
|
|
8
9
|
|
|
9
10
|
const { appdata: appdataReason, broad: broadReason } = reasons;
|
|
10
11
|
|
|
@@ -26,18 +27,17 @@ export function appdataRules(req: Request): string[] {
|
|
|
26
27
|
if (reason) denials.push(reason);
|
|
27
28
|
}
|
|
28
29
|
const home = req.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
-
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})
|
|
30
|
+
const signature = new RegExp(`(~|\\$HOME|\\$\\{HOME\\}|${home})/Library/(${trees})`, "i");
|
|
30
31
|
for (const fragment of req.uninspectable) if (signature.test(fragment)) denials.push(appdataReason);
|
|
31
32
|
return denials;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
35
36
|
const cwd = cmd.cwd;
|
|
36
|
-
const prog = cmd.program >= 0 ? basename(cmd.argv[cmd.program]
|
|
37
|
+
const prog = cmd.program >= 0 ? basename(cmd.argv[cmd.program]!.text) : "";
|
|
37
38
|
const data = dataPrograms.has(prog);
|
|
38
39
|
// cd reads its target but walks nothing.
|
|
39
|
-
const recursive = cmd.argv.slice(cmd.program + 1).some((word) =>
|
|
40
|
-
(prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
40
|
+
const recursive = cmd.argv.slice(cmd.program + 1).some((word) => (prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
41
41
|
const gitConfig = prog === "git" && cmd.argv.slice(cmd.program + 1).some((word) => word.text === "config");
|
|
42
42
|
const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
|
|
43
43
|
const paths: { path: string; glob: boolean }[] = [];
|
|
@@ -46,7 +46,7 @@ function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
|
46
46
|
if (word.role === "program" && !word.value.includes("/")) continue;
|
|
47
47
|
if (data && !word.globs && i > cmd.program) continue;
|
|
48
48
|
// An expansion the front end cannot resolve may well be $HOME.
|
|
49
|
-
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "
|
|
49
|
+
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "is").test(word.value)) return appdataReason;
|
|
50
50
|
paths.push({ path: absPath(word.value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
|
|
51
51
|
}
|
|
52
52
|
for (const r of cmd.redirects) {
|
|
@@ -60,23 +60,15 @@ function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
|
60
60
|
// Path operands scope a walk away from the current directory; a search
|
|
61
61
|
// tool's pattern is not one of them.
|
|
62
62
|
let scoped = false;
|
|
63
|
-
let noignore = cmd.flags.has("noignore");
|
|
64
63
|
for (const word of cmd.argv.slice(cmd.program + 1)) {
|
|
65
64
|
const text = word.text;
|
|
66
65
|
if ((word.role === "arg" || word.role === "path") && !text.startsWith("-")) scoped = true;
|
|
67
|
-
if (/^--(unrestricted|no-ignore)/.test(text)) noignore = true;
|
|
68
|
-
if (prog === "fd" && /^-[^-]*[uI]/.test(text)) noignore = true;
|
|
69
|
-
// A positive glob reaching Library overrides the ignore filter.
|
|
70
|
-
if (word.role === "glob" || word.role === "option:glob") {
|
|
71
|
-
const glob = new Bun.Glob(word.value.toLowerCase());
|
|
72
|
-
if (["library", ...appdataTrees.map((tree) => `library/${tree.toLowerCase()}/x`)].some((path) => glob.match(path))) noignore = true;
|
|
73
|
-
}
|
|
74
66
|
}
|
|
75
67
|
if (prog === "ls" && !scoped && isAppdata(cwd, home)) return appdataReason;
|
|
76
68
|
// rg and fd also read ignore files from cwd when given a path operand.
|
|
77
69
|
let denied = false;
|
|
78
|
-
if (
|
|
70
|
+
if (["find", "du", "tree"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isAppdata(cwd, home));
|
|
79
71
|
else if (prog === "ls" || prog === "grep") denied = !scoped && recursive && isBroad(cwd, home);
|
|
80
|
-
else if (
|
|
72
|
+
else if (["rg", "fd", "ag", "ack"].includes(prog)) denied = !scoped && (isBroad(cwd, home) || isLibrary(cwd, home));
|
|
81
73
|
return denied ? broadReason : undefined;
|
|
82
74
|
}
|
package/src/rules/credentials.ts
CHANGED
|
@@ -3,18 +3,28 @@
|
|
|
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 { absPath, isSensitive, isSensitiveRoot, sshPrivate, sshScopeDenied } from "../paths";
|
|
8
|
+
import { reasons } from "../reasons";
|
|
9
|
+
import type { Command, Request, Word } from "../record";
|
|
9
10
|
|
|
10
11
|
const readerPrograms = new Set(
|
|
11
|
-
(
|
|
12
|
-
"
|
|
12
|
+
(
|
|
13
|
+
"cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
|
|
14
|
+
"uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack"
|
|
15
|
+
).split(" "),
|
|
13
16
|
);
|
|
14
17
|
const {
|
|
15
|
-
file: fileReason,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
file: fileReason,
|
|
19
|
+
dump: dumpReason,
|
|
20
|
+
variable: varReason,
|
|
21
|
+
token: tokenReason,
|
|
22
|
+
keychain: keychainReason,
|
|
23
|
+
trace: traceReason,
|
|
24
|
+
upload: uploadReason,
|
|
25
|
+
ssh: sshReason,
|
|
26
|
+
grepSsh: grepSshReason,
|
|
27
|
+
hiddenSearch: hiddenSearchReason,
|
|
18
28
|
} = reasons;
|
|
19
29
|
|
|
20
30
|
const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
|
|
@@ -24,7 +34,7 @@ export function credentialRules(req: Request): string[] {
|
|
|
24
34
|
if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
|
|
25
35
|
if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
|
|
26
36
|
if (req.operation === "search") {
|
|
27
|
-
if (
|
|
37
|
+
if (isSensitiveRoot(req.searchRoot)) denials.push(fileReason);
|
|
28
38
|
// A positive glob selects files even past ignore rules.
|
|
29
39
|
if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
|
|
30
40
|
}
|
|
@@ -37,6 +47,25 @@ export function credentialRules(req: Request): string[] {
|
|
|
37
47
|
export function credentialFilesystemRules(req: Request): string[] {
|
|
38
48
|
if ((req.operation === "read" || req.operation === "write") && sshScopeDenied(req.target, req.home, false)) return [sshReason];
|
|
39
49
|
if (req.operation === "search" && sshScopeDenied(req.searchRoot, req.home, true)) return [grepSshReason];
|
|
50
|
+
for (const cmd of req.commands) {
|
|
51
|
+
for (const redirect of cmd.redirects) {
|
|
52
|
+
if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target && sshScopeDenied(absPath(redirect.target, cmd.cwd, req.home), req.home, false)) return [sshReason];
|
|
53
|
+
}
|
|
54
|
+
if (cmd.program < 0) continue;
|
|
55
|
+
const name = basename(cmd.argv[cmd.program]!.text);
|
|
56
|
+
const readsPath = ["ls", "find", "tree", "rg", "grep", "ag", "ack", "fd"].includes(name);
|
|
57
|
+
if (!readsPath && !readerPrograms.has(name)) continue;
|
|
58
|
+
const words = cmd.argv.slice(cmd.program + 1);
|
|
59
|
+
// A search with no path operand reads the working directory.
|
|
60
|
+
const searches = ["rg", "ag", "ack"].includes(name) || (name === "grep" && cmd.flags.has("recursive"));
|
|
61
|
+
if (searches && !cmd.flags.has("help") && !words.some((word) => word.role === "path") && sshScopeDenied(cmd.cwd, req.home, true)) return [grepSshReason];
|
|
62
|
+
const filter = name === "jq" ? jqFilter(words) : undefined;
|
|
63
|
+
for (const word of words) {
|
|
64
|
+
if (word === filter) continue;
|
|
65
|
+
if (!["arg", "path", "patfile", "option:patfile"].includes(word.role) || word.value.startsWith("-")) continue;
|
|
66
|
+
if (sshScopeDenied(absPath(word.value, cmd.cwd, req.home, /^['"]/.test(word.raw)), req.home, false)) return [sshReason];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
40
69
|
return [];
|
|
41
70
|
}
|
|
42
71
|
|
|
@@ -48,7 +77,7 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
48
77
|
if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
|
|
49
78
|
return denials;
|
|
50
79
|
}
|
|
51
|
-
const program = cmd.argv[cmd.program]
|
|
80
|
+
const program = cmd.argv[cmd.program]!.text;
|
|
52
81
|
const name = basename(program);
|
|
53
82
|
const words = cmd.argv.slice(cmd.program + 1);
|
|
54
83
|
const args = words.map((w) => w.text);
|
|
@@ -58,7 +87,7 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
58
87
|
if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
|
|
59
88
|
if (name === "set" && !args.length) denials.push(dumpReason);
|
|
60
89
|
if (name === "typeset" || name === "declare") {
|
|
61
|
-
if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]))) denials.push(dumpReason);
|
|
90
|
+
if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]!))) denials.push(dumpReason);
|
|
62
91
|
for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
|
|
63
92
|
}
|
|
64
93
|
}
|
|
@@ -80,6 +109,7 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
80
109
|
case "ag":
|
|
81
110
|
case "ack":
|
|
82
111
|
if (cmd.flags.has("help")) break;
|
|
112
|
+
if ((name === "grep" && cmd.flags.has("recursive")) || (["rg", "ag"].includes(name) && cmd.flags.has("hidden"))) denials.push(hiddenSearchReason);
|
|
83
113
|
for (const w of words) {
|
|
84
114
|
if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
|
|
85
115
|
}
|
|
@@ -87,7 +117,9 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
87
117
|
// Search operands, pattern files and positive globs are read targets;
|
|
88
118
|
// the pattern is not.
|
|
89
119
|
for (const w of words) {
|
|
90
|
-
|
|
120
|
+
const quoted = /^['"]/.test(w.raw);
|
|
121
|
+
if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, quoted)) denials.push(fileReason);
|
|
122
|
+
if (w.role === "path" && isSensitiveRoot(absPath(w.value, cmd.cwd, home, quoted))) denials.push(fileReason);
|
|
91
123
|
if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
|
|
92
124
|
}
|
|
93
125
|
break;
|
|
@@ -106,12 +138,9 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
106
138
|
break;
|
|
107
139
|
default:
|
|
108
140
|
if (!readerPrograms.has(name)) return denials;
|
|
109
|
-
|
|
141
|
+
const filter = name === "jq" ? jqFilter(words) : undefined;
|
|
110
142
|
for (const w of words) {
|
|
111
|
-
if (
|
|
112
|
-
jqFilter = false;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
143
|
+
if (w === filter) continue;
|
|
115
144
|
const target = /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
|
|
116
145
|
if (target && !target.startsWith("-") && sensitive(target, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
|
|
117
146
|
}
|
|
@@ -121,10 +150,15 @@ function credentialCommand(cmd: Command, home: string): string[] {
|
|
|
121
150
|
return denials;
|
|
122
151
|
}
|
|
123
152
|
|
|
153
|
+
function jqFilter(words: Word[]): Word | undefined {
|
|
154
|
+
if (words.some((word) => word.text === "-f" || word.text === "--from-file")) return undefined;
|
|
155
|
+
return words.find((word) => !word.text.startsWith("-"));
|
|
156
|
+
}
|
|
157
|
+
|
|
124
158
|
// A token-display flag counts even when a later flag cancels it.
|
|
125
159
|
function ghShowsToken(args: string[]): boolean {
|
|
126
160
|
for (let i = 0; i < args.length; i++) {
|
|
127
|
-
const a = args[i]
|
|
161
|
+
const a = args[i]!;
|
|
128
162
|
if (a === "--") return false;
|
|
129
163
|
if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
|
|
130
164
|
else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
|
|
@@ -138,19 +172,20 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
|
|
|
138
172
|
const denials: string[] = [];
|
|
139
173
|
const long = /^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)(=|$)/s;
|
|
140
174
|
for (let i = 0; i < args.length; i++) {
|
|
141
|
-
const word = args[i]
|
|
175
|
+
const word = args[i]!;
|
|
142
176
|
let key = "";
|
|
143
177
|
let value = "";
|
|
144
178
|
if (word === "--") break;
|
|
179
|
+
if (/^file:\/\//i.test(word) && sensitive(word)) denials.push(fileReason);
|
|
145
180
|
if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) denials.push(traceReason);
|
|
146
181
|
else if (long.test(word)) {
|
|
147
|
-
key = word.slice(2).split("=")[0]
|
|
182
|
+
key = word.slice(2).split("=")[0]!;
|
|
148
183
|
value = word.includes("=") ? word.slice(word.indexOf("=") + 1) : (args[++i] ?? "");
|
|
149
184
|
} else if (/^-[^-]/.test(word)) {
|
|
150
185
|
for (let k = 1; k < word.length; k++) {
|
|
151
186
|
if (word[k] === "v") denials.push(traceReason);
|
|
152
|
-
if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k])) continue;
|
|
153
|
-
key = word[k]
|
|
187
|
+
if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k]!)) continue;
|
|
188
|
+
key = word[k]!;
|
|
154
189
|
value = word.slice(k + 1) || (args[++i] ?? "");
|
|
155
190
|
break;
|
|
156
191
|
}
|
|
@@ -159,7 +194,10 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
|
|
|
159
194
|
if (!/@./s.test(value)) continue;
|
|
160
195
|
value = value.slice(value.indexOf("@") + 1);
|
|
161
196
|
} else if (key === "F" || key === "form") {
|
|
162
|
-
value = value
|
|
197
|
+
value = value
|
|
198
|
+
.slice(value.indexOf("=") + 1)
|
|
199
|
+
.replace(/^[@<]/, "")
|
|
200
|
+
.split(";")[0]!;
|
|
163
201
|
} else if (!["T", "upload-file", "K", "config"].includes(key)) continue;
|
|
164
202
|
if (sensitive(value)) denials.push(uploadReason);
|
|
165
203
|
}
|
|
@@ -167,8 +205,7 @@ function curlRules(args: string[], sensitive: (word: string) => boolean): string
|
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
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*";
|
|
208
|
+
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
209
|
const readerSignature = new RegExp(`(^|[^A-Za-z0-9_])(${readers})([^A-Za-z0-9_-].*)?[\\s/"'=](${names})($|[\\s/"'>|&)\`])`, "s");
|
|
173
210
|
|
|
174
211
|
// Text the front end could not structure: interpreter code, case bodies, or
|
|
@@ -183,7 +220,7 @@ function credentialSignatures(fragment: string): string[] {
|
|
|
183
220
|
)
|
|
184
221
|
denials.push(dumpReason);
|
|
185
222
|
const reader = segment.match(readerSignature);
|
|
186
|
-
if (reader
|
|
223
|
+
if (reader) denials.push(fileReason);
|
|
187
224
|
if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
|
|
188
225
|
if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
|
|
189
226
|
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,18 @@
|
|
|
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, visit: (part: Node, names: string[]) => void): Word {
|
|
15
10
|
const text = (part: Node) => slice(part.Pos().Offset(), part.End().Offset());
|
|
16
11
|
const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "" };
|
|
17
12
|
const expansion = (part: Node) => {
|
|
18
13
|
const param = part as ParamExp;
|
|
19
|
-
const plain = type(part) === "ParamExp" &&
|
|
20
|
-
|
|
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 known = plain ? (param.Param!.Value === "HOME" ? home : vars.get(param.Param!.Value)) : undefined;
|
|
22
16
|
out.text += known ?? text(part);
|
|
23
17
|
out.expands ||= known === undefined;
|
|
24
18
|
visit(part, out.vars);
|
|
@@ -34,13 +28,13 @@ export function readWord(
|
|
|
34
28
|
out.text += value[++i] ?? "";
|
|
35
29
|
continue;
|
|
36
30
|
}
|
|
37
|
-
if ("*?[".includes(value[i])) out.globs = true;
|
|
31
|
+
if ("*?[".includes(value[i]!)) out.globs = true;
|
|
38
32
|
out.text += value[i];
|
|
39
33
|
}
|
|
40
34
|
} else if (kind === "SglQuoted") {
|
|
41
35
|
const quoted = part as SglQuoted;
|
|
42
36
|
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, (
|
|
37
|
+
? 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
38
|
if (hex || unicode || octal) return String.fromCodePoint(parseInt(hex ?? unicode ?? octal, octal ? 8 : 16));
|
|
45
39
|
return ({ a: "\x07", b: "\b", e: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" } as Record<string, string>)[char] ?? char;
|
|
46
40
|
})
|
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
|
+
}
|
package/src/mvdan-sh.d.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// The mvdan-sh package ships no types. This declares only the AST subset the
|
|
2
|
-
// front end reads, so the package needs no type dependency. Positions are
|
|
3
|
-
// UTF-8 byte offsets into the parsed source.
|
|
4
|
-
declare module "mvdan-sh" {
|
|
5
|
-
export interface Pos {
|
|
6
|
-
Offset(): number;
|
|
7
|
-
}
|
|
8
|
-
export interface Node {
|
|
9
|
-
Pos(): Pos;
|
|
10
|
-
End(): Pos;
|
|
11
|
-
}
|
|
12
|
-
export interface Lit extends Node {
|
|
13
|
-
Value: string;
|
|
14
|
-
}
|
|
15
|
-
export interface Comment extends Node {
|
|
16
|
-
Hash: Pos;
|
|
17
|
-
}
|
|
18
|
-
export interface Word extends Node {
|
|
19
|
-
Parts: Node[];
|
|
20
|
-
}
|
|
21
|
-
export interface Assign extends Node {
|
|
22
|
-
Naked: boolean;
|
|
23
|
-
Name: Lit | null;
|
|
24
|
-
Value: Word | null;
|
|
25
|
-
}
|
|
26
|
-
export interface Redirect extends Node {
|
|
27
|
-
OpPos: Pos;
|
|
28
|
-
Word: Word;
|
|
29
|
-
Hdoc: Word | null;
|
|
30
|
-
}
|
|
31
|
-
export interface Stmt extends Node {
|
|
32
|
-
Cmd: Node | null;
|
|
33
|
-
Background: boolean;
|
|
34
|
-
Redirs: Redirect[];
|
|
35
|
-
}
|
|
36
|
-
export interface File extends Node {
|
|
37
|
-
Stmts: Stmt[];
|
|
38
|
-
}
|
|
39
|
-
export interface CallExpr extends Node {
|
|
40
|
-
Assigns: Assign[];
|
|
41
|
-
Args: Word[];
|
|
42
|
-
}
|
|
43
|
-
export interface DeclClause extends Node {
|
|
44
|
-
Variant: Lit;
|
|
45
|
-
Args: Assign[];
|
|
46
|
-
}
|
|
47
|
-
export interface BinaryCmd extends Node {
|
|
48
|
-
OpPos: Pos;
|
|
49
|
-
X: Stmt;
|
|
50
|
-
Y: Stmt;
|
|
51
|
-
}
|
|
52
|
-
export interface Subshell extends Node {
|
|
53
|
-
Stmts: Stmt[];
|
|
54
|
-
}
|
|
55
|
-
export interface IfClause extends Node {
|
|
56
|
-
Cond: Stmt[];
|
|
57
|
-
Then: Stmt[];
|
|
58
|
-
Else: IfClause | null;
|
|
59
|
-
}
|
|
60
|
-
export interface SglQuoted extends Node {
|
|
61
|
-
Dollar: boolean;
|
|
62
|
-
Value: string;
|
|
63
|
-
}
|
|
64
|
-
export interface DblQuoted extends Node {
|
|
65
|
-
Parts: Node[];
|
|
66
|
-
}
|
|
67
|
-
export interface ParamExp extends Node {
|
|
68
|
-
Excl: boolean;
|
|
69
|
-
Length: boolean;
|
|
70
|
-
Width: boolean;
|
|
71
|
-
Param: Lit;
|
|
72
|
-
Index: Node | null;
|
|
73
|
-
Slice: object | null;
|
|
74
|
-
Repl: object | null;
|
|
75
|
-
Exp: object | null;
|
|
76
|
-
}
|
|
77
|
-
export interface CmdSubst extends Node {
|
|
78
|
-
Stmts: Stmt[];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const sh: {
|
|
82
|
-
syntax: {
|
|
83
|
-
NewParser(...options: unknown[]): { Parse(source: string, name: string): File };
|
|
84
|
-
NodeType(node: Node): string;
|
|
85
|
-
Walk(node: Node, visit: (node: Node | null) => boolean): void;
|
|
86
|
-
KeepComments(keep: boolean): unknown;
|
|
87
|
-
Variant(language: unknown): unknown;
|
|
88
|
-
LangBash: unknown;
|
|
89
|
-
};
|
|
90
|
-
};
|
|
91
|
-
export default sh;
|
|
92
|
-
}
|