@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.
@@ -0,0 +1,240 @@
1
+ // Parse shell structure into command records; rejected syntax remains
2
+ // available to the signature rules.
3
+ import sh from "mvdan-sh";
4
+ import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
5
+ import { resolve } from "node:path";
6
+ import { resolveCommand, stdinKind } from "./argv.ts";
7
+ import { misreadComment } from "./comments.ts";
8
+ import { boundedDirectories } from "./cwd.ts";
9
+ import { movedOnSuccess } from "./moves.ts";
10
+ import { expandHome } from "./paths.ts";
11
+ import { xargsReplacements } from "./pipeline.ts";
12
+ import type { Command, Redirect, Script, Word } from "./record.ts";
13
+ import { readWord } from "./words.ts";
14
+
15
+ const { syntax } = sh;
16
+ const parser = syntax.NewParser(syntax.KeepComments(true), syntax.Variant(syntax.LangBash));
17
+
18
+ interface Scope {
19
+ readonly dir: { cwd: string; previous?: string; alternatives?: string[] }; // shared by the commands a cd in this scope moves
20
+ readonly vars: Map<string, string>;
21
+ readonly slice: (start: number, end: number) => string;
22
+ }
23
+
24
+ const type = (node: Node) => syntax.NodeType(node);
25
+
26
+ export function parseScript(source: string, cwd: string, home: string): Script {
27
+ const script: Script = { commands: [], uninspectable: [] };
28
+
29
+ function parse(src: string, cwd: string) {
30
+ const bytes = Buffer.from(src);
31
+ let file;
32
+ try {
33
+ file = parser.Parse(src, "");
34
+ } catch {
35
+ file = undefined;
36
+ }
37
+ if (!file || misreadComment(file, bytes)) {
38
+ script.uninspectable.push(src);
39
+ return;
40
+ }
41
+ const slice = (start: number, end: number) => bytes.subarray(start, end).toString();
42
+ const scope: Scope = { dir: { cwd }, vars: new Map(), slice };
43
+ for (const stmt of file.Stmts) statement(stmt, scope);
44
+ }
45
+
46
+ const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
47
+ // A cd inside runs in a separate shell and leaves this scope's cwd alone.
48
+ const isolated = (scope: Scope): Scope => ({ ...scope, dir: { ...scope.dir }, vars: new Map(scope.vars) });
49
+
50
+ function statement(stmt: Stmt, outer: Scope) {
51
+ const scope = stmt.Background ? isolated(outer) : outer;
52
+ const cmd = stmt.Cmd;
53
+ const kind = cmd && type(cmd);
54
+ if (kind === "CallExpr" || kind === "DeclClause") return simple(cmd, stmt.Redirs, scope);
55
+ // Redirections on a compound command, or with no command at all, still
56
+ // open their files.
57
+ if (stmt.Redirs.length) simple(null, stmt.Redirs, scope);
58
+ if (!cmd) return;
59
+ if (kind === "BinaryCmd") return binary(cmd as BinaryCmd, scope);
60
+ if (kind === "Subshell") {
61
+ const inner = isolated(scope);
62
+ for (const s of (cmd as Subshell).Stmts) statement(s, inner);
63
+ return;
64
+ }
65
+ children(cmd, scope);
66
+ }
67
+
68
+ // Only the left side of && moves the directory the right side runs in.
69
+ function binary(cmd: BinaryCmd, scope: Scope) {
70
+ const op = scope.slice(cmd.OpPos.Offset(), cmd.OpPos.Offset() + 2).trim();
71
+ const failed = isolated(scope);
72
+ const start = script.commands.length;
73
+ statement(cmd.X, op === "&&" || op === "||" ? scope : isolated(scope));
74
+ const middle = script.commands.length;
75
+ const directCd = op === "&&" && movedOnSuccess(cmd.X, scope.slice);
76
+ if (directCd) {
77
+ const old = [failed.dir.cwd, ...(failed.dir.alternatives ?? [])];
78
+ scope.dir.alternatives = scope.dir.alternatives?.filter((cwd) => !old.includes(cwd));
79
+ statement(cmd.Y, scope);
80
+ scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
81
+ return;
82
+ }
83
+ statement(cmd.Y, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
84
+ if (op === "|") for (const item of xargsReplacements(script.commands.slice(start, middle), script.commands.slice(middle))) parse(item.source, item.cwd);
85
+ if (op === "||") {
86
+ scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), failed.dir.cwd, ...(failed.dir.alternatives ?? [])], home);
87
+ }
88
+ }
89
+
90
+ // Compound words may hold substitutions; branch bodies may not run.
91
+ function children(node: Node, scope: Scope) {
92
+ if (type(node) === "FuncDecl") scope = isolated(scope);
93
+ if (type(node) === "IfClause") return conditional(node as IfClause, scope);
94
+ const outer = scope;
95
+ const loop = type(node) === "WhileClause" || type(node) === "ForClause";
96
+ if (loop) scope = isolated(scope);
97
+ syntax.Walk(node, (child) => {
98
+ if (!child || child === node) return true;
99
+ const kind = type(child);
100
+ if (kind === "Stmt") statement(child as Stmt, scope);
101
+ else if (kind === "Word") {
102
+ const value = word(child as WordNode, scope);
103
+ for (const cwd of [scope.dir.cwd, ...(scope.dir.alternatives ?? [])]) {
104
+ script.commands.push({ argv: [value], redirects: [], cwd, program: -1, wrappers: [], shell: true, flags: new Set() });
105
+ }
106
+ }
107
+ else return true;
108
+ return false;
109
+ });
110
+ if (loop) outer.dir.alternatives = boundedDirectories(outer.dir.cwd, [...(outer.dir.alternatives ?? []), scope.dir.cwd, ...(scope.dir.alternatives ?? [])], home);
111
+ }
112
+
113
+ function conditional(node: IfClause, scope: Scope) {
114
+ const condition = isolated(scope);
115
+ for (const stmt of node.Cond) statement(stmt, condition);
116
+ const then = isolated(condition);
117
+ for (const stmt of node.Then) statement(stmt, then);
118
+ const branches = [scope, condition, then];
119
+ if (node.Else) {
120
+ const otherwise = isolated(condition);
121
+ conditional(node.Else, otherwise);
122
+ branches.push(otherwise);
123
+ }
124
+ scope.dir.alternatives = boundedDirectories(scope.dir.cwd, branches.flatMap((branch) => [branch.dir.cwd, ...(branch.dir.alternatives ?? [])]), home);
125
+ }
126
+
127
+ function expansions(node: Node, scope: Scope, vars: string[]) {
128
+ syntax.Walk(node, (child) => {
129
+ if (!child) return true;
130
+ const kind = type(child);
131
+ if (kind === "ParamExp") vars.push((child as ParamExp).Param.Value);
132
+ if (kind !== "CmdSubst" && kind !== "ProcSubst") return true;
133
+ const inner = isolated(scope);
134
+ for (const s of (child as CmdSubst).Stmts) statement(s, inner);
135
+ return false;
136
+ });
137
+ }
138
+
139
+ function word(node: WordNode, scope: Scope): Word {
140
+ return readWord(node, scope.slice, scope.vars, home, (part, names) => expansions(part, scope, names));
141
+ }
142
+
143
+ function assign(node: Assign, scope: Scope, role: "assign" | "arg"): Word {
144
+ const name = node.Name?.Value ?? "";
145
+ const value = node.Value ? word(node.Value, scope) : undefined;
146
+ // Array and index expressions can hold substitutions outside the value.
147
+ syntax.Walk(node, (child) => {
148
+ if (!child || child === node) return true;
149
+ if (child === node.Value) return false;
150
+ if (type(child) === "Word") word(child as WordNode, scope);
151
+ return type(child) !== "Word";
152
+ });
153
+ return {
154
+ ...(value ?? { expands: false, globs: false, vars: [] }),
155
+ text: `${name}=${value?.text ?? ""}`,
156
+ raw: text(node, scope),
157
+ role,
158
+ value: `${name}=${value?.text ?? ""}`,
159
+ };
160
+ }
161
+
162
+ function literal(value: string): Word {
163
+ return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
164
+ }
165
+
166
+ function simple(cmd: Node | null, redirs: RedirectNode[], scope: Scope) {
167
+ const argv: Word[] = [];
168
+ if (cmd && type(cmd) === "CallExpr") {
169
+ for (const a of (cmd as CallExpr).Assigns) argv.push(assign(a, scope, "assign"));
170
+ for (const w of (cmd as CallExpr).Args) argv.push(word(w, scope));
171
+ } else if (cmd) {
172
+ const decl = cmd as DeclClause;
173
+ argv.push(literal(decl.Variant.Value));
174
+ for (const a of decl.Args) {
175
+ if (!a.Naked) argv.push(assign(a, scope, "arg"));
176
+ else argv.push(a.Value ? word(a.Value, scope) : literal(a.Name?.Value ?? ""));
177
+ }
178
+ }
179
+ const redirects = redirs.map((r) => redirect(r, scope)).filter((r) => r !== undefined);
180
+ const command: Command = {
181
+ argv,
182
+ redirects,
183
+ cwd: scope.dir.cwd,
184
+ program: -1,
185
+ wrappers: [],
186
+ shell: true,
187
+ flags: new Set(),
188
+ };
189
+ script.commands.push(command);
190
+ const { children: sources, code } = resolveCommand(command, home);
191
+ for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd });
192
+ for (const src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
193
+ script.uninspectable.push(...code);
194
+ for (const r of redirects) {
195
+ if (r.direction !== "heredoc" && r.direction !== "herestring") continue;
196
+ const stdin = stdinKind(command);
197
+ if (stdin === "shell") parse(r.target, command.cwd);
198
+ if (stdin === "code") script.uninspectable.push(r.target);
199
+ }
200
+ track(command, scope);
201
+ }
202
+
203
+ function redirect(node: RedirectNode, scope: Scope): Redirect | undefined {
204
+ const op = scope.slice(node.OpPos.Offset(), node.Word.Pos().Offset()).trim();
205
+ if (op === "<<" || op === "<<-") {
206
+ const quoted = node.Word.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
207
+ const vars: string[] = [];
208
+ if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
209
+ return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
210
+ }
211
+ const target = word(node.Word, scope);
212
+ if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
213
+ if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
214
+ const direction = op === "<" || op === "<>" ? "in" : "out";
215
+ return { direction, target: target.text, vars: [] };
216
+ }
217
+
218
+ function track(command: Command, scope: Scope) {
219
+ const program = command.argv[command.program];
220
+ const declaration = command.shell && ["export", "local", "declare", "typeset"].includes(program?.text ?? "");
221
+ for (const word of command.argv.filter((w) => ((command.program < 0 && w.role === "precommand") || (declaration && w.role === "arg")) && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text))) {
222
+ const at = word.text.indexOf("=");
223
+ if (!word.expands) scope.vars.set(word.text.slice(0, at), expandHome(word.text.slice(at + 1), home));
224
+ }
225
+ if (!program || !command.shell || !["cd", "pushd"].includes(program.text)) return;
226
+ let target = command.argv.slice(command.program + 1).find((w) => !w.text.startsWith("-"))?.text;
227
+ const previous = command.argv.slice(command.program + 1).some((w) => w.text === "-");
228
+ if (previous) target = scope.dir.previous;
229
+ if (previous && target === undefined) return;
230
+ if (target === undefined && program.text === "cd") target = home;
231
+ if (target !== undefined) {
232
+ scope.dir.alternatives = boundedDirectories(resolve(scope.dir.cwd, target), [scope.dir.cwd, ...(scope.dir.alternatives ?? []), ...(scope.dir.alternatives ?? []).map((cwd) => resolve(cwd, target))], home);
233
+ scope.dir.previous = scope.dir.cwd;
234
+ scope.dir.cwd = resolve(scope.dir.cwd, target);
235
+ }
236
+ }
237
+
238
+ parse(source, cwd);
239
+ return script;
240
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Entry of the agent guard, run by bin/agent-guard:
2
+ // bun guard.ts --runtime claude|codex|pi --cwd HOOK_DIR < event.json
3
+ // The event is a PreToolUse event in Claude's shape; Codex sends only Bash
4
+ // events, with the cwd possibly inside tool_input. HOOK_DIR, the hook's own
5
+ // working directory, stands in when the event names none. Exit 0 means no
6
+ // objection and exit 2 denies with the reason on stderr. The wrapper turns
7
+ // every other outcome into a denial.
8
+ import { homedir } from "node:os";
9
+ import { buildRequest, evaluate, suggestions } from "./core.ts";
10
+ import type { Runtime, Tool } from "./record.ts";
11
+
12
+ const [flag, runtime, cwdFlag, hookCwd] = process.argv.slice(2) as [string, Runtime, string, string];
13
+ if (flag !== "--runtime" || !["claude", "codex", "pi"].includes(runtime) || cwdFlag !== "--cwd" || !hookCwd) {
14
+ console.error("usage: agent-guard --runtime claude|codex|pi < event.json");
15
+ process.exit(2);
16
+ }
17
+
18
+ // A missing tool_input throws, which the wrapper turns into a denial. A
19
+ // tool_input without the field a tool needs names nothing to check and passes.
20
+ const event: { tool_name?: unknown; cwd?: unknown; tool_input: Record<string, unknown> } = JSON.parse(await Bun.stdin.text());
21
+ const input = event.tool_input;
22
+ const string = (value: unknown) => (typeof value === "string" ? value : undefined);
23
+ const cwd = string(event.cwd) ?? string(input.cwd) ?? hookCwd;
24
+
25
+ const tools: Record<string, [Tool, string | undefined]> = {
26
+ bash: ["bash", string(input.command)],
27
+ read: ["read", string(input.file_path)],
28
+ edit: ["edit", string(input.file_path)],
29
+ write: ["write", string(input.file_path)],
30
+ grep: ["grep", string(input.path) ?? ""],
31
+ };
32
+ const [tool, value] = tools[(string(event.tool_name) ?? "Bash").toLowerCase()] ?? [];
33
+ const request = tool && value !== undefined ? buildRequest(runtime, tool, cwd, value, string(input.glob) ?? "", homedir()) : undefined;
34
+ const reason = request && evaluate(request);
35
+ if (reason) {
36
+ console.error(runtime === "claude" ? `DENIED: ${reason} Do NOT bypass this restriction or retry the same blocked command.` : reason);
37
+ process.exitCode = 2;
38
+ } else if (request) {
39
+ const advice = suggestions(request);
40
+ if (advice.length) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: advice.join("\n") } }));
41
+ }
package/src/links.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { readlinkSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { expandHome, isAppdata, isSensitive } from "./paths.ts";
4
+ import type { Request, Word } from "./record.ts";
5
+ import { dataPrograms } from "./rules/appdata.ts";
6
+
7
+ export function linkedRequest(req: Request): Request | undefined {
8
+ let changed = false;
9
+ const protectedPath = (path: string) => isAppdata(path, req.home) || isSensitive(path);
10
+ const linked = (value: string, cwd: string, quoted = false): string => {
11
+ const input = quoted ? value : expandHome(value, req.home);
12
+ const absolute = input.startsWith("/") ? input : `${cwd}/${input}`;
13
+ let path = "/";
14
+ let parts = absolute.split("/").filter(Boolean);
15
+ let followed = false;
16
+ let depth = 0;
17
+ while (parts.length) {
18
+ path = resolve(path, parts.shift()!);
19
+ // Do not ask the filesystem about a path inside a protected tree.
20
+ if (protectedPath(path)) {
21
+ changed = true;
22
+ return path;
23
+ }
24
+ let target: string;
25
+ try {
26
+ target = readlinkSync(path);
27
+ } catch {
28
+ continue;
29
+ }
30
+ if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
31
+ const targetPath = resolve(dirname(path), target);
32
+ followed = true;
33
+ changed = true;
34
+ if (protectedPath(targetPath)) return targetPath;
35
+ const next = resolve(targetPath, ...parts);
36
+ if (protectedPath(next)) return next;
37
+ parts = next.split("/").filter(Boolean);
38
+ path = "/";
39
+ }
40
+ return followed ? path : value;
41
+ };
42
+ const word = (w: Word, cwd: string): Word => {
43
+ if (w.expands || w.globs || !w.value || w.value.startsWith("-")) return w;
44
+ if (w.role === "program" && !w.value.includes("/")) return w;
45
+ if (!["arg", "path", "patfile", "option:patfile", "program"].includes(w.role)) return w;
46
+ const value = linked(w.value, cwd, /^[\'"]/.test(w.raw));
47
+ return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
48
+ };
49
+ const commands = req.commands.map((cmd) => ({
50
+ ...cmd,
51
+ argv: cmd.argv.map((w, i) =>
52
+ dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : word(w, cmd.cwd)),
53
+ redirects: cmd.redirects.map((r) =>
54
+ (r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r),
55
+ }));
56
+ const target = req.target ? linked(req.target, req.cwd) : req.target;
57
+ const searchRoot = req.searchRoot ? linked(req.searchRoot, req.cwd) : req.searchRoot;
58
+ return changed ? { ...req, target, searchRoot, commands } : undefined;
59
+ }
package/src/moves.ts ADDED
@@ -0,0 +1,28 @@
1
+ import sh from "mvdan-sh";
2
+ import type { BinaryCmd, CallExpr, Lit, Stmt } from "mvdan-sh";
3
+
4
+ // Discard the prior cwd only when every move in an && chain has a literal destination.
5
+ export function movedOnSuccess(stmt: Stmt, slice: (start: number, end: number) => string): boolean {
6
+ const type = sh.syntax.NodeType;
7
+ const state = (item: Stmt): "moved" | "unchanged" | "uncertain" => {
8
+ const node = item.Cmd;
9
+ if (!node) return "unchanged";
10
+ if (type(node) === "CallExpr") {
11
+ const call = node as CallExpr;
12
+ const first = call.Args[0]?.Parts[0];
13
+ const name = first && type(first) === "Lit" ? (first as Lit).Value : "";
14
+ if (name === "popd") return "uncertain";
15
+ if (name !== "cd" && name !== "pushd") return "unchanged";
16
+ const target = call.Args[1];
17
+ const part = target?.Parts[0];
18
+ return target?.Parts.length === 1 && part && type(part) === "Lit" && (part as Lit).Value !== "-" ? "moved" : "uncertain";
19
+ }
20
+ if (type(node) !== "BinaryCmd") return "uncertain";
21
+ const chain = node as BinaryCmd;
22
+ if (slice(chain.OpPos.Offset(), chain.OpPos.Offset() + 2) !== "&&") return "uncertain";
23
+ const left = state(chain.X);
24
+ const right = state(chain.Y);
25
+ return left === "uncertain" || right === "uncertain" ? "uncertain" : left === "moved" || right === "moved" ? "moved" : "unchanged";
26
+ };
27
+ return state(stmt) === "moved";
28
+ }
@@ -0,0 +1,92 @@
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
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,149 @@
1
+ // Filesystem checks run after lexical denials to avoid touching protected trees.
2
+ import { statSync, realpathSync } from "node:fs";
3
+ import { userInfo } from "node:os";
4
+ import { basename, dirname, resolve } from "node:path";
5
+
6
+ export const appdataTrees = ["Containers", "Group Containers", "Mobile Documents", "CloudStorage"];
7
+
8
+ // The one list of credential-bearing paths, matched against absolute paths.
9
+ export const sensitivePaths = [
10
+ "**/.env", "**/.env.local*", "**/.env.*.local",
11
+ "**/.env.production", "**/.env.staging", "**/.env.development",
12
+ "**/.npmrc", "**/.zprofile*", "**/.zsh_history*", "**/*.pem", "**/*.key",
13
+ "**/auth.json*", "**/.credentials.json*", "**/.aws/credentials*",
14
+ "**/private-keys-v1.d", "**/private-keys-v1.d/**",
15
+ ];
16
+
17
+ const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
18
+
19
+ export function expandHome(path: string, home: string): string {
20
+ for (const prefix of ["~", `~${userInfo().username}`]) {
21
+ if (path === prefix || path.startsWith(`${prefix}/`)) return home + path.slice(prefix.length);
22
+ }
23
+ return path;
24
+ }
25
+
26
+ export function absPath(path: string, cwd: string, home: string, quoted = false): string {
27
+ if (!quoted) path = expandHome(path, home);
28
+ return resolve(cwd, path);
29
+ }
30
+
31
+ export function isAppdata(path: string, home: string, glob = false): boolean {
32
+ if (glob) {
33
+ const expanded = Bun.$.braces(path);
34
+ if (expanded.length > 1) return expanded.some((each) => isAppdata(each, home, true));
35
+ }
36
+ const library = `${home}/Library/`.toLowerCase();
37
+ if (!path.toLowerCase().startsWith(library)) return false;
38
+ const rest = path.slice(library.length).toLowerCase();
39
+ if (appdataTrees.some((tree) => rest === tree.toLowerCase() || rest.startsWith(`${tree.toLowerCase()}/`))) return true;
40
+ if (!glob) return false;
41
+ const fixed = rest.split(/[*?[]/)[0].replace(/\/$/, "");
42
+ return fixed !== "" && appdataTrees.some((tree) => tree.toLowerCase().startsWith(fixed));
43
+ }
44
+
45
+ export function isBroad(path: string, home: string, glob = false): boolean {
46
+ if (glob) {
47
+ const expanded = Bun.$.braces(path);
48
+ if (expanded.length > 1) return expanded.some((each) => isBroad(each, home, true));
49
+ }
50
+ path = path.toLowerCase();
51
+ home = home.toLowerCase();
52
+ if (path === "/") return true;
53
+ const trimmed = path.replace(/\/$/, "");
54
+ if (trimmed === home || trimmed === `${home}/library` || home.startsWith(`${trimmed}/`)) return true;
55
+ if (!glob) return false;
56
+ const pattern = new Bun.Glob(path);
57
+ if ([home, `${home}/library`, ...appdataTrees.flatMap((tree) => [`${home}/library/${tree.toLowerCase()}`, `${home}/library/${tree.toLowerCase()}/x`])].some((candidate) => pattern.match(candidate))) return true;
58
+ const prefix = path.split(/[*?[]/, 1)[0].replace(/\/$/, "");
59
+ return path.includes("**") && (prefix === home || prefix === `${home}/library` || home.startsWith(`${prefix}/`));
60
+ }
61
+
62
+ // ~/.ignore keeps rg and fd out of ~/Library only when they start above it.
63
+ export function isLibrary(path: string, home: string): boolean {
64
+ return path.toLowerCase() === `${home}/library`.toLowerCase() || isAppdata(path, home);
65
+ }
66
+
67
+ const sshPublic = (name: string) => /^(config|config\..*|.*\.pub|allowed_signers|known_hosts.*)$/s.test(name);
68
+
69
+ // Under ~/.ssh only a top-level client config, public key, allowed_signers or
70
+ // known_hosts file is public.
71
+ export function sshPrivate(path: string): boolean {
72
+ const at = path.lastIndexOf("/.ssh/");
73
+ if (at < 0 || at + 6 >= path.length) return false;
74
+ const rest = path.slice(at + 6);
75
+ return rest.includes("/") || !sshPublic(rest);
76
+ }
77
+
78
+ // A credential-bearing absolute path, after brace expansion. For an unquoted
79
+ // glob, a pattern counts when it could match a listed name; a bare wildcard
80
+ // does not.
81
+ export function isSensitive(path: string, glob = false): boolean {
82
+ const expanded = Bun.$.braces(path);
83
+ if (expanded.length > 1) return expanded.some((each) => isSensitive(each, glob));
84
+ if (sshPrivate(path) || sensitiveGlobs.some((listed) => listed.match(path))) return true;
85
+ const base = basename(path);
86
+ if (!glob || /^[*?]*$/.test(base)) return false;
87
+ const pattern = new Bun.Glob(base);
88
+ return sensitivePaths.some((listed) => {
89
+ const name = basename(listed).replaceAll("*", "x");
90
+ return !/^x*$/.test(name) && pattern.match(name);
91
+ });
92
+ }
93
+
94
+ // A path that does not exist or cannot be searched has no inode to compare.
95
+ function stat(path: string) {
96
+ try {
97
+ return statSync(path);
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ function sameFile(a: string, b: string): boolean {
104
+ if (a === b) return true;
105
+ const x = stat(a);
106
+ const y = stat(b);
107
+ return x !== undefined && y !== undefined && x.dev === y.dev && x.ino === y.ino;
108
+ }
109
+
110
+ function real(path: string): string {
111
+ try {
112
+ return realpathSync(path);
113
+ } catch {
114
+ return path;
115
+ }
116
+ }
117
+
118
+ function kind(path: string): "dir" | "file" | "other" {
119
+ const st = stat(path);
120
+ return st?.isDirectory() ? "dir" : st?.isFile() ? "file" : "other";
121
+ }
122
+
123
+ // Filesystem check for a file tool target, or a search root when search is
124
+ // set. It follows symlinks and compares inodes so a link or a case alias of
125
+ // ~/.ssh resolves to the directory it names.
126
+ export function sshScopeDenied(target: string, home: string, search: boolean): boolean {
127
+ const ssh = `${home}/.ssh`;
128
+ const candidates = [...new Set([target, real(target)])];
129
+ const roots = [...new Set([ssh, real(ssh)])];
130
+ for (const candidate of candidates) {
131
+ for (const root of roots) {
132
+ if (sameFile(candidate, root)) return true;
133
+ if (search) {
134
+ for (let parent = root; parent !== "/"; ) {
135
+ parent = dirname(parent);
136
+ if (sameFile(candidate, parent)) return true;
137
+ }
138
+ }
139
+ for (let parent = candidate; parent !== "/"; ) {
140
+ parent = dirname(parent);
141
+ if (!sameFile(parent, root)) continue;
142
+ if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
143
+ if (kind(target) === "dir") return true;
144
+ if (search && kind(target) !== "file") return true;
145
+ }
146
+ }
147
+ }
148
+ return false;
149
+ }
@@ -0,0 +1,26 @@
1
+ import { basename } from "node:path";
2
+ import type { Command } from "./record.ts";
3
+
4
+ // A literal printf value is known before xargs substitutes it into a command.
5
+ export function xargsReplacements(left: Command[], right: Command[]): { source: string; cwd: string }[] {
6
+ const printf = left.find((cmd) => cmd.program >= 0 && basename(cmd.argv[cmd.program].text) === "printf");
7
+ const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
8
+ if (!printf || !xargs) return [];
9
+ const args = printf.argv.slice(printf.program + 1);
10
+ if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0].text)) return [];
11
+
12
+ const options = xargs.argv.slice(0, xargs.program);
13
+ let marker = "";
14
+ for (let i = 0; i < options.length; i++) {
15
+ const option = options[i].text;
16
+ if (option === "-I" || option === "--replace") marker = options[i + 1]?.text ?? "";
17
+ else if (option.startsWith("-I")) marker = option.slice(2);
18
+ else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
19
+ }
20
+ if (!marker) return [];
21
+ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
22
+ const source = xargs.argv.slice(xargs.program).map((word) =>
23
+ word.text.includes(marker) ? quote(word.text.replaceAll(marker, args[1].text)) : word.raw,
24
+ ).join(" ");
25
+ return [{ source, cwd: xargs.cwd }];
26
+ }
package/src/reasons.ts ADDED
@@ -0,0 +1,19 @@
1
+ export const reasons = {
2
+ appdata: "This reads a protected macOS app-data directory. Name a specific non-sensitive file under ~/Library/Application Support instead, or ask the user to inspect the protected file and share the needed fact.",
3
+ broad: "A scan rooted at the home directory or ~/Library reaches every app-data entry. Scope the scan to a project path.",
4
+ file: "This reads a credential or environment file. Read a non-sensitive config file instead; if a fact from this file is needed, ask the user to inspect it and share only that fact.",
5
+ dump: "This dumps environment or shell variables, including secrets. Name the non-sensitive variable needed and read only that variable.",
6
+ variable: "This prints the value of a credential variable. Ask the user for the specific non-sensitive fact needed, or let the authorized client consume the credential without printing it.",
7
+ token: "This prints a Git hosting token. Use auth status without token-display flags; if authentication needs repair, ask the user to update the credential in their terminal.",
8
+ keychain: "This extracts a password from the macOS Keychain. State the intended use and run the authorized client that consumes it without printing it.",
9
+ trace: "curl verbose or trace output can print HTTP headers including Authorization. Drop -v and --trace; use a normal curl request for the needed result.",
10
+ upload: "This sends the contents of a credential file. Send only the required non-sensitive fields explicitly, and let the client obtain authentication from its normal credential source.",
11
+ ssh: "This reads private material under ~/.ssh. Search public material in the project or request the exact public key or client-config path; ask the user to inspect private material locally if a specific non-sensitive fact is needed.",
12
+ grepSsh: "Grep would search private ~/.ssh material. Narrow the search to a project directory or an exact public key, client config, allowed_signers, or known_hosts file.",
13
+ symlink: "The agent guard could not complete its symlink check. Name the direct non-sensitive file outside protected trees, or ask the user to inspect the target locally.",
14
+ find: "find is harder to scope for this repository's searches. Use fd with a project-root path instead.",
15
+ replace: "rg -r means --replace. Drop -r; use -n for line numbers, or spell --replace VALUE for an intentional replacement.",
16
+ include: "rg has no --include flag. Filter files with -g GLOB (for example -g '*.ts') or a type filter such as -t ts.",
17
+ bre: "rg regex is not grep BRE: a\\|b matches a literal pipe. Write alternation as a|b; for a literal pipe, use [|] or -F.",
18
+ launcher: "An explicit path or wrapper bypasses the provider-aware Claude launcher. Run Claude as `claude ...` to use the persisted mode.",
19
+ } as const;