@loophubs/agent-guard 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/agent-guard +48 -0
- package/package.json +26 -0
- package/src/argv.ts +196 -0
- package/src/comments.ts +16 -0
- package/src/core.ts +44 -0
- package/src/cwd.ts +5 -0
- package/src/frontend.ts +240 -0
- package/src/guard.ts +41 -0
- package/src/links.ts +59 -0
- package/src/moves.ts +28 -0
- package/src/mvdan-sh.d.ts +92 -0
- package/src/paths.ts +149 -0
- package/src/pipeline.ts +26 -0
- package/src/reasons.ts +19 -0
- package/src/record.ts +51 -0
- package/src/rules/appdata.ts +82 -0
- package/src/rules/credentials.ts +193 -0
- package/src/rules/workflow.ts +38 -0
- package/src/search-roles.ts +76 -0
- package/src/words.ts +62 -0
package/src/record.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A search option's value keeps the option's word as option:ROLE.
|
|
2
|
+
export type ValueRole = "pattern" | "patfile" | "glob" | "nglob" | "optarg";
|
|
3
|
+
export type Role = "arg" | "assign" | "precommand" | "namespace" | "program" | "code" | "option" | "path" | ValueRole | `option:${ValueRole}`;
|
|
4
|
+
export type Flag = "explicit" | "files" | "help" | "noignore" | "fixed" | "recursive" | "include" | "replace";
|
|
5
|
+
|
|
6
|
+
export interface Word {
|
|
7
|
+
text: string; // quotes removed; ~, $HOME and ${HOME} expanded
|
|
8
|
+
raw: string;
|
|
9
|
+
expands: boolean; // holds an expansion the front end cannot resolve
|
|
10
|
+
globs: boolean; // holds an unquoted glob character
|
|
11
|
+
vars: string[]; // parameter names the shell expands
|
|
12
|
+
role: Role; // set by argv.ts
|
|
13
|
+
value: string; // an option's value without its flag, set by argv.ts
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Redirect {
|
|
17
|
+
direction: "in" | "out" | "herestring" | "heredoc";
|
|
18
|
+
target: string; // file, word, or heredoc body
|
|
19
|
+
vars: string[]; // parameters the shell expands in the target or body
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Command {
|
|
23
|
+
argv: Word[];
|
|
24
|
+
redirects: Redirect[];
|
|
25
|
+
cwd: string; // after the cd commands that run before it in the same shell
|
|
26
|
+
program: number; // argv index of the word that runs, -1 when nothing runs
|
|
27
|
+
wrappers: string[];
|
|
28
|
+
shell: boolean; // the program runs in this shell rather than behind a wrapper
|
|
29
|
+
flags: Set<Flag>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Script {
|
|
33
|
+
commands: Command[];
|
|
34
|
+
uninspectable: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Runtime = "claude" | "codex" | "pi";
|
|
38
|
+
export type Tool = "bash" | "read" | "edit" | "write" | "grep";
|
|
39
|
+
|
|
40
|
+
export interface Request {
|
|
41
|
+
runtime: Runtime;
|
|
42
|
+
tool: Tool;
|
|
43
|
+
home: string;
|
|
44
|
+
cwd: string;
|
|
45
|
+
operation: "" | "read" | "write" | "search";
|
|
46
|
+
target: string;
|
|
47
|
+
searchRoot: string;
|
|
48
|
+
glob: string;
|
|
49
|
+
commands: Command[];
|
|
50
|
+
uninspectable: string[];
|
|
51
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// App Data rules. macOS records a Files & Folders App Data entry whenever a
|
|
2
|
+
// process reads or enumerates another app's ~/Library data tree, so these deny
|
|
3
|
+
// those reads and the broad walks that reach them.
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { absPath, appdataTrees, isAppdata, isBroad, isLibrary } from "../paths.ts";
|
|
6
|
+
import { reasons } from "../reasons.ts";
|
|
7
|
+
import type { Command, Request } from "../record.ts";
|
|
8
|
+
|
|
9
|
+
const { appdata: appdataReason, broad: broadReason } = reasons;
|
|
10
|
+
|
|
11
|
+
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
|
+
|
|
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})`);
|
|
30
|
+
for (const fragment of req.uninspectable) if (signature.test(fragment)) denials.push(appdataReason);
|
|
31
|
+
return denials;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appdataCommand(cmd: Command, home: string): string | undefined {
|
|
35
|
+
const cwd = cmd.cwd;
|
|
36
|
+
const prog = cmd.program >= 0 ? basename(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) =>
|
|
40
|
+
(prog === "ls" ? /^(--recursive$|-[^-]*R)/ : /^(--recursive$|-[^-]*[rR])/).test(word.text));
|
|
41
|
+
const gitConfig = prog === "git" && cmd.argv.slice(cmd.program + 1).some((word) => word.text === "config");
|
|
42
|
+
const walk = !data && !gitConfig && !["cd", "pushd", "popd"].includes(prog) && !noWalkPrograms.has(prog) && !(["ls", "cp"].includes(prog) && !recursive);
|
|
43
|
+
const paths: { path: string; glob: boolean }[] = [];
|
|
44
|
+
for (const [i, word] of cmd.argv.entries()) {
|
|
45
|
+
if (!word.value || ["pattern", "code", "option:pattern", "optarg"].includes(word.role)) continue;
|
|
46
|
+
if (word.role === "program" && !word.value.includes("/")) continue;
|
|
47
|
+
if (data && !word.globs && i > cmd.program) continue;
|
|
48
|
+
// An expansion the front end cannot resolve may well be $HOME.
|
|
49
|
+
if (word.expands && new RegExp(`/Library/(${trees})(/.*)?$`, "s").test(word.value)) return appdataReason;
|
|
50
|
+
paths.push({ path: absPath(word.value, cwd, home, /^['"]/.test(word.raw)), glob: word.globs });
|
|
51
|
+
}
|
|
52
|
+
for (const r of cmd.redirects) {
|
|
53
|
+
if ((r.direction === "in" || r.direction === "out") && r.target) paths.push({ path: absPath(r.target, cwd, home), glob: false });
|
|
54
|
+
}
|
|
55
|
+
if (paths.some(({ path, glob }) => isAppdata(path, home, glob))) return appdataReason;
|
|
56
|
+
// Shell glob expansion touches directories even when the command does not walk them.
|
|
57
|
+
if (paths.some(({ path, glob }) => isBroad(path, home, glob) && (walk || glob))) return broadReason;
|
|
58
|
+
if (cmd.program < 0) return undefined;
|
|
59
|
+
|
|
60
|
+
// Path operands scope a walk away from the current directory; a search
|
|
61
|
+
// tool's pattern is not one of them.
|
|
62
|
+
let scoped = false;
|
|
63
|
+
let noignore = cmd.flags.has("noignore");
|
|
64
|
+
for (const word of cmd.argv.slice(cmd.program + 1)) {
|
|
65
|
+
const text = word.text;
|
|
66
|
+
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
|
+
}
|
|
75
|
+
if (prog === "ls" && !scoped && isAppdata(cwd, home)) return appdataReason;
|
|
76
|
+
// rg and fd also read ignore files from cwd when given a path operand.
|
|
77
|
+
let denied = false;
|
|
78
|
+
if (prog === "find" || prog === "du") denied = !scoped && (isBroad(cwd, home) || isAppdata(cwd, home));
|
|
79
|
+
else if (prog === "ls" || prog === "grep") denied = !scoped && recursive && isBroad(cwd, home);
|
|
80
|
+
else if (prog === "rg" || prog === "fd") denied = !scoped && (noignore ? isBroad(cwd, home) : isLibrary(cwd, home));
|
|
81
|
+
return denied ? broadReason : undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
import { absPath, isSensitive, sshPrivate, sshScopeDenied } from "../paths.ts";
|
|
7
|
+
import { reasons } from "../reasons.ts";
|
|
8
|
+
import type { Command, Request } from "../record.ts";
|
|
9
|
+
|
|
10
|
+
const readerPrograms = new Set(
|
|
11
|
+
("cat head tail less more bat sed awk jq yq base64 xxd od strings diff openssl plutil cp tee tar source . sort " +
|
|
12
|
+
"uniq cut nl fold rev paste comm join iconv hexdump hd zcat gzcat bzcat xzcat ag ack").split(" "),
|
|
13
|
+
);
|
|
14
|
+
const {
|
|
15
|
+
file: fileReason, dump: dumpReason, variable: varReason, token: tokenReason,
|
|
16
|
+
keychain: keychainReason, trace: traceReason, upload: uploadReason,
|
|
17
|
+
ssh: sshReason, grepSsh: grepSshReason,
|
|
18
|
+
} = reasons;
|
|
19
|
+
|
|
20
|
+
const secretName = (name: string) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL/.test(name.toUpperCase());
|
|
21
|
+
|
|
22
|
+
export function credentialRules(req: Request): string[] {
|
|
23
|
+
const denials: string[] = [];
|
|
24
|
+
if (req.operation === "read" && isSensitive(req.target)) denials.push(fileReason);
|
|
25
|
+
if (req.operation === "write" && sshPrivate(req.target)) denials.push(sshReason);
|
|
26
|
+
if (req.operation === "search") {
|
|
27
|
+
if (isSensitive(req.searchRoot)) denials.push(fileReason);
|
|
28
|
+
// A positive glob selects files even past ignore rules.
|
|
29
|
+
if (req.glob && !req.glob.startsWith("!") && isSensitive(`${req.searchRoot}/${basename(req.glob)}`, true)) denials.push(fileReason);
|
|
30
|
+
}
|
|
31
|
+
for (const cmd of req.commands) denials.push(...credentialCommand(cmd, req.home));
|
|
32
|
+
for (const fragment of req.uninspectable) denials.push(...credentialSignatures(fragment));
|
|
33
|
+
return denials;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The core runs this last: it follows symlinks, so it touches the filesystem.
|
|
37
|
+
export function credentialFilesystemRules(req: Request): string[] {
|
|
38
|
+
if ((req.operation === "read" || req.operation === "write") && sshScopeDenied(req.target, req.home, false)) return [sshReason];
|
|
39
|
+
if (req.operation === "search" && sshScopeDenied(req.searchRoot, req.home, true)) return [grepSshReason];
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function credentialCommand(cmd: Command, home: string): string[] {
|
|
44
|
+
const denials: string[] = [];
|
|
45
|
+
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);
|
|
47
|
+
if (cmd.program < 0) {
|
|
48
|
+
if (cmd.wrappers.at(-1) === "env" && !cmd.wrappers.includes("env-S")) denials.push(dumpReason);
|
|
49
|
+
return denials;
|
|
50
|
+
}
|
|
51
|
+
const program = cmd.argv[cmd.program].text;
|
|
52
|
+
const name = basename(program);
|
|
53
|
+
const words = cmd.argv.slice(cmd.program + 1);
|
|
54
|
+
const args = words.map((w) => w.text);
|
|
55
|
+
// Shell variables exist only in the shell, so a path or a wrapper that
|
|
56
|
+
// execs a program never reaches these builtins.
|
|
57
|
+
if (cmd.shell && !program.includes("/")) {
|
|
58
|
+
if (name === "export" && (!args.length || args.some((a) => /^-[^-]*p/.test(a)))) denials.push(dumpReason);
|
|
59
|
+
if (name === "set" && !args.length) denials.push(dumpReason);
|
|
60
|
+
if (name === "typeset" || name === "declare") {
|
|
61
|
+
if (!args.length || (args.length === 1 && /^-[^-]*[px]/.test(args[0]))) denials.push(dumpReason);
|
|
62
|
+
for (const a of args) if (!a.startsWith("-") && !a.includes("=") && secretName(a)) denials.push(varReason);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let display = readerPrograms.has(name) || ["echo", "printf", "print"].includes(name);
|
|
66
|
+
const operands = args.filter((a) => !a.startsWith("-"));
|
|
67
|
+
switch (name) {
|
|
68
|
+
case "printenv":
|
|
69
|
+
if (!operands.length) denials.push(dumpReason);
|
|
70
|
+
for (const a of operands) if (secretName(a)) denials.push(varReason);
|
|
71
|
+
break;
|
|
72
|
+
case "echo":
|
|
73
|
+
case "printf":
|
|
74
|
+
case "print":
|
|
75
|
+
if (name !== "echo" && args[0] === "-v") display = false;
|
|
76
|
+
if (display && words.some((w) => w.vars.some(secretName))) denials.push(varReason);
|
|
77
|
+
break;
|
|
78
|
+
case "rg":
|
|
79
|
+
case "grep":
|
|
80
|
+
case "ag":
|
|
81
|
+
case "ack":
|
|
82
|
+
if (cmd.flags.has("help")) break;
|
|
83
|
+
for (const w of words) {
|
|
84
|
+
if (w.role === "path" && absPath(w.value, cmd.cwd, home, /^['"]/.test(w.raw)) === `${home}/.ssh`) denials.push(sshReason);
|
|
85
|
+
}
|
|
86
|
+
if (cmd.flags.has("files")) break;
|
|
87
|
+
// Search operands, pattern files and positive globs are read targets;
|
|
88
|
+
// the pattern is not.
|
|
89
|
+
for (const w of words) {
|
|
90
|
+
if (["path", "patfile", "option:patfile"].includes(w.role) && sensitive(w.value, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
|
|
91
|
+
if (["glob", "option:glob"].includes(w.role) && sensitive(basename(w.value), true)) denials.push(fileReason);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case "gh":
|
|
95
|
+
if (args[0] === "auth" && args[1] === "token") denials.push(tokenReason);
|
|
96
|
+
if (args[0] === "auth" && args[1] === "status" && ghShowsToken(args.slice(2))) denials.push(tokenReason);
|
|
97
|
+
break;
|
|
98
|
+
case "glab":
|
|
99
|
+
if (args[0] === "auth" && args[1] === "status" && args.some((a) => /^(--show-token|-[^-]*t)/.test(a))) denials.push(tokenReason);
|
|
100
|
+
break;
|
|
101
|
+
case "security":
|
|
102
|
+
if (args.includes("-w") || args.includes("-g")) denials.push(keychainReason);
|
|
103
|
+
break;
|
|
104
|
+
case "curl":
|
|
105
|
+
denials.push(...curlRules(args, sensitive));
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
if (!readerPrograms.has(name)) return denials;
|
|
109
|
+
let jqFilter = name === "jq" && !args.includes("-f") && !args.includes("--from-file");
|
|
110
|
+
for (const w of words) {
|
|
111
|
+
if (jqFilter && !w.text.startsWith("-")) {
|
|
112
|
+
jqFilter = false;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const target = /^-.*=/s.test(w.text) ? w.text.slice(w.text.indexOf("=") + 1) : w.text;
|
|
116
|
+
if (target && !target.startsWith("-") && sensitive(target, w.globs, /^['"]/.test(w.raw))) denials.push(fileReason);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// A heredoc or here-string body is standard input the command prints.
|
|
120
|
+
if (display && cmd.redirects.some((r) => r.vars.some(secretName))) denials.push(varReason);
|
|
121
|
+
return denials;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A token-display flag counts even when a later flag cancels it.
|
|
125
|
+
function ghShowsToken(args: string[]): boolean {
|
|
126
|
+
for (let i = 0; i < args.length; i++) {
|
|
127
|
+
const a = args[i];
|
|
128
|
+
if (a === "--") return false;
|
|
129
|
+
if (["--hostname", "--jq", "--json", "--template", "-h"].includes(a)) i++;
|
|
130
|
+
else if (a === "--show-token" || a.startsWith("--show-token=") || /^-[at]*t/.test(a)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// curl reads ~/.curlrc and may add credentials the command line never shows,
|
|
136
|
+
// so any trace is denied; @file data and uploads name files it sends.
|
|
137
|
+
function curlRules(args: string[], sensitive: (word: string) => boolean): string[] {
|
|
138
|
+
const denials: string[] = [];
|
|
139
|
+
const long = /^--(data|data-ascii|data-binary|data-urlencode|json|form|header|upload-file|config)(=|$)/s;
|
|
140
|
+
for (let i = 0; i < args.length; i++) {
|
|
141
|
+
const word = args[i];
|
|
142
|
+
let key = "";
|
|
143
|
+
let value = "";
|
|
144
|
+
if (word === "--") break;
|
|
145
|
+
if (/^--(verbose|trace|trace-ascii)(=|$)/.test(word)) denials.push(traceReason);
|
|
146
|
+
else if (long.test(word)) {
|
|
147
|
+
key = word.slice(2).split("=")[0];
|
|
148
|
+
value = word.includes("=") ? word.slice(word.indexOf("=") + 1) : (args[++i] ?? "");
|
|
149
|
+
} else if (/^-[^-]/.test(word)) {
|
|
150
|
+
for (let k = 1; k < word.length; k++) {
|
|
151
|
+
if (word[k] === "v") denials.push(traceReason);
|
|
152
|
+
if (!"AbcCdDeEFHKmoPQrTtuUwxXyYz".includes(word[k])) continue;
|
|
153
|
+
key = word[k];
|
|
154
|
+
value = word.slice(k + 1) || (args[++i] ?? "");
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (key === "d" || key.startsWith("data") || key === "json" || key === "H" || key === "header") {
|
|
159
|
+
if (!/@./s.test(value)) continue;
|
|
160
|
+
value = value.slice(value.indexOf("@") + 1);
|
|
161
|
+
} else if (key === "F" || key === "form") {
|
|
162
|
+
value = value.slice(value.indexOf("=") + 1).replace(/^[@<]/, "").split(";")[0];
|
|
163
|
+
} else if (!["T", "upload-file", "K", "config"].includes(key)) continue;
|
|
164
|
+
if (sensitive(value)) denials.push(uploadReason);
|
|
165
|
+
}
|
|
166
|
+
return denials;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
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*";
|
|
172
|
+
const readerSignature = new RegExp(`(^|[^A-Za-z0-9_])(${readers})([^A-Za-z0-9_-].*)?[\\s/"'=](${names})($|[\\s/"'>|&)\`])`, "s");
|
|
173
|
+
|
|
174
|
+
// Text the front end could not structure: interpreter code, case bodies, or
|
|
175
|
+
// source the parser rejects. Match the commands that would expose a credential.
|
|
176
|
+
function credentialSignatures(fragment: string): string[] {
|
|
177
|
+
const denials: string[] = [];
|
|
178
|
+
for (const segment of fragment.replace(/&&|\|\||\n/g, ";").split(";")) {
|
|
179
|
+
if (
|
|
180
|
+
/(^|[^A-Za-z0-9_-])printenv([^A-Za-z0-9_-]|$)/.test(segment) ||
|
|
181
|
+
/(^|[|(])\s*(env|export|set|typeset|declare)\s*($|[|>)])/.test(segment) ||
|
|
182
|
+
/(declare|typeset|export)\s+-[a-z]*[px]/.test(segment)
|
|
183
|
+
)
|
|
184
|
+
denials.push(dumpReason);
|
|
185
|
+
const reader = segment.match(readerSignature);
|
|
186
|
+
if (reader && !(reader[4].startsWith(".ssh/") && !sshPrivate(`/${reader[4]}`))) denials.push(fileReason);
|
|
187
|
+
if (/curl\s.*\s(-[A-Za-z]*v[A-Za-z]*|--verbose|--trace(-ascii)?)(\s|=|$)/s.test(segment)) denials.push(traceReason);
|
|
188
|
+
if (/gh\s+auth\s+token/.test(segment)) denials.push(tokenReason);
|
|
189
|
+
if (/security\s.*\s-[wg](\s|$)/s.test(segment)) denials.push(keychainReason);
|
|
190
|
+
if (/(ECHO|PRINTF|PRINT)\s.*\$\{?[A-Z0-9_]*(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/s.test(segment.toUpperCase())) denials.push(varReason);
|
|
191
|
+
}
|
|
192
|
+
return denials;
|
|
193
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
import { basename } from "node:path";
|
|
5
|
+
import { reasons } from "../reasons.ts";
|
|
6
|
+
import type { Request } from "../record.ts";
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
find: findReason, replace: replaceReason, include: includeReason,
|
|
10
|
+
bre: breReason, launcher: launcherReason,
|
|
11
|
+
} = reasons;
|
|
12
|
+
|
|
13
|
+
export function claudeWorkflowRules(req: Request): string[] {
|
|
14
|
+
const denials: string[] = [];
|
|
15
|
+
for (const cmd of req.commands) {
|
|
16
|
+
if (cmd.program < 0) continue;
|
|
17
|
+
const name = basename(cmd.argv[cmd.program].text);
|
|
18
|
+
if (name === "find") denials.push(findReason);
|
|
19
|
+
if (name !== "rg") continue;
|
|
20
|
+
if (cmd.flags.has("replace")) denials.push(replaceReason);
|
|
21
|
+
if (cmd.flags.has("include")) denials.push(includeReason);
|
|
22
|
+
if (cmd.flags.has("fixed")) continue;
|
|
23
|
+
for (const w of cmd.argv.slice(cmd.program + 1)) {
|
|
24
|
+
if ((w.role === "pattern" || w.role === "option:pattern") && /(^|[^\\])\\\|/.test(w.value)) denials.push(breReason);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return denials;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function codexWorkflowRules(req: Request): string[] {
|
|
31
|
+
const denials: string[] = [];
|
|
32
|
+
for (const cmd of req.commands) {
|
|
33
|
+
if (cmd.program < 0) continue;
|
|
34
|
+
const program = cmd.argv[cmd.program].text;
|
|
35
|
+
if (basename(program) === "claude" && (program.includes("/") || cmd.wrappers.length)) denials.push(launcherReason);
|
|
36
|
+
}
|
|
37
|
+
return denials;
|
|
38
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Command, ValueRole, Word } from "./record.ts";
|
|
2
|
+
|
|
3
|
+
const rgShortValues = "efgtTEABCmMjrd";
|
|
4
|
+
const rgLongValues = new Set(
|
|
5
|
+
("regexp file glob iglob type type-not encoding replace color colors sort sortr max-depth max-filesize pre pre-glob " +
|
|
6
|
+
"engine threads max-columns type-add type-clear path-separator context-separator field-context-separator " +
|
|
7
|
+
"field-match-separator after-context before-context context max-count ignore-file dfa-size-limit " +
|
|
8
|
+
"regex-size-limit hyperlink-format").split(" "),
|
|
9
|
+
);
|
|
10
|
+
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
|
+
);
|
|
15
|
+
|
|
16
|
+
export function searchRoles(cmd: Command, args: Word[], prog: string) {
|
|
17
|
+
const rg = prog === "rg";
|
|
18
|
+
const shortValues = rg ? rgShortValues : grepShortValues;
|
|
19
|
+
const longValues = rg ? rgLongValues : grepLongValues;
|
|
20
|
+
const flags = cmd.flags;
|
|
21
|
+
const operands: Word[] = [];
|
|
22
|
+
let options = true;
|
|
23
|
+
|
|
24
|
+
const value = (at: number, key: string, text: string) => {
|
|
25
|
+
const target = args[at];
|
|
26
|
+
if (!target) return;
|
|
27
|
+
let role: ValueRole = "optarg";
|
|
28
|
+
if (key === "e" || key === "regexp") (role = "pattern"), flags.add("explicit");
|
|
29
|
+
else if (key === "f" || key === "file") (role = "patfile"), flags.add("explicit");
|
|
30
|
+
else if (["g", "glob", "iglob", "include"].includes(key)) role = text.startsWith("!") ? "nglob" : "glob";
|
|
31
|
+
target.role = target.role === "option" ? `option:${role}` : role;
|
|
32
|
+
target.value = text;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < args.length; i++) {
|
|
36
|
+
const word = args[i].text;
|
|
37
|
+
if (options && word === "--") {
|
|
38
|
+
options = false;
|
|
39
|
+
args[i].role = "option";
|
|
40
|
+
} else if (options && /^--./.test(word)) {
|
|
41
|
+
args[i].role = "option";
|
|
42
|
+
const key = word.slice(2).split("=")[0];
|
|
43
|
+
if (key === "files") flags.add("files");
|
|
44
|
+
else if (key === "help" || key === "version") flags.add("help");
|
|
45
|
+
else if (key.startsWith("no-ignore") || key === "unrestricted") flags.add("noignore");
|
|
46
|
+
else if (key === "fixed-strings") flags.add("fixed");
|
|
47
|
+
else if (key === "recursive") flags.add("recursive");
|
|
48
|
+
else if (key === "include") flags.add("include");
|
|
49
|
+
if (word.includes("=")) value(i, key, word.slice(word.indexOf("=") + 1));
|
|
50
|
+
else if (longValues.has(key)) i++, value(i, key, args[i]?.text ?? "");
|
|
51
|
+
} else if (options && /^-./.test(word)) {
|
|
52
|
+
args[i].role = "option";
|
|
53
|
+
for (let k = 1; k < word.length; k++) {
|
|
54
|
+
const c = word[k];
|
|
55
|
+
if ((rg || prog === "grep") && c === "F") flags.add("fixed");
|
|
56
|
+
if (rg && c === "u") flags.add("noignore");
|
|
57
|
+
if (prog === "grep" && (c === "r" || c === "R")) flags.add("recursive");
|
|
58
|
+
if ((rg && c === "h") || c === "V") flags.add("help");
|
|
59
|
+
if (rg && c === "r") flags.add("replace");
|
|
60
|
+
if (!shortValues.includes(c)) continue;
|
|
61
|
+
let text = word.slice(k + 1);
|
|
62
|
+
if (rg) text = text.replace(/^=/, "");
|
|
63
|
+
if (!text) i++, (text = args[i]?.text ?? "");
|
|
64
|
+
value(i, c, text);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
operands.push(args[i]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let explicit = flags.has("explicit") || flags.has("files");
|
|
72
|
+
for (const operand of operands) {
|
|
73
|
+
operand.role = explicit ? "path" : "pattern";
|
|
74
|
+
explicit = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/words.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sh from "mvdan-sh";
|
|
2
|
+
import type { DblQuoted, Lit, Node, ParamExp, SglQuoted, Word as WordNode } from "mvdan-sh";
|
|
3
|
+
import { expandHome } from "./paths.ts";
|
|
4
|
+
import type { Word } from "./record.ts";
|
|
5
|
+
|
|
6
|
+
const type = sh.syntax.NodeType;
|
|
7
|
+
|
|
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 {
|
|
15
|
+
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: "" };
|
|
17
|
+
const expansion = (part: Node) => {
|
|
18
|
+
const param = part as ParamExp;
|
|
19
|
+
const plain = type(part) === "ParamExp" &&
|
|
20
|
+
!(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
|
|
21
|
+
const known = plain ? (param.Param.Value === "HOME" ? home : vars.get(param.Param.Value)) : undefined;
|
|
22
|
+
out.text += known ?? text(part);
|
|
23
|
+
out.expands ||= known === undefined;
|
|
24
|
+
visit(part, out.vars);
|
|
25
|
+
};
|
|
26
|
+
node.Parts.forEach((part, index) => {
|
|
27
|
+
const kind = type(part);
|
|
28
|
+
if (kind === "Lit") {
|
|
29
|
+
let value = (part as Lit).Value;
|
|
30
|
+
if (index === 0) value = expandHome(value, home);
|
|
31
|
+
if (Bun.$.braces(value).length > 1) out.globs = true;
|
|
32
|
+
for (let i = 0; i < value.length; i++) {
|
|
33
|
+
if (value[i] === "\\") {
|
|
34
|
+
out.text += value[++i] ?? "";
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if ("*?[".includes(value[i])) out.globs = true;
|
|
38
|
+
out.text += value[i];
|
|
39
|
+
}
|
|
40
|
+
} else if (kind === "SglQuoted") {
|
|
41
|
+
const quoted = part as SglQuoted;
|
|
42
|
+
out.text += quoted.Dollar
|
|
43
|
+
? quoted.Value.replace(/\\(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{4})|([0-7]{1,3})|(.))/gs, (match, hex, unicode, octal, char) => {
|
|
44
|
+
if (hex || unicode || octal) return String.fromCodePoint(parseInt(hex ?? unicode ?? octal, octal ? 8 : 16));
|
|
45
|
+
return ({ a: "\x07", b: "\b", e: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" } as Record<string, string>)[char] ?? char;
|
|
46
|
+
})
|
|
47
|
+
: quoted.Value;
|
|
48
|
+
} else if (kind === "DblQuoted") {
|
|
49
|
+
for (const inner of (part as DblQuoted).Parts) {
|
|
50
|
+
if (type(inner) === "Lit") out.text += (inner as Lit).Value.replace(/\\\n/g, "").replace(/\\([$`"\\])/g, "$1");
|
|
51
|
+
else expansion(inner);
|
|
52
|
+
}
|
|
53
|
+
} else if (kind === "ExtGlob") {
|
|
54
|
+
out.globs = true;
|
|
55
|
+
out.text += text(part);
|
|
56
|
+
} else {
|
|
57
|
+
expansion(part);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
out.value = out.text;
|
|
61
|
+
return out;
|
|
62
|
+
}
|