@loophubs/agent-guard 0.3.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.
@@ -0,0 +1,33 @@
1
+ import { basename } from "node:path";
2
+
3
+ import type { Context } from "./programs";
4
+ import type { Effect, Target } from "./record";
5
+
6
+ // rg, grep, ag and ack: the roles search-roles.ts gave the words say which are roots, pattern files and globs.
7
+ export function searchTargets(name: string, { cmd, words, make, claimed }: Context): Target[] {
8
+ const help = cmd.flags.has("help");
9
+ const files = cmd.flags.has("files");
10
+ const recursive = name === "grep" && cmd.flags.has("recursive");
11
+ const walk = recursive || (["rg", "ag"].includes(name) && cmd.flags.has("hidden")) ? "hidden" : "visible";
12
+ // Listing names, or printing help, reads no content; a hidden listing feeds what reads it.
13
+ const effect: Effect = help || (files && walk !== "hidden") ? "list" : "read";
14
+ const targets: Target[] = [];
15
+ for (const [i, word] of words.entries()) {
16
+ // The file these options name holds ignore rules that the search reads.
17
+ const owner = word.role === "optarg" ? words[i - 1] : word.role === "option:optarg" ? word : undefined;
18
+ if (word.role === "path") targets.push(make(word.value, word, effect, { via: "operand", walk }));
19
+ else if (word.role === "patfile" || word.role === "option:patfile") targets.push(make(word.value, word, effect, { via: "option", walk: "none" }));
20
+ else if (owner && /^--(ignore-file|exclude-from)(=|$)/.test(owner.text)) targets.push(make(word.value, word, "read", { via: "option", walk: "none" }));
21
+ else continue;
22
+ claimed.add(word);
23
+ }
24
+ const scoped = targets.some((target) => target.via === "operand");
25
+ if (!scoped && (name !== "grep" || recursive)) targets.push(make(cmd.cwd, undefined, help ? "list" : effect, { via: name === "grep" ? "cwd" : "scan", walk, search: !help }));
26
+ for (const word of words) {
27
+ if (word.role !== "glob" && word.role !== "option:glob") continue;
28
+ claimed.add(word);
29
+ // A positive glob selects files even past ignore rules.
30
+ targets.push(make(basename(word.value), word, effect, { via: "option", glob: true, walk: "none" }));
31
+ }
32
+ return targets;
33
+ }
@@ -0,0 +1,46 @@
1
+ import type { Context } from "./programs";
2
+ import type { Effect, Target } from "./record";
3
+
4
+ // `-o KEY=VALUE`, `-o "KEY VALUE"` and `-oKEY=VALUE` set a client option; these keys name a file the client reads or writes.
5
+ const sshFileOptions: Record<string, Effect> = { identityfile: "use", certificatefile: "use", globalknownhostsfile: "use", userknownhostsfile: "write", revokedhostkeys: "use", pkcs11provider: "use" };
6
+
7
+ // The option letters that take a value, from each client's synopsis in the OpenSSH 10.3p1 manual page; the first one in a cluster takes the rest of the word or the next word.
8
+ const valueLetters: Record<string, string> = { ssh: "BDEFIJLOPQRSWbceilmopw", scp: "DFJPSXcilo", sftp: "BDFJPRSXbcilos" };
9
+
10
+ // The client uses the identity and configuration files itself (sftp passes them to ssh), and ssh writes its log file.
11
+ const letterEffects: Record<string, Effect> = { i: "use", F: "use", E: "write" };
12
+
13
+ // ssh reads options before and right after the destination, scp and sftp before their first operand; later words are the remote command or operands.
14
+ export const sshTargets =
15
+ (client: "ssh" | "scp" | "sftp") =>
16
+ ({ words, make, claimed }: Context): Target[] => {
17
+ const targets: Target[] = [];
18
+ let operands = 0;
19
+ for (let i = 0; i < words.length; i++) {
20
+ const word = words[i]!;
21
+ if (word.text === "--") break;
22
+ if (!/^-./.test(word.text)) {
23
+ if (++operands === (client === "ssh" ? 2 : 1)) break;
24
+ continue;
25
+ }
26
+ const at = [...word.text.slice(1)].findIndex((letter) => valueLetters[client]!.includes(letter));
27
+ if (at < 0) continue;
28
+ const letter = word.text[at + 1]!;
29
+ const glued = word.text.slice(at + 2);
30
+ const holder = glued ? word : words[++i];
31
+ if (!holder) break;
32
+ const value = glued || holder.text;
33
+ let effect: Effect | undefined = client === "sftp" && letter === "b" && value !== "-" ? "read" : letterEffects[letter];
34
+ let paths = [value];
35
+ if (letter === "o") {
36
+ const setting = /^(\w+)(?:\s*=\s*|\s+)(.+)$/s.exec(value);
37
+ effect = sshFileOptions[setting?.[1]?.toLowerCase() ?? ""];
38
+ // ssh strips double quotes, takes several known-hosts files separated by spaces, and expands %d and ${HOME} to the home directory.
39
+ paths = (setting?.[2] ?? "").match(/"[^"]*"|\S+/g)?.map((path) => path.replace(/"/g, "").replace(/^(%d|\$\{HOME\})(?=\/|$)/, "~")) ?? [];
40
+ }
41
+ if (!effect) continue;
42
+ claimed.add(holder);
43
+ for (const path of paths) targets.push(make(path, holder, effect, { via: "option", ...(letter === "o" && { quoted: false }) }));
44
+ }
45
+ return targets;
46
+ };
package/src/ssh.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { basename, dirname } from "node:path";
2
+
3
+ import { followLinks, notALink } from "./links";
4
+ import { isAppdata, sshPublic } from "./paths";
5
+ import { probes } from "./probes";
6
+
7
+ // A path that does not exist has no inode to compare. Any other failure means the comparison did not run, so it propagates.
8
+ function stat(path: string) {
9
+ try {
10
+ return probes.stat(path);
11
+ } catch (error) {
12
+ if (notALink.includes((error as NodeJS.ErrnoException).code ?? "")) return undefined;
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ function sameFile(a: string, b: string): boolean {
18
+ if (a === b) return true;
19
+ const x = stat(a);
20
+ const y = stat(b);
21
+ return x !== undefined && y !== undefined && x.dev === y.dev && x.ino === y.ino;
22
+ }
23
+
24
+ function kind(path: string): "dir" | "file" | "other" {
25
+ const st = stat(path);
26
+ return st?.isDirectory() ? "dir" : st?.isFile() ? "file" : "other";
27
+ }
28
+
29
+ // Whether the spelling puts a path at or under `root`, or for a search above it. Case is ignored because the default APFS volume ignores it.
30
+ function near(path: string, root: string, search: boolean): boolean {
31
+ const [spelled, base] = [path.toLowerCase().replace(/\/$/, "") || "/", root.toLowerCase()];
32
+ return spelled === base || spelled.startsWith(`${base}/`) || (search && (spelled === "/" || base.startsWith(`${spelled}/`)));
33
+ }
34
+
35
+ // Filesystem check for a file tool target, or a search root when search is set. It compares inodes so a case alias of ~/.ssh resolves to the
36
+ // directory it names. Only a path whose spelling, or whose readlink-followed spelling, is already at or under ~/.ssh is compared: the inode
37
+ // probes follow links, so a target that is not established to be in ~/.ssh scope is never handed to them. When ~/.ssh scope leads into App Data
38
+ // the comparison is skipped and the target is denied, because stat would search that tree.
39
+ export function sshScopeDenied(target: string, home: string, search: boolean): boolean {
40
+ const ssh = `${home}/.ssh`;
41
+ const appdataOnly = (path: string) => isAppdata(path, home);
42
+ const roots = [...new Set([ssh, followLinks(ssh, home, appdataOnly)])];
43
+ const candidates = [...new Set([target, followLinks(target, home, appdataOnly)])];
44
+ if (!candidates.some((candidate) => roots.some((root) => near(candidate, root, search)))) return false;
45
+ if ([...roots, ...candidates].some((path) => isAppdata(path, home))) return true;
46
+ for (const candidate of candidates) {
47
+ for (const root of roots) {
48
+ if (sameFile(candidate, root)) return true;
49
+ if (search) {
50
+ for (let parent = root; parent !== "/";) {
51
+ parent = dirname(parent);
52
+ if (sameFile(candidate, parent)) return true;
53
+ }
54
+ }
55
+ for (let parent = candidate; parent !== "/";) {
56
+ parent = dirname(parent);
57
+ if (!sameFile(parent, root)) continue;
58
+ if (parent !== dirname(candidate) || !sshPublic(basename(candidate))) return true;
59
+ if (kind(target) === "dir") return true;
60
+ if (search && kind(target) !== "file") return true;
61
+ }
62
+ }
63
+ }
64
+ return false;
65
+ }
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
+ }