@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/src/frontend.ts CHANGED
@@ -1,30 +1,51 @@
1
- // Parse shell structure into command records; rejected syntax remains
2
- // available to the signature rules.
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
- 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";
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; 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
  }
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.uninspectable.push(src);
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, scope);
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, inner);
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, op === "&&" || op === "||" ? scope : isolated(scope));
98
+ statement(cmd.X!, op === "&&" || op === "||" ? scope : isolated(scope));
74
99
  const middle = script.commands.length;
75
- const directCd = op === "&&" && movedOnSuccess(cmd.X, scope.slice);
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, scope);
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
- statement(cmd.Y, op === "||" ? failed : op === "&&" ? scope : isolated(scope));
84
- if (op === "|") for (const item of xargsReplacements(script.commands.slice(start, middle), script.commands.slice(middle))) parse(item.source, item.cwd);
85
- if (op === "||") {
86
- scope.dir.alternatives = boundedDirectories(scope.dir.cwd, [...(scope.dir.alternatives ?? []), failed.dir.cwd, ...(failed.dir.alternatives ?? [])], home);
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") scope = isolated(scope);
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, condition);
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, then);
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(scope.dir.cwd, branches.flatMap((branch) => [branch.dir.cwd, ...(branch.dir.alternatives ?? [])]), home);
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.Value);
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, inner);
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, scope, "assign"));
170
- for (const w of (cmd as CallExpr).Args) argv.push(word(w, scope));
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.Value));
211
+ argv.push(literal(decl.Variant!.Value));
174
212
  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 ?? ""));
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, scope)).filter((r) => r !== undefined);
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
- for (const cwd of scope.dir.alternatives ?? []) script.commands.push({ ...command, cwd });
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.Pos().Offset()).trim();
254
+ const op = scope.slice(node.OpPos.Offset(), node.Word!.Pos().Offset()).trim();
205
255
  if (op === "<<" || op === "<<-") {
206
- const quoted = node.Word.Parts.some((p) => type(p) !== "Lit" || (p as Lit).Value.includes("\\"));
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, scope);
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
- 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;
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(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;
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 { 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,10 @@
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 { 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
- path = resolve(path, parts.shift()!);
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 = resolve(dirname(path), target);
34
+ const targetPath = target.startsWith("/") ? target : `${dirname(path)}/${target}`;
32
35
  followed = true;
33
36
  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);
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, /^[\'"]/.test(w.raw));
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
- 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;
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";