@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/src/frontend.ts CHANGED
@@ -1,22 +1,39 @@
1
- // Parse shell structure into command records; rejected syntax remains
2
- // available to the signature rules.
3
- import sh from "mvdan-sh";
1
+ // Parse shell structure into command records. Failed syntax blocks the call;
2
+ // interpreter code still reaches the signature rules.
3
+
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
4
8
  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";
9
+ import sh from "mvdan-sh";
10
+
11
+ import { resolveCommand, stdinKind } from "./argv";
12
+ import { misreadComment } from "./comments";
13
+ import { boundedDirectories } from "./cwd";
14
+ import { movedOnSuccess } from "./moves";
15
+ import { expandHome } from "./paths";
16
+ import { xargsReplacements } from "./pipeline";
17
+ import type { Command, Redirect, Script, Word } from "./record";
18
+ import { readWord } from "./words";
14
19
 
15
20
  const { syntax } = sh;
21
+ let packageDir = import.meta.dir;
22
+ let parserPath = "";
23
+ while (!parserPath) {
24
+ const candidate = join(packageDir, "node_modules/mvdan-sh/index.js");
25
+ if (existsSync(candidate)) parserPath = candidate;
26
+ else {
27
+ const parent = dirname(packageDir);
28
+ if (parent === packageDir) throw new Error("mvdan-sh dependency is missing");
29
+ packageDir = parent;
30
+ }
31
+ }
32
+ if (sh !== (await import(pathToFileURL(parserPath).href)).default) throw new Error("mvdan-sh resolved outside its dependency package");
16
33
  const parser = syntax.NewParser(syntax.KeepComments(true), syntax.Variant(syntax.LangBash));
17
34
 
18
35
  interface Scope {
19
- readonly dir: { cwd: string; previous?: string; alternatives?: string[] }; // shared by the commands a cd in this scope moves
36
+ readonly dir: { cwd: string; alternatives?: string[] | undefined }; // shared by the commands a cd in this scope moves
20
37
  readonly vars: Map<string, string>;
21
38
  readonly slice: (start: number, end: number) => string;
22
39
  }
@@ -24,7 +41,7 @@ interface Scope {
24
41
  const type = (node: Node) => syntax.NodeType(node);
25
42
 
26
43
  export function parseScript(source: string, cwd: string, home: string): Script {
27
- const script: Script = { commands: [], uninspectable: [] };
44
+ const script: Script = { commands: [], uninspectable: [], parseFailed: false };
28
45
 
29
46
  function parse(src: string, cwd: string) {
30
47
  const bytes = Buffer.from(src);
@@ -35,12 +52,12 @@ export function parseScript(source: string, cwd: string, home: string): Script {
35
52
  file = undefined;
36
53
  }
37
54
  if (!file || misreadComment(file, bytes)) {
38
- script.uninspectable.push(src);
55
+ script.parseFailed = true;
39
56
  return;
40
57
  }
41
58
  const slice = (start: number, end: number) => bytes.subarray(start, end).toString();
42
59
  const scope: Scope = { dir: { cwd }, vars: new Map(), slice };
43
- for (const stmt of file.Stmts) statement(stmt, scope);
60
+ for (const stmt of file.Stmts) statement(stmt!, scope);
44
61
  }
45
62
 
46
63
  const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
@@ -59,7 +76,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
59
76
  if (kind === "BinaryCmd") return binary(cmd as BinaryCmd, scope);
60
77
  if (kind === "Subshell") {
61
78
  const inner = isolated(scope);
62
- for (const s of (cmd as Subshell).Stmts) statement(s, inner);
79
+ for (const s of (cmd as Subshell).Stmts) statement(s!, inner);
63
80
  return;
64
81
  }
65
82
  children(cmd, scope);
@@ -70,17 +87,17 @@ export function parseScript(source: string, cwd: string, home: string): Script {
70
87
  const op = scope.slice(cmd.OpPos.Offset(), cmd.OpPos.Offset() + 2).trim();
71
88
  const failed = isolated(scope);
72
89
  const start = script.commands.length;
73
- statement(cmd.X, op === "&&" || op === "||" ? scope : isolated(scope));
90
+ statement(cmd.X!, op === "&&" || op === "||" ? scope : isolated(scope));
74
91
  const middle = script.commands.length;
75
- const directCd = op === "&&" && movedOnSuccess(cmd.X, scope.slice);
92
+ const directCd = op === "&&" && movedOnSuccess(cmd.X!, scope.slice);
76
93
  if (directCd) {
77
94
  const old = [failed.dir.cwd, ...(failed.dir.alternatives ?? [])];
78
95
  scope.dir.alternatives = scope.dir.alternatives?.filter((cwd) => !old.includes(cwd));
79
- statement(cmd.Y, scope);
96
+ statement(cmd.Y!, scope);
80
97
  scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
81
98
  return;
82
99
  }
83
- statement(cmd.Y, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
100
+ statement(cmd.Y!, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
84
101
  if (op === "|") for (const item of xargsReplacements(script.commands.slice(start, middle), script.commands.slice(middle))) parse(item.source, item.cwd);
85
102
  if (op === "||") {
86
103
  scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), failed.dir.cwd, ...(failed.dir.alternatives ?? [])], home);
@@ -103,8 +120,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
103
120
  for (const cwd of [scope.dir.cwd, ...(scope.dir.alternatives ?? [])]) {
104
121
  script.commands.push({ argv: [value], redirects: [], cwd, program: -1, wrappers: [], shell: true, flags: new Set() });
105
122
  }
106
- }
107
- else return true;
123
+ } else return true;
108
124
  return false;
109
125
  });
110
126
  if (loop) outer.dir.alternatives = boundedDirectories(outer.dir.cwd, [...(outer.dir.alternatives ?? []), scope.dir.cwd, ...(scope.dir.alternatives ?? [])], home);
@@ -112,26 +128,30 @@ export function parseScript(source: string, cwd: string, home: string): Script {
112
128
 
113
129
  function conditional(node: IfClause, scope: Scope) {
114
130
  const condition = isolated(scope);
115
- for (const stmt of node.Cond) statement(stmt, condition);
131
+ for (const stmt of node.Cond) statement(stmt!, condition);
116
132
  const then = isolated(condition);
117
- for (const stmt of node.Then) statement(stmt, then);
133
+ for (const stmt of node.Then) statement(stmt!, then);
118
134
  const branches = [scope, condition, then];
119
135
  if (node.Else) {
120
136
  const otherwise = isolated(condition);
121
137
  conditional(node.Else, otherwise);
122
138
  branches.push(otherwise);
123
139
  }
124
- scope.dir.alternatives = boundedDirectories(scope.dir.cwd, branches.flatMap((branch) => [branch.dir.cwd, ...(branch.dir.alternatives ?? [])]), home);
140
+ scope.dir.alternatives = boundedDirectories(
141
+ scope.dir.cwd,
142
+ branches.flatMap((branch) => [branch.dir.cwd, ...(branch.dir.alternatives ?? [])]),
143
+ home,
144
+ );
125
145
  }
126
146
 
127
147
  function expansions(node: Node, scope: Scope, vars: string[]) {
128
148
  syntax.Walk(node, (child) => {
129
149
  if (!child) return true;
130
150
  const kind = type(child);
131
- if (kind === "ParamExp") vars.push((child as ParamExp).Param.Value);
151
+ if (kind === "ParamExp") vars.push((child as ParamExp).Param!.Value);
132
152
  if (kind !== "CmdSubst" && kind !== "ProcSubst") return true;
133
153
  const inner = isolated(scope);
134
- for (const s of (child as CmdSubst).Stmts) statement(s, inner);
154
+ for (const s of (child as CmdSubst).Stmts) statement(s!, inner);
135
155
  return false;
136
156
  });
137
157
  }
@@ -163,20 +183,20 @@ export function parseScript(source: string, cwd: string, home: string): Script {
163
183
  return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
164
184
  }
165
185
 
166
- function simple(cmd: Node | null, redirs: RedirectNode[], scope: Scope) {
186
+ function simple(cmd: Node | null, redirs: (RedirectNode | null)[], scope: Scope) {
167
187
  const argv: Word[] = [];
168
188
  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));
189
+ for (const a of (cmd as CallExpr).Assigns) argv.push(assign(a!, scope, "assign"));
190
+ for (const w of (cmd as CallExpr).Args) argv.push(word(w!, scope));
171
191
  } else if (cmd) {
172
192
  const decl = cmd as DeclClause;
173
- argv.push(literal(decl.Variant.Value));
193
+ argv.push(literal(decl.Variant!.Value));
174
194
  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 ?? ""));
195
+ if (!a!.Naked) argv.push(assign(a!, scope, "arg"));
196
+ else argv.push(a!.Value ? word(a!.Value, scope) : literal(a!.Name?.Value ?? ""));
177
197
  }
178
198
  }
179
- const redirects = redirs.map((r) => redirect(r, scope)).filter((r) => r !== undefined);
199
+ const redirects = redirs.map((r) => redirect(r!, scope)).filter((r) => r !== undefined);
180
200
  const command: Command = {
181
201
  argv,
182
202
  redirects,
@@ -201,14 +221,14 @@ export function parseScript(source: string, cwd: string, home: string): Script {
201
221
  }
202
222
 
203
223
  function redirect(node: RedirectNode, scope: Scope): Redirect | undefined {
204
- const op = scope.slice(node.OpPos.Offset(), node.Word.Pos().Offset()).trim();
224
+ const op = scope.slice(node.OpPos.Offset(), node.Word!.Pos().Offset()).trim();
205
225
  if (op === "<<" || op === "<<-") {
206
- const quoted = node.Word.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
226
+ const quoted = node.Word!.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
207
227
  const vars: string[] = [];
208
228
  if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
209
229
  return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
210
230
  }
211
- const target = word(node.Word, scope);
231
+ const target = word(node.Word!, scope);
212
232
  if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
213
233
  if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
214
234
  const direction = op === "<" || op === "<>" ? "in" : "out";
@@ -223,14 +243,15 @@ export function parseScript(source: string, cwd: string, home: string): Script {
223
243
  if (!word.expands) scope.vars.set(word.text.slice(0, at), expandHome(word.text.slice(at + 1), home));
224
244
  }
225
245
  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;
246
+ const args = command.argv.slice(command.program + 1);
247
+ let target = args.find((w) => !w.text.startsWith("-"))?.text;
248
+ if (target === undefined && (!args.length || (args.length === 1 && args[0]?.text === "--")) && program.text === "cd") target = home;
231
249
  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;
250
+ scope.dir.alternatives = boundedDirectories(
251
+ resolve(scope.dir.cwd, target),
252
+ [scope.dir.cwd, ...(scope.dir.alternatives ?? []), ...(scope.dir.alternatives ?? []).map((cwd) => resolve(cwd, target))],
253
+ home,
254
+ );
234
255
  scope.dir.cwd = resolve(scope.dir.cwd, target);
235
256
  }
236
257
  }
package/src/guard.ts CHANGED
@@ -6,31 +6,47 @@
6
6
  // objection and exit 2 denies with the reason on stderr. The wrapper turns
7
7
  // every other outcome into a denial.
8
8
  import { homedir } from "node:os";
9
- import { buildRequest, evaluate, suggestions } from "./core.ts";
10
- import type { Runtime, Tool } from "./record.ts";
9
+ import { resolve } from "node:path";
10
+ import { parseArgs } from "node:util";
11
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");
12
+ import { buildRequest, evaluate, suggestions } from "./core";
13
+ import type { Runtime, Tool } from "./record";
14
+
15
+ const usage = "usage: agent-guard --runtime claude|codex|pi < event.json";
16
+ let flags: { runtime?: string; cwd?: string };
17
+ try {
18
+ flags = parseArgs({ options: { runtime: { type: "string" }, cwd: { type: "string" } } as const }).values;
19
+ } catch {
20
+ console.error(usage);
21
+ process.exit(2);
22
+ }
23
+ const runtime = flags.runtime as Runtime | undefined;
24
+ const hookCwd = flags.cwd;
25
+ if (!runtime || !["claude", "codex", "pi"].includes(runtime) || !hookCwd) {
26
+ console.error(usage);
15
27
  process.exit(2);
16
28
  }
17
29
 
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.
30
+ // A missing tool_input, or a known tool whose field is not a string, throws,
31
+ // which the wrapper turns into a denial: a runtime that renames or retypes a
32
+ // field must not silently stop being guarded. Tools the guard does not know pass.
20
33
  const event: { tool_name?: unknown; cwd?: unknown; tool_input: Record<string, unknown> } = JSON.parse(await Bun.stdin.text());
21
34
  const input = event.tool_input;
22
35
  const string = (value: unknown) => (typeof value === "string" ? value : undefined);
23
- const cwd = string(event.cwd) ?? string(input.cwd) ?? hookCwd;
36
+ const cwd = string(event.cwd) ?? string(input["cwd"]) ?? hookCwd;
24
37
 
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) ?? ""],
38
+ const fields: Record<string, [Tool, string]> = {
39
+ bash: ["bash", "command"],
40
+ read: ["read", "file_path"],
41
+ edit: ["edit", "file_path"],
42
+ write: ["write", "file_path"],
43
+ grep: ["grep", "path"],
31
44
  };
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;
45
+ const [tool, field] = fields[(string(event.tool_name) ?? "Bash").toLowerCase()] ?? [];
46
+ // Grep searches the working directory when it names no path.
47
+ const value = field && (tool === "grep" && input[field] === undefined ? "" : string(input[field]));
48
+ if (field && value === undefined) throw new Error(`tool_input.${field} is not a string`);
49
+ const request = tool && value !== undefined ? buildRequest(runtime, tool, cwd, value, string(input["glob"]) ?? "", resolve(homedir())) : undefined;
34
50
  const reason = request && evaluate(request);
35
51
  if (reason) {
36
52
  console.error(runtime === "claude" ? `DENIED: ${reason} Do NOT bypass this restriction or retry the same blocked command.` : reason);
package/src/links.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { readlinkSync } from "node:fs";
2
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";
3
+
4
+ import { absPath, expandHome, isAppdata, isSensitive } from "./paths";
5
+ import type { Request, Word } from "./record";
6
+ import { dataPrograms } from "./rules/appdata";
6
7
 
7
8
  export function linkedRequest(req: Request): Request | undefined {
8
9
  let changed = false;
@@ -15,7 +16,8 @@ export function linkedRequest(req: Request): Request | undefined {
15
16
  let followed = false;
16
17
  let depth = 0;
17
18
  while (parts.length) {
18
- path = resolve(path, parts.shift()!);
19
+ const part = parts.shift()!;
20
+ path = part === ".." ? dirname(path) : resolve(path, part);
19
21
  // Do not ask the filesystem about a path inside a protected tree.
20
22
  if (protectedPath(path)) {
21
23
  changed = true;
@@ -28,13 +30,10 @@ export function linkedRequest(req: Request): Request | undefined {
28
30
  continue;
29
31
  }
30
32
  if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
31
- const targetPath = resolve(dirname(path), target);
33
+ const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
32
34
  followed = true;
33
35
  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);
36
+ parts = [...targetPath.split("/").filter(Boolean), ...parts];
38
37
  path = "/";
39
38
  }
40
39
  return followed ? path : value;
@@ -43,17 +42,23 @@ export function linkedRequest(req: Request): Request | undefined {
43
42
  if (w.expands || w.globs || !w.value || w.value.startsWith("-")) return w;
44
43
  if (w.role === "program" && !w.value.includes("/")) return w;
45
44
  if (!["arg", "path", "patfile", "option:patfile", "program"].includes(w.role)) return w;
46
- const value = linked(w.value, cwd, /^[\'"]/.test(w.raw));
45
+ const value = linked(w.value, cwd, /^['"]/.test(w.raw));
47
46
  return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
48
47
  };
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;
48
+ const commands = req.commands.map((cmd) => {
49
+ const checkedCwd = linked(cmd.cwd, "/");
50
+ const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
51
+ if (cwd !== cmd.cwd) changed = true;
52
+ return {
53
+ ...cmd,
54
+ cwd,
55
+ argv: cmd.argv.map((w, i) => (dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : word(w, cmd.cwd))),
56
+ redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
57
+ };
58
+ });
59
+ const checkedCwd = linked(req.inputCwd, "/");
60
+ const cwd = checkedCwd === req.inputCwd ? req.cwd : checkedCwd;
61
+ const target = req.target ? linked(req.pathInput, req.inputCwd) : req.target;
62
+ const searchRoot = req.searchRoot ? linked(req.pathInput || req.inputCwd, req.inputCwd) : req.searchRoot;
63
+ return changed ? { ...req, cwd, target, searchRoot, commands } : undefined;
59
64
  }
package/src/moves.ts CHANGED
@@ -1,5 +1,5 @@
1
- import sh from "mvdan-sh";
2
1
  import type { BinaryCmd, CallExpr, Lit, Stmt } from "mvdan-sh";
2
+ import sh from "mvdan-sh";
3
3
 
4
4
  // Discard the prior cwd only when every move in an && chain has a literal destination.
5
5
  export function movedOnSuccess(stmt: Stmt, slice: (start: number, end: number) => string): boolean {
@@ -20,8 +20,8 @@ export function movedOnSuccess(stmt: Stmt, slice: (start: number, end: number) =
20
20
  if (type(node) !== "BinaryCmd") return "uncertain";
21
21
  const chain = node as BinaryCmd;
22
22
  if (slice(chain.OpPos.Offset(), chain.OpPos.Offset() + 2) !== "&&") return "uncertain";
23
- const left = state(chain.X);
24
- const right = state(chain.Y);
23
+ const left = state(chain.X!);
24
+ const right = state(chain.Y!);
25
25
  return left === "uncertain" || right === "uncertain" ? "uncertain" : left === "moved" || right === "moved" ? "moved" : "unchanged";
26
26
  };
27
27
  return state(stmt) === "moved";
package/src/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Filesystem checks run after lexical denials to avoid touching protected trees.
2
- import { statSync, realpathSync } from "node:fs";
2
+ import { realpathSync, statSync } from "node:fs";
3
3
  import { userInfo } from "node:os";
4
4
  import { basename, dirname, resolve } from "node:path";
5
5
 
@@ -7,15 +7,27 @@ export const appdataTrees = ["Containers", "Group Containers", "Mobile Documents
7
7
 
8
8
  // The one list of credential-bearing paths, matched against absolute paths.
9
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/**",
10
+ "**/.env",
11
+ "**/.env.*",
12
+ "**/.npmrc",
13
+ "**/.zprofile*",
14
+ "**/.zsh_history*",
15
+ "**/*.pem",
16
+ "**/*.key",
17
+ "**/auth.json*",
18
+ "**/.credentials.json*",
19
+ "**/.aws/credentials*",
20
+ "**/private-keys-v1.d",
21
+ "**/private-keys-v1.d/**",
15
22
  ];
16
23
 
17
24
  const sensitiveGlobs = sensitivePaths.map((path) => new Bun.Glob(path));
18
25
 
26
+ // Directories that hold listed files, so a search rooted at one reads them.
27
+ // ~/.ssh has its own inode-based check and reason.
28
+ const credentialRoots = [".aws", ".gnupg"];
29
+ const credentialDirectories = [".ssh", ...credentialRoots];
30
+
19
31
  export function expandHome(path: string, home: string): string {
20
32
  for (const prefix of ["~", `~${userInfo().username}`]) {
21
33
  if (path === prefix || path.startsWith(`${prefix}/`)) return home + path.slice(prefix.length);
@@ -23,22 +35,34 @@ export function expandHome(path: string, home: string): string {
23
35
  return path;
24
36
  }
25
37
 
38
+ // curl, open and git read a file:// URL as the path it names.
26
39
  export function absPath(path: string, cwd: string, home: string, quoted = false): string {
27
40
  if (!quoted) path = expandHome(path, home);
28
- return resolve(cwd, path);
41
+ return resolve(cwd, path.replace(/^file:\/\//i, ""));
42
+ }
43
+
44
+ // Whether the shell could expand the glob's leading segments to `directory`, so
45
+ // `~/Lib*/Cont*/x` reads inside `~/Library/Containers`. A pattern that stops at
46
+ // the directory only names it and is left to the callers' other checks.
47
+ function globReaches(path: string, directory: string): boolean {
48
+ const pattern = path.split("/");
49
+ const target = directory.split("/");
50
+ if (pattern.length <= target.length) return false;
51
+ return target.every((segment, i) => i === 0 || pattern[i] === "**" || new Bun.Glob(pattern[i]!.toLowerCase()).match(segment.toLowerCase()));
29
52
  }
30
53
 
31
54
  export function isAppdata(path: string, home: string, glob = false): boolean {
32
55
  if (glob) {
33
56
  const expanded = Bun.$.braces(path);
34
57
  if (expanded.length > 1) return expanded.some((each) => isAppdata(each, home, true));
58
+ if (appdataTrees.some((tree) => globReaches(path, `${home}/Library/${tree}`))) return true;
35
59
  }
36
60
  const library = `${home}/Library/`.toLowerCase();
37
61
  if (!path.toLowerCase().startsWith(library)) return false;
38
62
  const rest = path.slice(library.length).toLowerCase();
39
63
  if (appdataTrees.some((tree) => rest === tree.toLowerCase() || rest.startsWith(`${tree.toLowerCase()}/`))) return true;
40
64
  if (!glob) return false;
41
- const fixed = rest.split(/[*?[]/)[0].replace(/\/$/, "");
65
+ const fixed = rest.split(/[*?[]/)[0]!.replace(/\/$/, "");
42
66
  return fixed !== "" && appdataTrees.some((tree) => tree.toLowerCase().startsWith(fixed));
43
67
  }
44
68
 
@@ -54,12 +78,12 @@ export function isBroad(path: string, home: string, glob = false): boolean {
54
78
  if (trimmed === home || trimmed === `${home}/library` || home.startsWith(`${trimmed}/`)) return true;
55
79
  if (!glob) return false;
56
80
  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(/\/$/, "");
81
+ if ([home, `${home}/library`, ...appdataTrees.flatMap((tree) => [`${home}/library/${tree.toLowerCase()}`, `${home}/library/${tree.toLowerCase()}/x`])].some((candidate) => pattern.match(candidate)))
82
+ return true;
83
+ const prefix = path.split(/[*?[]/, 1)[0]!.replace(/\/$/, "");
59
84
  return path.includes("**") && (prefix === home || prefix === `${home}/library` || home.startsWith(`${prefix}/`));
60
85
  }
61
86
 
62
- // ~/.ignore keeps rg and fd out of ~/Library only when they start above it.
63
87
  export function isLibrary(path: string, home: string): boolean {
64
88
  return path.toLowerCase() === `${home}/library`.toLowerCase() || isAppdata(path, home);
65
89
  }
@@ -77,13 +101,18 @@ export function sshPrivate(path: string): boolean {
77
101
 
78
102
  // A credential-bearing absolute path, after brace expansion. For an unquoted
79
103
  // glob, a pattern counts when it could match a listed name; a bare wildcard
80
- // does not.
104
+ // does not, unless it sits in a credential directory. Names match regardless of
105
+ // case because the default APFS volume ignores it.
81
106
  export function isSensitive(path: string, glob = false): boolean {
82
107
  const expanded = Bun.$.braces(path);
83
108
  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;
109
+ const lower = path.toLowerCase();
110
+ if ([".env.example", ".env.age"].includes(basename(lower))) return false;
111
+ if (sshPrivate(path) || sensitiveGlobs.some((listed) => listed.match(lower))) return true;
112
+ if (!glob) return false;
113
+ if (globDirectory(path)) return true;
114
+ const base = basename(lower);
115
+ if (/^[*?]*$/.test(base)) return credentialDirectories.includes(basename(dirname(lower)));
87
116
  const pattern = new Bun.Glob(base);
88
117
  return sensitivePaths.some((listed) => {
89
118
  const name = basename(listed).replaceAll("*", "x");
@@ -91,6 +120,24 @@ export function isSensitive(path: string, glob = false): boolean {
91
120
  });
92
121
  }
93
122
 
123
+ // A wildcard directory segment such as `.s*` may expand to a credential
124
+ // directory. The shell's wildcards skip a leading dot, so only a segment that
125
+ // starts with one counts.
126
+ function globDirectory(path: string): boolean {
127
+ const segments = path.split("/");
128
+ return segments.slice(0, -1).some((segment, i) => {
129
+ if (!segment.startsWith(".") || !/[*?[]/.test(segment)) return false;
130
+ const pattern = new Bun.Glob(segment.toLowerCase());
131
+ return credentialDirectories.some((dir) => pattern.match(dir) && isSensitive([...segments.slice(0, i), dir, ...segments.slice(i + 1)].join("/"), true));
132
+ });
133
+ }
134
+
135
+ // A search reads everything under its root, so a credential directory counts as
136
+ // its listed files do.
137
+ export function isSensitiveRoot(path: string): boolean {
138
+ return isSensitive(path) || credentialRoots.includes(basename(path.toLowerCase()));
139
+ }
140
+
94
141
  // A path that does not exist or cannot be searched has no inode to compare.
95
142
  function stat(path: string) {
96
143
  try {
@@ -131,12 +178,12 @@ export function sshScopeDenied(target: string, home: string, search: boolean): b
131
178
  for (const root of roots) {
132
179
  if (sameFile(candidate, root)) return true;
133
180
  if (search) {
134
- for (let parent = root; parent !== "/"; ) {
181
+ for (let parent = root; parent !== "/";) {
135
182
  parent = dirname(parent);
136
183
  if (sameFile(candidate, parent)) return true;
137
184
  }
138
185
  }
139
- for (let parent = candidate; parent !== "/"; ) {
186
+ for (let parent = candidate; parent !== "/";) {
140
187
  parent = dirname(parent);
141
188
  if (!sameFile(parent, root)) continue;
142
189
  if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
package/src/pipeline.ts CHANGED
@@ -1,26 +1,28 @@
1
1
  import { basename } from "node:path";
2
- import type { Command } from "./record.ts";
2
+
3
+ import type { Command } from "./record";
3
4
 
4
5
  // A literal printf value is known before xargs substitutes it into a command.
5
6
  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 printf = left.find((cmd) => cmd.program >= 0 && basename(cmd.argv[cmd.program]!.text) === "printf");
7
8
  const xargs = right.find((cmd) => cmd.program >= 0 && cmd.wrappers.includes("xargs"));
8
9
  if (!printf || !xargs) return [];
9
10
  const args = printf.argv.slice(printf.program + 1);
10
- if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0].text)) return [];
11
+ if (args.length !== 2 || !/^%s(?:\\n)?$/.test(args[0]!.text)) return [];
11
12
 
12
13
  const options = xargs.argv.slice(0, xargs.program);
13
14
  let marker = "";
14
15
  for (let i = 0; i < options.length; i++) {
15
- const option = options[i].text;
16
+ const option = options[i]!.text;
16
17
  if (option === "-I" || option === "--replace") marker = options[i + 1]?.text ?? "";
17
18
  else if (option.startsWith("-I")) marker = option.slice(2);
18
19
  else if (option.startsWith("--replace=")) marker = option.slice("--replace=".length);
19
20
  }
20
21
  if (!marker) return [];
21
22
  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(" ");
23
+ const source = xargs.argv
24
+ .slice(xargs.program)
25
+ .map((word) => (word.text.includes(marker) ? quote(word.text.replaceAll(marker, args[1]!.text)) : word.raw))
26
+ .join(" ");
25
27
  return [{ source, cwd: xargs.cwd }];
26
28
  }
package/src/reasons.ts CHANGED
@@ -1,7 +1,10 @@
1
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.",
2
+ syntax: "The agent guard cannot inspect this shell syntax. Rewrite it as a Bash-compatible command with explicit paths, or run a narrower command that the guard can inspect.",
3
+ appdata:
4
+ "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
5
  broad: "A scan rooted at the home directory or ~/Library reaches every app-data entry. Scope the scan to a project path.",
4
6
  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.",
7
+ hiddenSearch: "A recursive search that includes hidden files can read credentials. Use default rg on a project path, or search an exact non-sensitive file without recursive or hidden-file flags.",
5
8
  dump: "This dumps environment or shell variables, including secrets. Name the non-sensitive variable needed and read only that variable.",
6
9
  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
10
  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.",
@@ -11,9 +14,7 @@ export const reasons = {
11
14
  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
15
  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
16
  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
17
  replace: "rg -r means --replace. Drop -r; use -n for line numbers, or spell --replace VALUE for an intentional replacement.",
16
18
  include: "rg has no --include flag. Filter files with -g GLOB (for example -g '*.ts') or a type filter such as -t ts.",
17
19
  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
20
  } as const;
package/src/record.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // A search option's value keeps the option's word as option:ROLE.
2
2
  export type ValueRole = "pattern" | "patfile" | "glob" | "nglob" | "optarg";
3
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";
4
+ export type Flag = "explicit" | "files" | "help" | "fixed" | "recursive" | "include" | "replace" | "hidden";
5
5
 
6
6
  export interface Word {
7
7
  text: string; // quotes removed; ~, $HOME and ${HOME} expanded
@@ -32,6 +32,7 @@ export interface Command {
32
32
  export interface Script {
33
33
  commands: Command[];
34
34
  uninspectable: string[];
35
+ parseFailed: boolean;
35
36
  }
36
37
 
37
38
  export type Runtime = "claude" | "codex" | "pi";
@@ -42,10 +43,13 @@ export interface Request {
42
43
  tool: Tool;
43
44
  home: string;
44
45
  cwd: string;
46
+ inputCwd: string;
47
+ pathInput: string;
45
48
  operation: "" | "read" | "write" | "search";
46
49
  target: string;
47
50
  searchRoot: string;
48
51
  glob: string;
49
52
  commands: Command[];
50
53
  uninspectable: string[];
54
+ parseFailed: boolean;
51
55
  }