@loophubs/agent-guard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -84
- package/bin/agent-guard +14 -10
- package/package.json +29 -8
- package/src/argv.ts +66 -58
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +66 -45
- package/src/guard.ts +32 -16
- package/src/links.ts +25 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +64 -17
- package/src/pipeline.ts +9 -7
- package/src/reasons.ts +4 -3
- package/src/record.ts +5 -1
- package/src/rules/appdata.ts +10 -18
- package/src/rules/credentials.ts +63 -26
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +9 -15
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/src/frontend.ts
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
|
-
// Parse shell structure into command records
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
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;
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
90
|
+
statement(cmd.X!, op === "&&" || op === "||" ? scope : isolated(scope));
|
|
74
91
|
const middle = script.commands.length;
|
|
75
|
-
const directCd = op === "&&" && movedOnSuccess(cmd.X
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
170
|
-
for (const w of (cmd as CallExpr).Args) argv.push(word(w
|
|
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
|
|
193
|
+
argv.push(literal(decl.Variant!.Value));
|
|
174
194
|
for (const a of decl.Args) {
|
|
175
|
-
if (!a
|
|
176
|
-
else argv.push(a
|
|
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
|
|
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
|
|
224
|
+
const op = scope.slice(node.OpPos.Offset(), node.Word!.Pos().Offset()).trim();
|
|
205
225
|
if (op === "<<" || op === "<<-") {
|
|
206
|
-
const quoted = node.Word
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
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(
|
|
233
|
-
|
|
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 {
|
|
10
|
-
import
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
19
|
-
//
|
|
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
|
|
36
|
+
const cwd = string(event.cwd) ?? string(input["cwd"]) ?? hookCwd;
|
|
24
37
|
|
|
25
|
-
const
|
|
26
|
-
bash: ["bash",
|
|
27
|
-
read: ["read",
|
|
28
|
-
edit: ["edit",
|
|
29
|
-
write: ["write",
|
|
30
|
-
grep: ["grep",
|
|
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,
|
|
33
|
-
|
|
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
|
-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
33
|
+
const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
|
|
32
34
|
followed = true;
|
|
33
35
|
changed = true;
|
|
34
|
-
|
|
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, /^[
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 {
|
|
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",
|
|
11
|
-
"**/.env
|
|
12
|
-
"**/.npmrc",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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]
|
|
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)))
|
|
58
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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" | "
|
|
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
|
}
|