@hank-warren/pi-auto-permissions 0.12.0 → 0.13.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/setup-scan.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Fact gathering behind `/auto-permissions setup`.
7
+ *
8
+ * The privacy line matches Claude Code's `/auto-mode-setup` exactly: project
9
+ * files (git remotes, AGENTS.md/README.md, lockfiles) are read as content the
10
+ * user already chose to keep in the project, but session files contribute
11
+ * **command strings only** — the hosts, registries, buckets, and CLI names
12
+ * that appeared in commands the agent ran. User messages are never read,
13
+ * never excerpted, and never sent to the drafting model.
14
+ */
15
+
16
+ export interface ProjectScan {
17
+ remotes: string[];
18
+ lockfiles: string[];
19
+ registryHosts: string[];
20
+ /** First slice of AGENTS.md (preferred) or README.md, as workflow context. */
21
+ instructionExcerpt?: string;
22
+ }
23
+
24
+ export interface CommandScan {
25
+ cliNames: string[];
26
+ hosts: string[];
27
+ buckets: string[];
28
+ sampleCount: number;
29
+ }
30
+
31
+ const INSTRUCTION_EXCERPT_CHARS = 4_000;
32
+ const LOCKFILE_NAMES = [
33
+ "package-lock.json",
34
+ "yarn.lock",
35
+ "pnpm-lock.yaml",
36
+ "bun.lock",
37
+ "Cargo.lock",
38
+ "poetry.lock",
39
+ "uv.lock",
40
+ "go.sum",
41
+ "Gemfile.lock",
42
+ ];
43
+ const MAX_LOCKFILE_BYTES = 5 * 1024 * 1024;
44
+ const MAX_SESSION_FILE_BYTES = 8 * 1024 * 1024;
45
+ const MAX_LIST = 20;
46
+
47
+ const HOST_PATTERN = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?=[/:\s'"]|$)/gi;
48
+ const BUCKET_PATTERN = /\b(?:s3|gs):\/\/[a-z0-9._-]+/gi;
49
+ const SSH_HOST_PATTERN = /\b[a-z_][a-z0-9_-]*@([a-z0-9.-]+\.[a-z]{2,})\b/gi;
50
+
51
+ function unique(values: Iterable<string>): string[] {
52
+ return [...new Set(values)];
53
+ }
54
+
55
+ export function extractHosts(text: string): string[] {
56
+ const hosts: string[] = [];
57
+ for (const match of text.matchAll(HOST_PATTERN)) hosts.push(match[1].toLowerCase());
58
+ for (const match of text.matchAll(SSH_HOST_PATTERN)) hosts.push(match[1].toLowerCase());
59
+ return unique(hosts);
60
+ }
61
+
62
+ function gitRemotes(cwd: string): string[] {
63
+ try {
64
+ const output = execFileSync("git", ["remote", "-v"], {
65
+ cwd,
66
+ encoding: "utf8",
67
+ stdio: ["ignore", "pipe", "ignore"],
68
+ timeout: 5_000,
69
+ });
70
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ export function scanProject(cwd: string): ProjectScan {
77
+ const lockfiles: string[] = [];
78
+ const registryHosts: string[] = [];
79
+ for (const name of LOCKFILE_NAMES) {
80
+ const path = join(cwd, name);
81
+ if (!existsSync(path)) continue;
82
+ lockfiles.push(name);
83
+ try {
84
+ if (statSync(path).size > MAX_LOCKFILE_BYTES) continue;
85
+ registryHosts.push(...extractHosts(readFileSync(path, "utf8")));
86
+ } catch {
87
+ // A lockfile that cannot be read contributes only its name.
88
+ }
89
+ }
90
+
91
+ let instructionExcerpt: string | undefined;
92
+ for (const name of ["AGENTS.md", "README.md"]) {
93
+ try {
94
+ const content = readFileSync(join(cwd, name), "utf8");
95
+ instructionExcerpt = `${name}:\n${content.slice(0, INSTRUCTION_EXCERPT_CHARS)}`;
96
+ break;
97
+ } catch {
98
+ // Try the next candidate.
99
+ }
100
+ }
101
+
102
+ return {
103
+ remotes: gitRemotes(cwd),
104
+ lockfiles,
105
+ registryHosts: unique(registryHosts).slice(0, MAX_LIST),
106
+ ...(instructionExcerpt ? { instructionExcerpt } : {}),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Bash command strings from one session file's JSONL content — and nothing
112
+ * else. Only assistant toolCall blocks whose tool is bash contribute; user
113
+ * messages, assistant prose, and tool results are never extracted.
114
+ */
115
+ export function extractBashCommands(sessionFileContent: string): string[] {
116
+ const commands: string[] = [];
117
+ for (const line of sessionFileContent.split("\n")) {
118
+ if (!line.trim()) continue;
119
+ let entry: unknown;
120
+ try {
121
+ entry = JSON.parse(line);
122
+ } catch {
123
+ continue;
124
+ }
125
+ const message = (entry as { type?: string; message?: { role?: string; content?: unknown } })?.message;
126
+ if ((entry as { type?: string })?.type !== "message" || message?.role !== "assistant") continue;
127
+ const blocks = Array.isArray(message.content) ? message.content : [];
128
+ for (const block of blocks) {
129
+ if (!block || typeof block !== "object") continue;
130
+ const call = block as { type?: string; name?: string; arguments?: { command?: unknown } };
131
+ if (call.type !== "toolCall" || typeof call.name !== "string") continue;
132
+ const baseName = call.name.slice(call.name.lastIndexOf(".") + 1);
133
+ if (baseName !== "bash") continue;
134
+ if (typeof call.arguments?.command === "string") commands.push(call.arguments.command);
135
+ }
136
+ }
137
+ return commands;
138
+ }
139
+
140
+ export function summarizeCommands(commands: readonly string[]): CommandScan {
141
+ const cliCounts = new Map<string, number>();
142
+ const hosts = new Set<string>();
143
+ const buckets = new Set<string>();
144
+ for (const command of commands) {
145
+ const first = /^[\s(]*(?:sudo\s+)?([A-Za-z0-9_.\/-]+)/.exec(command)?.[1];
146
+ if (first) {
147
+ const name = first.slice(first.lastIndexOf("/") + 1);
148
+ if (name && !/^[0-9.]+$/.test(name)) cliCounts.set(name, (cliCounts.get(name) ?? 0) + 1);
149
+ }
150
+ for (const host of extractHosts(command)) hosts.add(host);
151
+ for (const match of command.matchAll(BUCKET_PATTERN)) buckets.add(match[0].toLowerCase());
152
+ }
153
+ const cliNames = [...cliCounts.entries()]
154
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
155
+ .slice(0, MAX_LIST)
156
+ .map(([name]) => name);
157
+ return {
158
+ cliNames,
159
+ hosts: [...hosts].slice(0, MAX_LIST),
160
+ buckets: [...buckets].slice(0, MAX_LIST),
161
+ sampleCount: commands.length,
162
+ };
163
+ }
164
+
165
+ /** Newest session files first, bounded, across the per-project directories. */
166
+ export function scanRecentSessionCommands(sessionsRoot: string, maxFiles = 10): CommandScan {
167
+ const files: { path: string; mtimeMs: number }[] = [];
168
+ try {
169
+ for (const dir of readdirSync(sessionsRoot)) {
170
+ const dirPath = join(sessionsRoot, dir);
171
+ let names: string[];
172
+ try {
173
+ names = readdirSync(dirPath);
174
+ } catch {
175
+ continue;
176
+ }
177
+ for (const name of names) {
178
+ if (!name.endsWith(".jsonl")) continue;
179
+ const path = join(dirPath, name);
180
+ try {
181
+ const stats = statSync(path);
182
+ if (stats.size <= MAX_SESSION_FILE_BYTES) files.push({ path, mtimeMs: stats.mtimeMs });
183
+ } catch {
184
+ // Skip unreadable files.
185
+ }
186
+ }
187
+ }
188
+ } catch {
189
+ return { cliNames: [], hosts: [], buckets: [], sampleCount: 0 };
190
+ }
191
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
192
+
193
+ const commands: string[] = [];
194
+ for (const file of files.slice(0, maxFiles)) {
195
+ try {
196
+ commands.push(...extractBashCommands(readFileSync(file.path, "utf8")));
197
+ } catch {
198
+ // Skip unreadable files.
199
+ }
200
+ }
201
+ return summarizeCommands(commands);
202
+ }
203
+
204
+ export const SETUP_DRAFT_SYSTEM_PROMPT = `You draft trust configuration for Auto Permissions, a command reviewer for an AI coding agent. You receive verifiable facts about one operator's project and recent agent activity: git remotes, lockfiles and the registry hosts inside them, an excerpt of the project's own instructions, and facts derived from commands recent sessions ran (CLI names, hosts, buckets). No user conversation is included and none may be assumed.
205
+
206
+ Draft two things:
207
+ 1. "environment": prose entries for the reviewer's trust configuration, written the way you would describe the infrastructure to a new engineer ("Our GitHub org X is trusted source control", "registry.internal.example is our internal package registry"). Only name infrastructure the provided facts support. Never invent hosts, orgs, or services, and never include one-off or public utility hosts (example.com downloads, api.github.com) unless the facts show them as this project's own infrastructure.
208
+ 2. "trustedGroups": rule groups from the provided list that this project could reasonably trust in .pi/trusted-ops, based on what the commands show as routine. Suggest a group only when the activity clearly supports it; when in doubt, suggest none.
209
+
210
+ Return strict JSON only: {"environment": string[], "trustedGroups": string[]}. Either list may be empty.`;
211
+
212
+ export function buildSetupDraftRequest(
213
+ project: ProjectScan,
214
+ commands: CommandScan,
215
+ availableGroups: readonly string[],
216
+ ): string {
217
+ return [
218
+ "<PROJECT_FACTS>",
219
+ JSON.stringify(
220
+ {
221
+ gitRemotes: project.remotes,
222
+ lockfiles: project.lockfiles,
223
+ lockfileRegistryHosts: project.registryHosts,
224
+ ...(project.instructionExcerpt ? { projectInstructionsExcerpt: project.instructionExcerpt } : {}),
225
+ },
226
+ null,
227
+ 2,
228
+ ),
229
+ "</PROJECT_FACTS>",
230
+ "",
231
+ "<RECENT_COMMAND_FACTS note=\"derived from command strings only; no user messages\">",
232
+ JSON.stringify(commands, null, 2),
233
+ "</RECENT_COMMAND_FACTS>",
234
+ "",
235
+ `<AVAILABLE_TRUST_GROUPS>${JSON.stringify([...availableGroups])}</AVAILABLE_TRUST_GROUPS>`,
236
+ ].join("\n");
237
+ }
238
+
239
+ export interface SetupDraft {
240
+ environment: string[];
241
+ trustedGroups: string[];
242
+ }
243
+
244
+ export function parseSetupDraft(text: string, availableGroups: readonly string[]): SetupDraft {
245
+ const trimmed = text.trim();
246
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1];
247
+ const candidate = fenced ?? trimmed;
248
+ const start = candidate.indexOf("{");
249
+ const end = candidate.lastIndexOf("}");
250
+ if (start < 0 || end <= start) throw new Error("setup draft returned no JSON object");
251
+ const value = JSON.parse(candidate.slice(start, end + 1)) as { environment?: unknown; trustedGroups?: unknown };
252
+
253
+ const strings = (input: unknown): string[] =>
254
+ Array.isArray(input)
255
+ ? unique(input.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()))
256
+ : [];
257
+ const groups = new Set(availableGroups);
258
+ return {
259
+ environment: strings(value.environment).slice(0, MAX_LIST),
260
+ trustedGroups: strings(value.trustedGroups).filter((group) => groups.has(group)),
261
+ };
262
+ }