@loophubs/agent-guard 0.2.0 → 0.4.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/targets.ts ADDED
@@ -0,0 +1,151 @@
1
+ // Turn a request into inferred targets under modelled command semantics, each with what the command is modelled to do to it.
2
+ import { basename } from "node:path";
3
+
4
+ import { programName } from "./argv";
5
+ import { absPath, expandHome } from "./paths";
6
+ import { type Context, DEFAULT_EFFECT, dataPrograms, globalOptions, specFor, specs, walkOf } from "./programs";
7
+ import type { Command, Effect, Request, Target, Word } from "./record";
8
+
9
+ const pathRoles = new Set(["arg", "path", "patfile", "option:patfile", "optarg"]);
10
+
11
+ function maker(home: string, cmd: Command, command: number, walk: Target["walk"], sends: boolean): Context["make"] {
12
+ return (path, word, effect, options = {}) => {
13
+ const quoted = options.quoted ?? (word ? /^['"]/.test(word.raw) : false);
14
+ const input = (quoted ? path : expandHome(path, home)).replace(/^file:\/\//i, "");
15
+ const base = options.base ?? cmd.cwd;
16
+ return {
17
+ path: absPath(path, base, home, quoted),
18
+ unresolved: input.startsWith("/") ? input : `${base}/${input}`,
19
+ glob: options.glob ?? word?.globs ?? false,
20
+ effect,
21
+ walk: options.walk ?? walk,
22
+ sends: options.sends ?? sends,
23
+ expands: word?.expands ?? false,
24
+ via: options.via ?? "operand",
25
+ search: options.search ?? false,
26
+ command,
27
+ };
28
+ };
29
+ }
30
+
31
+ // A glued `--name=value` is a path in the value, and `@path` or an httpie `field=@path` reads the file; a bare option is not a path.
32
+ function operandValue(word: Word): string | undefined {
33
+ const value = word.value.startsWith("-") ? (word.value.includes("=") ? word.value.slice(word.value.indexOf("=") + 1) : undefined) : word.value;
34
+ if (value === undefined) return undefined;
35
+ if (value.startsWith("@")) return value.slice(1) || undefined;
36
+ return /^[^=@\s]+?(?:==?|:)@(.+)$/s.exec(value)?.[1] ?? value;
37
+ }
38
+
39
+ function commandTargets(cmd: Command, command: number, home: string): Target[] {
40
+ const program = cmd.argv[cmd.program];
41
+ const name = program ? programName(program.text) : "";
42
+ const spec = specFor(name);
43
+ // A command with no program, such as a for loop's word list, still names paths.
44
+ const words = cmd.argv.slice(cmd.program + 1);
45
+ const walk = walkOf(spec, name, words);
46
+ const options = { ...globalOptions, ...spec.options };
47
+ // An option's value takes the option's effect, glued with = or in the next word; the last letter of a cluster such as -lf takes the value.
48
+ const optionEffect = (i: number): Effect | undefined => {
49
+ const word = words[i]!;
50
+ const previous = words[i - 1]?.text ?? "";
51
+ const option = word.value.startsWith("-") ? (word.value.includes("=") ? word.value.slice(0, word.value.indexOf("=")) : "") : /^-[^-]/.test(previous) ? `-${previous.at(-1)}` : previous;
52
+ return options[option];
53
+ };
54
+ // A short option takes the rest of its word as the value: `-idata`, and `-vidata` after flags in a cluster.
55
+ const glued = (word: Word): { value: string; effect: Effect } | undefined => {
56
+ if (!/^-[^-]/.test(word.text)) return undefined;
57
+ const letters = [...word.text.slice(1)];
58
+ const at = letters.findIndex((letter) => options[`-${letter}`] !== undefined);
59
+ return at >= 0 && at < letters.length - 1 ? { value: word.text.slice(at + 2), effect: options[`-${letters[at]}`]! } : undefined;
60
+ };
61
+ const operandWords = words.filter((word, i) => !word.text.startsWith("-") && optionEffect(i) === undefined);
62
+ // With `-t DIR`, alone or in a cluster, every operand is a source and the directory is the destination. A glob expands to several words, so it may hide sources.
63
+ const last = operandWords.at(-1);
64
+ const intoDirectory =
65
+ spec.options?.["-t"] === "write" && words.some((word) => /^-[^-]*t/.test(word.text) || (/^--t[a-z-]*(=|$)/.test(word.text) && "--target-directory".startsWith(word.text.split("=")[0]!)));
66
+ const destination = spec.last && !last?.globs && !intoDirectory ? last : undefined;
67
+ const remote = (word: Word) => spec.remote?.test(word.value) ?? false;
68
+ // A copy sends what it reads only when it names another machine; a local copy keeps its reads on this one.
69
+ const sends = (spec.sends ?? false) && (!spec.remote || operandWords.some(remote));
70
+ const make = maker(home, cmd, command, walk, sends);
71
+ const ctx: Context = { cmd, words, walk, claimed: new Set(), make };
72
+ // The word list of a for loop or a [[ test is not read by the shell.
73
+ const operands: Effect = spec.operands ?? (program ? DEFAULT_EFFECT : "use");
74
+ const targets: Target[] = [];
75
+ for (const redirect of cmd.redirects) {
76
+ if ((redirect.direction === "in" || redirect.direction === "out") && redirect.target)
77
+ targets.push(make(redirect.target, undefined, redirect.direction === "in" ? "read" : "write", { via: "redirect", glob: redirect.globs }));
78
+ }
79
+ if (cmd.items && program) targets.push(make(cmd.items.root, undefined, operands, { via: "items", glob: false, walk: cmd.items.hidden ? "hidden" : "visible" }));
80
+ // xargs reads its arguments from the -a file.
81
+ if (cmd.wrappers.includes("xargs") && program) {
82
+ const options = cmd.argv.slice(0, cmd.program);
83
+ for (const [i, word] of options.entries()) {
84
+ const file = word.text === "-a" || word.text === "--arg-file" ? options[i + 1] : undefined;
85
+ if (file) targets.push(make(file.text, file, "read", { via: "option" }));
86
+ else if (word.text.startsWith("--arg-file=")) targets.push(make(word.text.slice("--arg-file=".length), word, "read", { via: "option" }));
87
+ }
88
+ }
89
+ if (program?.value.includes("/")) targets.push(make(program.value, program, "use", { via: "option" }));
90
+ const start = targets.length;
91
+ targets.push(...(spec.targets?.(ctx) ?? []));
92
+ for (const [i, word] of words.entries()) {
93
+ const short = ctx.claimed.has(word) ? undefined : glued(word);
94
+ if (short) {
95
+ targets.push(make(short.value, word, short.effect, { via: "option" }));
96
+ continue;
97
+ }
98
+ const value = pathRoles.has(word.role) && !ctx.claimed.has(word) ? operandValue(word) : undefined;
99
+ if (!value) continue;
100
+ const effect = remote(word) ? "name" : word === destination ? spec.last! : (optionEffect(i) ?? (word.role === "optarg" ? "use" : operands));
101
+ targets.push(make(value, word, effect, { via: word.role === "optarg" ? "option" : "operand" }));
102
+ }
103
+ const lists = spec.cwd && !targets.slice(start).some((target) => target.via === "operand");
104
+ if (lists) targets.push(make(cmd.cwd, undefined, "list", { via: spec.cwd! }));
105
+ // A program runs in its working directory, which App Data records even when the command names nothing,
106
+ // or when it is a program the table does not model and may read what it does not name.
107
+ const named = targets.some((target) => ["operand", "cwd", "scan"].includes(target.via) && !["enter", "name"].includes(target.effect));
108
+ if (program && !dataPrograms.includes(name) && !["cd", "pushd", "popd"].includes(name) && (!named || !specs.has(name))) targets.push(make(cmd.cwd, undefined, "enter", { via: "cwd", walk: "none" }));
109
+ return targets;
110
+ }
111
+
112
+ // A word that starts like a path, or touches a quote: `e.key` is a property, `'.key'` and `"cert.pem"` are files. Quotes are not paired
113
+ // across lines, so an apostrophe in a comment does not hide the string literals after it; a string literal is a shell command that can name a file
114
+ // anywhere in it, so its words count wherever they sit.
115
+ function codeTokens(code: string): string[] {
116
+ const words = (text: string) => [...text.matchAll(/[\w.~/-]+/g)];
117
+ const strings = [...code.matchAll(/(['"`])((?:(?!\1)[^\n])*)\1/g)].flatMap((match) => words(match[2]!));
118
+ return [...words(code).filter((match) => /^[.~]|\//.test(match[0]) || /['"`]/.test(code[match.index - 1] ?? "") || /['"`]/.test(code[match.index + match[0].length] ?? "")), ...strings].map(
119
+ (match) => match[0],
120
+ );
121
+ }
122
+
123
+ export function extractTargets(req: Request): Target[] {
124
+ const targets: Target[] = [];
125
+ const add = (path: string, cwd: string, inputCwd: string, effect: Effect, options: Partial<Target>) => {
126
+ const input = expandHome(path, req.home);
127
+ targets.push({
128
+ path: absPath(path, cwd, req.home),
129
+ unresolved: input.startsWith("/") ? input : `${inputCwd}/${input}`,
130
+ glob: false,
131
+ effect,
132
+ walk: "none",
133
+ sends: false,
134
+ expands: false,
135
+ via: "tool",
136
+ search: false,
137
+ command: -1,
138
+ ...options,
139
+ });
140
+ };
141
+ const tool = (path: string, effect: Effect, options: Partial<Target> = {}) => add(path, req.cwd, req.inputCwd, effect, options);
142
+ if (req.operation === "read" || req.operation === "write") tool(req.pathInput, req.operation === "read" ? "read" : "write");
143
+ if (req.operation === "search") {
144
+ tool(req.pathInput || req.inputCwd, "read", { walk: "visible", search: true });
145
+ if (req.glob && !req.glob.startsWith("!")) tool(`${req.searchRoot}/${basename(req.glob)}`, "read", { glob: true });
146
+ }
147
+ req.commands.forEach((cmd, command) => targets.push(...commandTargets(cmd, command, req.home)));
148
+ // Inline code opens files the guard cannot trace, so each token that names a path is inferred to be a read target.
149
+ for (const { text, cwd } of req.uninspectable) for (const token of codeTokens(text)) add(token, cwd, cwd, "read", { via: "code" });
150
+ return targets;
151
+ }
package/src/words.ts CHANGED
@@ -6,13 +6,16 @@ import type { Word } from "./record";
6
6
 
7
7
  const type = sh.syntax.NodeType;
8
8
 
9
- export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, visit: (part: Node, names: string[]) => void): Word {
9
+ export function readWord(node: WordNode, slice: (start: number, end: number) => string, vars: Map<string, string>, home: string, pwd: string, visit: (part: Node, names: string[]) => void): Word {
10
10
  const text = (part: Node) => slice(part.Pos().Offset(), part.End().Offset());
11
- const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "" };
11
+ const out: Word = { text: "", raw: text(node), expands: false, globs: false, vars: [], role: "arg", value: "", pwd: false };
12
12
  const expansion = (part: Node) => {
13
13
  const param = part as ParamExp;
14
14
  const plain = type(part) === "ParamExp" && !(param.Excl || param.Length || param.Width || param.Index || param.Slice || param.Repl || param.Exp);
15
- const known = plain ? (param.Param!.Value === "HOME" ? home : vars.get(param.Param!.Value)) : undefined;
15
+ const name = plain ? param.Param!.Value : "";
16
+ const printsPwd = name === "PWD" || (type(part) === "CmdSubst" && /^(\$\(|`)\s*pwd(\s+-[LP])?\s*(\)|`)$/.test(text(part)));
17
+ const known = printsPwd ? pwd : name === "HOME" ? home : plain ? vars.get(name) : undefined;
18
+ out.pwd ||= printsPwd;
16
19
  out.text += known ?? text(part);
17
20
  out.expands ||= known === undefined;
18
21
  visit(part, out.vars);
@@ -21,7 +24,16 @@ export function readWord(node: WordNode, slice: (start: number, end: number) =>
21
24
  const kind = type(part);
22
25
  if (kind === "Lit") {
23
26
  let value = (part as Lit).Value;
24
- if (index === 0) value = expandHome(value, home);
27
+ if (index === 0 && /^~\+(\/|$)/.test(value)) {
28
+ value = pwd + value.slice(2);
29
+ out.pwd = true;
30
+ } else if (index === 0) value = expandHome(value, home);
31
+ // Bun expands {a,b} but not the sequence {a..z}, so read a sequence as a wildcard.
32
+ const sequence = /(?<!\\)\{(?:-?\d+|[A-Za-z])\.\.(?:-?\d+|[A-Za-z])(?:\.\.-?\d+)?\}/g;
33
+ if (sequence.test(value)) {
34
+ value = value.replace(sequence, "*");
35
+ out.globs = true;
36
+ }
25
37
  if (Bun.$.braces(value).length > 1) out.globs = true;
26
38
  for (let i = 0; i < value.length; i++) {
27
39
  if (value[i] === "\\") {