@loophubs/agent-guard 0.1.0 → 0.3.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 +99 -59
- package/src/comments.ts +3 -2
- package/src/core.ts +28 -15
- package/src/frontend.ts +107 -56
- package/src/guard.ts +32 -16
- package/src/links.ts +38 -20
- package/src/moves.ts +3 -3
- package/src/paths.ts +140 -20
- package/src/pipeline.ts +126 -14
- package/src/reasons.ts +6 -3
- package/src/record.ts +7 -1
- package/src/rules/appdata.ts +18 -23
- package/src/rules/credentials.ts +176 -33
- package/src/rules/workflow.ts +10 -26
- package/src/runner.ts +32 -0
- package/src/search-roles.ts +42 -21
- package/src/words.ts +23 -17
- package/tsconfig.json +26 -0
- package/src/mvdan-sh.d.ts +0 -92
package/src/frontend.ts
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
|
-
// Parse shell structure into command records
|
|
2
|
-
//
|
|
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
|
+
|
|
8
|
+
import type { Assign, BinaryCmd, CallExpr, CmdSubst, DeclClause, FuncDecl, IfClause, Lit, Node, ParamExp, Redirect as RedirectNode, Stmt, Subshell, Word as WordNode } from "mvdan-sh";
|
|
3
9
|
import sh from "mvdan-sh";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import { readWord } from "./words.ts";
|
|
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 { markWalkedInput, shellInput, xargsHereInput, 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
|
}
|
|
23
40
|
|
|
24
41
|
const type = (node: Node) => syntax.NodeType(node);
|
|
25
42
|
|
|
43
|
+
// A function call runs the body in the caller's shell; bound the work a chain of
|
|
44
|
+
// calls can multiply.
|
|
45
|
+
const maxFunctionRuns = 256;
|
|
46
|
+
|
|
26
47
|
export function parseScript(source: string, cwd: string, home: string): Script {
|
|
27
|
-
const script: Script = { commands: [], uninspectable: [] };
|
|
48
|
+
const script: Script = { commands: [], uninspectable: [], parseFailed: false };
|
|
28
49
|
|
|
29
50
|
function parse(src: string, cwd: string) {
|
|
30
51
|
const bytes = Buffer.from(src);
|
|
@@ -35,14 +56,18 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
35
56
|
file = undefined;
|
|
36
57
|
}
|
|
37
58
|
if (!file || misreadComment(file, bytes)) {
|
|
38
|
-
script.
|
|
59
|
+
script.parseFailed = true;
|
|
39
60
|
return;
|
|
40
61
|
}
|
|
41
62
|
const slice = (start: number, end: number) => bytes.subarray(start, end).toString();
|
|
42
63
|
const scope: Scope = { dir: { cwd }, vars: new Map(), slice };
|
|
43
|
-
for (const stmt of file.Stmts) statement(stmt
|
|
64
|
+
for (const stmt of file.Stmts) statement(stmt!, scope);
|
|
44
65
|
}
|
|
45
66
|
|
|
67
|
+
const functions = new Map<string, { body: Stmt; slice: Scope["slice"] }>();
|
|
68
|
+
const running = new Set<string>();
|
|
69
|
+
let functionRuns = 0;
|
|
70
|
+
|
|
46
71
|
const text = (node: Node, scope: Scope) => scope.slice(node.Pos().Offset(), node.End().Offset());
|
|
47
72
|
// A cd inside runs in a separate shell and leaves this scope's cwd alone.
|
|
48
73
|
const isolated = (scope: Scope): Scope => ({ ...scope, dir: { ...scope.dir }, vars: new Map(scope.vars) });
|
|
@@ -59,7 +84,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
59
84
|
if (kind === "BinaryCmd") return binary(cmd as BinaryCmd, scope);
|
|
60
85
|
if (kind === "Subshell") {
|
|
61
86
|
const inner = isolated(scope);
|
|
62
|
-
for (const s of (cmd as Subshell).Stmts) statement(s
|
|
87
|
+
for (const s of (cmd as Subshell).Stmts) statement(s!, inner);
|
|
63
88
|
return;
|
|
64
89
|
}
|
|
65
90
|
children(cmd, scope);
|
|
@@ -70,26 +95,36 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
70
95
|
const op = scope.slice(cmd.OpPos.Offset(), cmd.OpPos.Offset() + 2).trim();
|
|
71
96
|
const failed = isolated(scope);
|
|
72
97
|
const start = script.commands.length;
|
|
73
|
-
statement(cmd.X
|
|
98
|
+
statement(cmd.X!, op === "&&" || op === "||" ? scope : isolated(scope));
|
|
74
99
|
const middle = script.commands.length;
|
|
75
|
-
const directCd = op === "&&" && movedOnSuccess(cmd.X
|
|
100
|
+
const directCd = op === "&&" && movedOnSuccess(cmd.X!, scope.slice);
|
|
76
101
|
if (directCd) {
|
|
77
102
|
const old = [failed.dir.cwd, ...(failed.dir.alternatives ?? [])];
|
|
78
103
|
scope.dir.alternatives = scope.dir.alternatives?.filter((cwd) => !old.includes(cwd));
|
|
79
|
-
statement(cmd.Y
|
|
104
|
+
statement(cmd.Y!, scope);
|
|
80
105
|
scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), ...old], home);
|
|
81
106
|
return;
|
|
82
107
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
108
|
+
// zsh runs the last element of a pipeline in this shell and bash forks it,
|
|
109
|
+
// so a cd there may or may not move the commands after it.
|
|
110
|
+
const right = op === "||" ? failed : op === "&&" ? scope : isolated(scope);
|
|
111
|
+
statement(cmd.Y!, right);
|
|
112
|
+
if (op === "|" || op === "|&") {
|
|
113
|
+
const left = script.commands.slice(start, middle);
|
|
114
|
+
const rest = script.commands.slice(middle);
|
|
115
|
+
markWalkedInput(left, rest);
|
|
116
|
+
for (const item of [...xargsReplacements(left, rest), ...shellInput(left, rest)]) parse(item.source, item.cwd);
|
|
87
117
|
}
|
|
118
|
+
if (op !== "&&") scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), right.dir.cwd, ...(right.dir.alternatives ?? [])], home);
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
// Compound words may hold substitutions; branch bodies may not run.
|
|
91
122
|
function children(node: Node, scope: Scope) {
|
|
92
|
-
if (type(node) === "FuncDecl")
|
|
123
|
+
if (type(node) === "FuncDecl") {
|
|
124
|
+
const func = node as FuncDecl;
|
|
125
|
+
if (func.Name && func.Body) functions.set(func.Name.Value, { body: func.Body, slice: scope.slice });
|
|
126
|
+
scope = isolated(scope);
|
|
127
|
+
}
|
|
93
128
|
if (type(node) === "IfClause") return conditional(node as IfClause, scope);
|
|
94
129
|
const outer = scope;
|
|
95
130
|
const loop = type(node) === "WhileClause" || type(node) === "ForClause";
|
|
@@ -103,8 +138,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
103
138
|
for (const cwd of [scope.dir.cwd, ...(scope.dir.alternatives ?? [])]) {
|
|
104
139
|
script.commands.push({ argv: [value], redirects: [], cwd, program: -1, wrappers: [], shell: true, flags: new Set() });
|
|
105
140
|
}
|
|
106
|
-
}
|
|
107
|
-
else return true;
|
|
141
|
+
} else return true;
|
|
108
142
|
return false;
|
|
109
143
|
});
|
|
110
144
|
if (loop) outer.dir.alternatives = boundedDirectories(outer.dir.cwd, [...(outer.dir.alternatives ?? []), scope.dir.cwd, ...(scope.dir.alternatives ?? [])], home);
|
|
@@ -112,32 +146,36 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
112
146
|
|
|
113
147
|
function conditional(node: IfClause, scope: Scope) {
|
|
114
148
|
const condition = isolated(scope);
|
|
115
|
-
for (const stmt of node.Cond) statement(stmt
|
|
149
|
+
for (const stmt of node.Cond) statement(stmt!, condition);
|
|
116
150
|
const then = isolated(condition);
|
|
117
|
-
for (const stmt of node.Then) statement(stmt
|
|
151
|
+
for (const stmt of node.Then) statement(stmt!, then);
|
|
118
152
|
const branches = [scope, condition, then];
|
|
119
153
|
if (node.Else) {
|
|
120
154
|
const otherwise = isolated(condition);
|
|
121
155
|
conditional(node.Else, otherwise);
|
|
122
156
|
branches.push(otherwise);
|
|
123
157
|
}
|
|
124
|
-
scope.dir.alternatives = boundedDirectories(
|
|
158
|
+
scope.dir.alternatives = boundedDirectories(
|
|
159
|
+
scope.dir.cwd,
|
|
160
|
+
branches.flatMap((branch) => [branch.dir.cwd, ...(branch.dir.alternatives ?? [])]),
|
|
161
|
+
home,
|
|
162
|
+
);
|
|
125
163
|
}
|
|
126
164
|
|
|
127
165
|
function expansions(node: Node, scope: Scope, vars: string[]) {
|
|
128
166
|
syntax.Walk(node, (child) => {
|
|
129
167
|
if (!child) return true;
|
|
130
168
|
const kind = type(child);
|
|
131
|
-
if (kind === "ParamExp") vars.push((child as ParamExp).Param
|
|
169
|
+
if (kind === "ParamExp") vars.push((child as ParamExp).Param!.Value);
|
|
132
170
|
if (kind !== "CmdSubst" && kind !== "ProcSubst") return true;
|
|
133
171
|
const inner = isolated(scope);
|
|
134
|
-
for (const s of (child as CmdSubst).Stmts) statement(s
|
|
172
|
+
for (const s of (child as CmdSubst).Stmts) statement(s!, inner);
|
|
135
173
|
return false;
|
|
136
174
|
});
|
|
137
175
|
}
|
|
138
176
|
|
|
139
177
|
function word(node: WordNode, scope: Scope): Word {
|
|
140
|
-
return readWord(node, scope.slice, scope.vars, home, (part, names) => expansions(part, scope, names));
|
|
178
|
+
return readWord(node, scope.slice, scope.vars, home, scope.dir.cwd, (part, names) => expansions(part, scope, names));
|
|
141
179
|
}
|
|
142
180
|
|
|
143
181
|
function assign(node: Assign, scope: Scope, role: "assign" | "arg"): Word {
|
|
@@ -151,7 +189,7 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
151
189
|
return type(child) !== "Word";
|
|
152
190
|
});
|
|
153
191
|
return {
|
|
154
|
-
...(value ?? { expands: false, globs: false, vars: [] }),
|
|
192
|
+
...(value ?? { expands: false, globs: false, vars: [], pwd: false }),
|
|
155
193
|
text: `${name}=${value?.text ?? ""}`,
|
|
156
194
|
raw: text(node, scope),
|
|
157
195
|
role,
|
|
@@ -160,23 +198,23 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
function literal(value: string): Word {
|
|
163
|
-
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value };
|
|
201
|
+
return { text: value, raw: value, expands: false, globs: false, vars: [], role: "arg", value, pwd: false };
|
|
164
202
|
}
|
|
165
203
|
|
|
166
|
-
function simple(cmd: Node | null, redirs: RedirectNode[], scope: Scope) {
|
|
204
|
+
function simple(cmd: Node | null, redirs: (RedirectNode | null)[], scope: Scope) {
|
|
167
205
|
const argv: Word[] = [];
|
|
168
206
|
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
|
|
207
|
+
for (const a of (cmd as CallExpr).Assigns) argv.push(assign(a!, scope, "assign"));
|
|
208
|
+
for (const w of (cmd as CallExpr).Args) argv.push(word(w!, scope));
|
|
171
209
|
} else if (cmd) {
|
|
172
210
|
const decl = cmd as DeclClause;
|
|
173
|
-
argv.push(literal(decl.Variant
|
|
211
|
+
argv.push(literal(decl.Variant!.Value));
|
|
174
212
|
for (const a of decl.Args) {
|
|
175
|
-
if (!a
|
|
176
|
-
else argv.push(a
|
|
213
|
+
if (!a!.Naked) argv.push(assign(a!, scope, "arg"));
|
|
214
|
+
else argv.push(a!.Value ? word(a!.Value, scope) : literal(a!.Name?.Value ?? ""));
|
|
177
215
|
}
|
|
178
216
|
}
|
|
179
|
-
const redirects = redirs.map((r) => redirect(r
|
|
217
|
+
const redirects = redirs.map((r) => redirect(r!, scope)).filter((r) => r !== undefined);
|
|
180
218
|
const command: Command = {
|
|
181
219
|
argv,
|
|
182
220
|
redirects,
|
|
@@ -188,7 +226,8 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
188
226
|
};
|
|
189
227
|
script.commands.push(command);
|
|
190
228
|
const { children: sources, code } = resolveCommand(command, home);
|
|
191
|
-
|
|
229
|
+
const moved = (cwd: string) => command.argv.map((w) => (w.pwd ? { ...w, text: w.text.replaceAll(scope.dir.cwd, cwd), value: w.value.replaceAll(scope.dir.cwd, cwd) } : w));
|
|
230
|
+
for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd, argv: moved(cwd) });
|
|
192
231
|
for (const src of sources) for (const cwd of [command.cwd, ...(scope.dir.alternatives ?? [])]) parse(src, cwd);
|
|
193
232
|
script.uninspectable.push(...code);
|
|
194
233
|
for (const r of redirects) {
|
|
@@ -196,23 +235,34 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
196
235
|
const stdin = stdinKind(command);
|
|
197
236
|
if (stdin === "shell") parse(r.target, command.cwd);
|
|
198
237
|
if (stdin === "code") script.uninspectable.push(r.target);
|
|
238
|
+
if (command.wrappers.includes("xargs")) for (const item of xargsHereInput(command, r.target)) parse(item.source, item.cwd);
|
|
239
|
+
}
|
|
240
|
+
const called = command.wrappers.every((w) => w === "time") ? command.argv[command.program]?.text : undefined;
|
|
241
|
+
const func = called === undefined ? undefined : functions.get(called);
|
|
242
|
+
if (func && !running.has(called!)) {
|
|
243
|
+
if (++functionRuns > maxFunctionRuns) script.parseFailed = true;
|
|
244
|
+
else {
|
|
245
|
+
running.add(called!);
|
|
246
|
+
statement(func.body, { ...scope, slice: func.slice });
|
|
247
|
+
running.delete(called!);
|
|
248
|
+
}
|
|
199
249
|
}
|
|
200
250
|
track(command, scope);
|
|
201
251
|
}
|
|
202
252
|
|
|
203
253
|
function redirect(node: RedirectNode, scope: Scope): Redirect | undefined {
|
|
204
|
-
const op = scope.slice(node.OpPos.Offset(), node.Word
|
|
254
|
+
const op = scope.slice(node.OpPos.Offset(), node.Word!.Pos().Offset()).trim();
|
|
205
255
|
if (op === "<<" || op === "<<-") {
|
|
206
|
-
const quoted = node.Word
|
|
256
|
+
const quoted = node.Word!.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
|
|
207
257
|
const vars: string[] = [];
|
|
208
258
|
if (node.Hdoc && !quoted) expansions(node.Hdoc, scope, vars);
|
|
209
|
-
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", vars };
|
|
259
|
+
return { direction: "heredoc", target: node.Hdoc ? text(node.Hdoc, scope) : "", globs: false, vars };
|
|
210
260
|
}
|
|
211
|
-
const target = word(node.Word
|
|
212
|
-
if (op === "<<<") return { direction: "herestring", target: target.text, vars: target.vars };
|
|
261
|
+
const target = word(node.Word!, scope);
|
|
262
|
+
if (op === "<<<") return { direction: "herestring", target: target.text, globs: false, vars: target.vars };
|
|
213
263
|
if ((op === "<&" || op === ">&") && /^(\d+|-)$/.test(target.text)) return undefined;
|
|
214
264
|
const direction = op === "<" || op === "<>" ? "in" : "out";
|
|
215
|
-
return { direction, target: target.text, vars: [] };
|
|
265
|
+
return { direction, target: target.text, globs: target.globs, vars: [] };
|
|
216
266
|
}
|
|
217
267
|
|
|
218
268
|
function track(command: Command, scope: Scope) {
|
|
@@ -223,14 +273,15 @@ export function parseScript(source: string, cwd: string, home: string): Script {
|
|
|
223
273
|
if (!word.expands) scope.vars.set(word.text.slice(0, at), expandHome(word.text.slice(at + 1), home));
|
|
224
274
|
}
|
|
225
275
|
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;
|
|
276
|
+
const args = command.argv.slice(command.program + 1);
|
|
277
|
+
let target = args.find((w) => !w.text.startsWith("-"))?.text;
|
|
278
|
+
if (target === undefined && args.every((w) => /^(--|-[PLqs]+)$/.test(w.text)) && program.text === "cd") target = home;
|
|
231
279
|
if (target !== undefined) {
|
|
232
|
-
scope.dir.alternatives = boundedDirectories(
|
|
233
|
-
|
|
280
|
+
scope.dir.alternatives = boundedDirectories(
|
|
281
|
+
resolve(scope.dir.cwd, target),
|
|
282
|
+
[scope.dir.cwd, ...(scope.dir.alternatives ?? []), ...(scope.dir.alternatives ?? []).map((cwd) => resolve(cwd, target))],
|
|
283
|
+
home,
|
|
284
|
+
);
|
|
234
285
|
scope.dir.cwd = resolve(scope.dir.cwd, target);
|
|
235
286
|
}
|
|
236
287
|
}
|
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,10 @@
|
|
|
1
1
|
import { readlinkSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
3
|
+
|
|
4
|
+
import { programName } from "./argv";
|
|
5
|
+
import { absPath, expandHome, isAppdata, isSensitive } from "./paths";
|
|
6
|
+
import type { Request, Word } from "./record";
|
|
7
|
+
import { dataPrograms } from "./rules/appdata";
|
|
6
8
|
|
|
7
9
|
export function linkedRequest(req: Request): Request | undefined {
|
|
8
10
|
let changed = false;
|
|
@@ -15,7 +17,8 @@ export function linkedRequest(req: Request): Request | undefined {
|
|
|
15
17
|
let followed = false;
|
|
16
18
|
let depth = 0;
|
|
17
19
|
while (parts.length) {
|
|
18
|
-
|
|
20
|
+
const part = parts.shift()!;
|
|
21
|
+
path = part === ".." ? dirname(path) : resolve(path, part);
|
|
19
22
|
// Do not ask the filesystem about a path inside a protected tree.
|
|
20
23
|
if (protectedPath(path)) {
|
|
21
24
|
changed = true;
|
|
@@ -28,13 +31,10 @@ export function linkedRequest(req: Request): Request | undefined {
|
|
|
28
31
|
continue;
|
|
29
32
|
}
|
|
30
33
|
if (++depth > 8) throw new Error("Symlink chain exceeds the agent guard limit");
|
|
31
|
-
const targetPath =
|
|
34
|
+
const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
|
|
32
35
|
followed = true;
|
|
33
36
|
changed = true;
|
|
34
|
-
|
|
35
|
-
const next = resolve(targetPath, ...parts);
|
|
36
|
-
if (protectedPath(next)) return next;
|
|
37
|
-
parts = next.split("/").filter(Boolean);
|
|
37
|
+
parts = [...targetPath.split("/").filter(Boolean), ...parts];
|
|
38
38
|
path = "/";
|
|
39
39
|
}
|
|
40
40
|
return followed ? path : value;
|
|
@@ -43,17 +43,35 @@ export function linkedRequest(req: Request): Request | undefined {
|
|
|
43
43
|
if (w.expands || w.globs || !w.value || w.value.startsWith("-")) return w;
|
|
44
44
|
if (w.role === "program" && !w.value.includes("/")) return w;
|
|
45
45
|
if (!["arg", "path", "patfile", "option:patfile", "program"].includes(w.role)) return w;
|
|
46
|
-
const value = linked(w.value, cwd, /^[
|
|
46
|
+
const value = linked(w.value, cwd, /^['"]/.test(w.raw));
|
|
47
47
|
return value === w.value ? w : { ...w, value, text: w.role.startsWith("option:") ? w.text : value };
|
|
48
48
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
// curl reads the file after @ or < in a data or form value, or one glued to -T, -K, --upload-file or --config.
|
|
50
|
+
const curlOperand = /^((?:-[A-Za-z]|--[a-z-]+=|[^=@<-][^=@<]*=)?[@<])([^;]+)(.*)$/s;
|
|
51
|
+
const curlFile = /^(-[TK]|--(?:upload-file|config)=)([^;]+)()$/s;
|
|
52
|
+
const curlWord = (w: Word, cwd: string): Word | undefined => {
|
|
53
|
+
const operand = curlOperand.exec(w.text) ?? curlFile.exec(w.text);
|
|
54
|
+
if (!operand || w.expands) return undefined;
|
|
55
|
+
const value = linked(operand[2]!, cwd);
|
|
56
|
+
return value === operand[2] ? w : { ...w, text: `${operand[1]}${value}${operand[3]}` };
|
|
57
|
+
};
|
|
58
|
+
const commands = req.commands.map((cmd) => {
|
|
59
|
+
const curl = cmd.program >= 0 && programName(cmd.argv[cmd.program]!.text) === "curl";
|
|
60
|
+
const checkedCwd = linked(cmd.cwd, "/");
|
|
61
|
+
const cwd = checkedCwd === cmd.cwd ? absPath(cmd.cwd, "/", req.home) : checkedCwd;
|
|
62
|
+
if (cwd !== cmd.cwd) changed = true;
|
|
63
|
+
return {
|
|
64
|
+
...cmd,
|
|
65
|
+
cwd,
|
|
66
|
+
argv: cmd.argv.map((w, i) =>
|
|
67
|
+
dataPrograms.has(basename(cmd.argv[cmd.program]?.text ?? "")) && i > cmd.program ? w : curl && i > cmd.program ? (curlWord(w, cmd.cwd) ?? word(w, cmd.cwd)) : word(w, cmd.cwd),
|
|
68
|
+
),
|
|
69
|
+
redirects: cmd.redirects.map((r) => ((r.direction === "in" || r.direction === "out") && r.target ? { ...r, target: linked(r.target, cmd.cwd) } : r)),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
const checkedCwd = linked(req.inputCwd, "/");
|
|
73
|
+
const cwd = checkedCwd === req.inputCwd ? req.cwd : checkedCwd;
|
|
74
|
+
const target = req.target ? linked(req.pathInput, req.inputCwd) : req.target;
|
|
75
|
+
const searchRoot = req.searchRoot ? linked(req.pathInput || req.inputCwd, req.inputCwd) : req.searchRoot;
|
|
76
|
+
return changed ? { ...req, cwd, target, searchRoot, commands } : undefined;
|
|
59
77
|
}
|
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";
|