@getfootgun/keys 0.0.0-stage → 0.2.0-rc.1

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/lib/mcp.mjs ADDED
@@ -0,0 +1,271 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
+ import { keychainStore, openVault } from "./vault.mjs";
6
+ import { makeServer } from "./server.mjs";
7
+ import { writeEnv } from "./envfile.mjs";
8
+ import { runTest } from "./testcall.mjs";
9
+ import { createSession, readSession, isExpired } from "./sessions.mjs";
10
+ import { SITE } from "./paths.mjs";
11
+ import { openUrl } from "./platform.mjs";
12
+ import { groupByProviderLabel, healthRecord } from "./check.mjs";
13
+ import { isProtected } from "./protect.mjs";
14
+ import { LABEL_RE, FREE_GUIDES } from "../shared/contract.mjs";
15
+
16
+ // Every result here carries names, statuses and messages. A key value is never read into a result.
17
+ const HELPER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+ export const SERVER_INFO = { name: "footgun-keys", version: HELPER_VERSION };
19
+
20
+ export const INSTRUCTIONS =
21
+ "When a project needs a third-party API key, call keys_request instead of asking the user to paste it. Tool results never contain key values.";
22
+
23
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const PROVIDER_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; // the server's guide slug rule
25
+ const BAD_PROVIDER = "That is not a valid guide name. Use a slug such as stripe or openai.";
26
+ const BAD_LABEL = "A label can use letters, numbers, spaces, dots, dashes and underscores, up to 64 characters.";
27
+ const PROTECT_TIP = 'Run "npx @getfootgun/keys protect" in this project so Claude Code\'s Read tool will not open it.';
28
+
29
+ const reply = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
30
+ const proText = () => `This needs Footgun Pro. ${SITE()}/#pricing`;
31
+ const SETUP_TEXT = "Run npx @getfootgun/keys setup to connect your Footgun account (it is free).";
32
+ const UNCONFIRMED_TEXT = "Footgun Keys could not confirm your Footgun plan (offline, or your API key was not accepted). Check your connection or run npx @getfootgun/keys setup again.";
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
+ // After an env file is written: say when .gitignore changed, and show the protect tip until the project denies reads.
35
+ async function writeNotes(wrote, projectPath) {
36
+ if (!wrote) return [];
37
+ const lines = wrote.addedGitignore ? [`Added ${wrote.file} to .gitignore.`] : [];
38
+ if (!(await isProtected(projectPath))) lines.push(PROTECT_TIP);
39
+ return lines;
40
+ }
41
+
42
+ export function buildTools({ vault, server, openUrl, home, now = () => new Date(), waitMs = 120000, fetch: fetchFn }) {
43
+ // Why a paid tool cannot run, or null when it can. Only a confirmed plan that lacks it gets the Pro message.
44
+ async function planBlock(tool, slug) {
45
+ const ent = await server.entitlement();
46
+ if (ent === null) return (await server.apiKey()) === null ? SETUP_TEXT : UNCONFIRMED_TEXT;
47
+ if (!ent.tools.includes(tool)) return proText();
48
+ if (slug !== undefined && !FREE_GUIDES.includes(slug) && ent.allGuides !== true) return proText();
49
+ return null;
50
+ }
51
+ async function guideAllowed(slug) {
52
+ if (FREE_GUIDES.includes(slug)) return true;
53
+ return (await server.entitlement())?.allGuides === true;
54
+ }
55
+ // Test with every stored value the guide captures; the values stay inside this function.
56
+ async function testGuide(guide, provider, label) {
57
+ const values = {};
58
+ for (const c of guide.captures) {
59
+ const v = await vault.get(provider, label, c.envVar);
60
+ if (v !== null) values[c.envVar] = v;
61
+ }
62
+ return runTest(guide, values, { fetch: fetchFn });
63
+ }
64
+ const testLine = (t) => (!t ? "Test call: skipped." : t.ok ? "Test call: ok." : `Test call: failed (${t.reason ?? "unexpected"}).`);
65
+
66
+ async function savedText(r, projectPath) {
67
+ const lines = [`Saved ${r.saved.join(", ")}.`];
68
+ if (r.wrote) lines.push(`Wrote to ${r.wrote.file} in the project.`);
69
+ lines.push(testLine(r.test));
70
+ lines.push(...(await writeNotes(r.wrote, projectPath)));
71
+ return lines.join("\n");
72
+ }
73
+
74
+ const tools = {
75
+ async keys_list() {
76
+ const entries = await vault.list();
77
+ if (entries.length === 0) return reply("No keys saved yet.");
78
+ return reply(entries.map((e) => {
79
+ const status = e.health === null ? "not checked" : e.health.ok ? "ok" : `failing: ${e.health.reason ?? "unknown"}`;
80
+ return `${e.envVar} (${e.provider}, ${e.label}): ${status}; used in ${e.projects.length} projects`;
81
+ }).join("\n"));
82
+ },
83
+
84
+ async keys_guides() {
85
+ const r = await server.guides();
86
+ if (!r.ok) return reply(`Could not load the guide list: ${r.error}`, true);
87
+ return reply(r.guides.map((g) => `${g.name} (${g.slug}): ${g.tier === "pro" ? "Pro" : "Free"}, ${g.status}`).join("\n"));
88
+ },
89
+
90
+ async keys_write_env({ project_path, env_vars }) {
91
+ const bad = env_vars.find((n) => !NAME_RE.test(n));
92
+ if (bad !== undefined) return reply("One of the names is not a valid env var name.", true);
93
+ const entries = await vault.list();
94
+ const found = [];
95
+ const missing = [];
96
+ for (const name of env_vars) {
97
+ const hits = entries.filter((e) => e.envVar === name).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
98
+ const value = hits[0] ? await vault.get(hits[0].provider, hits[0].label, name) : null;
99
+ if (value === null) missing.push(name);
100
+ else found.push({ name, value, entry: hits[0] });
101
+ }
102
+ const lines = [];
103
+ let wrote = null;
104
+ if (found.length > 0) {
105
+ try {
106
+ wrote = await writeEnv(project_path, found.map(({ name, value }) => ({ name, value })));
107
+ } catch (e) {
108
+ return reply(e instanceof Error && e.message ? e.message : "Could not write the env file.", true);
109
+ }
110
+ for (const f of found) await vault.addProject(f.entry.provider, f.entry.label, f.name, project_path);
111
+ lines.push(`Wrote ${found.map((f) => f.name).join(", ")} to ${wrote.file}.`);
112
+ }
113
+ for (const m of missing) lines.push(`${m}: missing (nothing written)`);
114
+ lines.push(...(await writeNotes(wrote, project_path)));
115
+ return reply(lines.join("\n"));
116
+ },
117
+
118
+ async keys_request({ provider, project_path, env_vars, label = "default", session }) {
119
+ if (typeof provider !== "string" || !PROVIDER_RE.test(provider)) return reply(BAD_PROVIDER, true);
120
+ if (typeof label !== "string" || !LABEL_RE.test(label)) return reply(BAD_LABEL, true);
121
+ const blocked = await planBlock("keys_request", provider);
122
+ if (blocked) return reply(blocked);
123
+ if (env_vars?.some((n) => !NAME_RE.test(n))) return reply("One of the names is not a valid env var name.", true);
124
+
125
+ if (session !== undefined) {
126
+ const s = await readSession(session, home);
127
+ if (!s) return reply("That guide session was not found. Call keys_request without a session to start again.", true);
128
+ if (isExpired(s, now())) return reply("That guide session expired.");
129
+ return waitFor(s.id);
130
+ }
131
+
132
+ if (env_vars?.length === 0) return reply("Name at least one env var, or leave env_vars out to get everything the guide captures.", true);
133
+ const g = await server.guide(provider);
134
+ if (!g.ok && !env_vars) return reply(`Could not load the ${provider} guide: ${g.error}`, true);
135
+ if (g.ok && env_vars) {
136
+ const captured = g.guide.captures.map((c) => c.envVar);
137
+ const extra = env_vars.filter((n) => !captured.includes(n));
138
+ if (extra.length) return reply(`The ${provider} guide does not capture ${extra.join(", ")}. It captures ${captured.join(", ")}.`, true);
139
+ }
140
+ const needed = env_vars ?? g.guide.captures.map((c) => c.envVar);
141
+
142
+ const values = [];
143
+ for (const name of needed) {
144
+ const v = await vault.get(provider, label, name);
145
+ if (v === null) break;
146
+ values.push({ name, value: v });
147
+ }
148
+ if (values.length === needed.length && needed.length > 0) {
149
+ let wrote;
150
+ try {
151
+ wrote = await writeEnv(project_path, values);
152
+ } catch (e) {
153
+ return reply(e instanceof Error && e.message ? e.message : "Could not write the env file.", true);
154
+ }
155
+ for (const name of needed) await vault.addProject(provider, label, name, project_path);
156
+ const test = g.ok ? await testGuide(g.guide, provider, label) : undefined;
157
+ return reply(await savedText({ saved: needed, wrote, test }, project_path));
158
+ }
159
+
160
+ const s = await createSession({ provider, envVars: needed, projectPath: project_path, label, home });
161
+ const url = `${SITE()}/keys/go/${provider}?s=${s.id}`;
162
+ try {
163
+ await openUrl(url);
164
+ } catch {
165
+ return reply(`Could not open Chrome. Open ${url} yourself, then call keys_request again with session "${s.id}".`, true);
166
+ }
167
+ return waitFor(s.id);
168
+ },
169
+
170
+ async keys_check({ env_var } = {}) {
171
+ const blocked = await planBlock("keys_check");
172
+ if (blocked) return reply(blocked);
173
+ const entries = (await vault.list()).filter((e) => env_var === undefined || e.envVar === env_var);
174
+ if (entries.length === 0) return reply(env_var ? `${env_var}: missing` : "No keys saved yet.");
175
+ const groups = groupByProviderLabel(entries);
176
+ const lines = [];
177
+ for (const list of groups) {
178
+ const { provider, label } = list[0];
179
+ const mark = (e, s) => `${e.envVar} (${e.provider}, ${e.label}): ${s}`;
180
+ if (!(await guideAllowed(provider))) { lines.push(...list.map((e) => mark(e, "needs Footgun Pro"))); continue; }
181
+ const g = await server.guide(provider);
182
+ if (!g.ok) { lines.push(...list.map((e) => mark(e, "not checked (guide unavailable)"))); continue; }
183
+ const t = await testGuide(g.guide, provider, label);
184
+ const health = healthRecord(t, now);
185
+ for (const e of list) {
186
+ await vault.setHealth(e.provider, e.label, e.envVar, health);
187
+ lines.push(mark(e, t.ok ? "ok" : `failing: ${health.reason}`));
188
+ }
189
+ }
190
+ return reply(lines.join("\n"));
191
+ },
192
+ };
193
+
194
+ // Poll the session file once a second, up to waitMs.
195
+ async function waitFor(id) {
196
+ const deadline = Date.now() + waitMs;
197
+ for (;;) {
198
+ const s = await readSession(id, home);
199
+ if (!s) return reply("That guide session was not found. Call keys_request without a session to start again.", true);
200
+ if (isExpired(s, now())) return reply("That guide session expired.");
201
+ if (s.status === "done" && s.result) return reply(await savedText(s.result, s.projectPath));
202
+ if (s.status === "failed") return reply(`The guide could not finish: ${s.result?.error ?? "unknown error"}`, true);
203
+ const left = deadline - Date.now();
204
+ if (left <= 0) break;
205
+ await sleep(Math.min(1000, left));
206
+ }
207
+ return reply(`Still waiting for the guide in Chrome. Call keys_request again with session "${id}".`);
208
+ }
209
+
210
+ return Object.fromEntries(Object.entries(tools).map(([name, fn]) => [name, async (args) => {
211
+ try {
212
+ return await fn(args ?? {});
213
+ } catch {
214
+ return reply("Something went wrong in Footgun Keys.", true);
215
+ }
216
+ }]));
217
+ }
218
+
219
+ const names = z.array(z.string().regex(NAME_RE, "Use a plain env var name like STRIPE_SECRET_KEY."));
220
+
221
+ export const SPECS = {
222
+ keys_list: { description: "List the saved keys by env var name, provider, label and health. Never shows values.", inputSchema: z.object({}) },
223
+ keys_guides: { description: "List the available setup guides and whether each is Free or Pro.", inputSchema: z.object({}) },
224
+ keys_write_env: {
225
+ description: "Write already-saved keys into the project's env file (git-ignored). Names missing from the vault are reported, not written.",
226
+ inputSchema: z.object({ project_path: z.string().describe("Absolute path of the project folder."), env_vars: names.describe("Env var names to write.") }),
227
+ },
228
+ keys_request: {
229
+ description: "Get a provider's API key into a project. Writes it from the Keychain if saved, otherwise opens a guided setup in Chrome and waits. If it returns a session, call it again with that session.",
230
+ inputSchema: z.object({
231
+ provider: z.string().regex(PROVIDER_RE, BAD_PROVIDER).describe("Guide slug, such as stripe or openai."),
232
+ project_path: z.string().describe("Absolute path of the project folder."),
233
+ env_vars: names.optional().describe("Env var names needed. Defaults to all the guide captures."),
234
+ label: z.string().regex(LABEL_RE, BAD_LABEL).optional().describe("Name for this key set. Defaults to default."),
235
+ session: z.string().optional().describe("Session id from an earlier keys_request that was still waiting."),
236
+ }),
237
+ },
238
+ keys_check: {
239
+ description: "Test saved keys against their provider and record whether each still works.",
240
+ inputSchema: z.object({ env_var: z.string().optional().describe("Check just this env var. Defaults to all saved keys.") }),
241
+ },
242
+ };
243
+
244
+ // On other platforms the server still answers initialize and tools/list (so a plugin that starts it does not
245
+ // crash), but every tool call says Footgun Keys runs on macOS and Windows only.
246
+ export function toolsFor(platform, makeDeps) {
247
+ if (platform !== "darwin" && platform !== "win32") {
248
+ return Object.fromEntries(Object.keys(SPECS).map((name) => [name, async () => reply("Footgun Keys runs on macOS and Windows for now.")]));
249
+ }
250
+ return buildTools(makeDeps());
251
+ }
252
+
253
+ export async function runMcp() {
254
+ const tools = toolsFor(process.platform, () => {
255
+ const store = keychainStore();
256
+ return { vault: openVault({ store }), server: makeServer({ store }), openUrl };
257
+ });
258
+ const mcp = new McpServer(SERVER_INFO, { instructions: INSTRUCTIONS });
259
+ for (const [name, spec] of Object.entries(SPECS)) {
260
+ mcp.registerTool(name, { title: name, ...spec }, async (args) => tools[name](args));
261
+ }
262
+ const transport = new StdioServerTransport();
263
+ const closed = new Promise((resolve) => { transport.onclose = resolve; });
264
+ await mcp.connect(transport);
265
+ await closed;
266
+ }
267
+
268
+ export async function run() {
269
+ await runMcp();
270
+ return 0;
271
+ }
@@ -0,0 +1,7 @@
1
+ import { keychainStore } from "./vault.mjs";
2
+ import { runNativeHost } from "./native.mjs";
3
+
4
+ // Chrome passes the caller origin first (chrome-extension://<id>/), and on some platforms a window id after it.
5
+ export async function run(args) {
6
+ return runNativeHost(process.stdin, process.stdout, { store: keychainStore(), origin: args[0] });
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import type { KeyStore } from "./vault.mjs";
3
+
4
+ export interface HostDeps {
5
+ store: KeyStore;
6
+ home?: string;
7
+ fetch?: typeof fetch;
8
+ now?: () => Date;
9
+ origin?: string;
10
+ extensionIds?: string[];
11
+ }
12
+ export function readMessage(stream: Readable): Promise<unknown | null>;
13
+ export function writeMessage(stream: Writable, obj: unknown): Promise<void>;
14
+ export function handle(req: unknown, deps: HostDeps): Promise<Record<string, unknown>>;
15
+ export function runNativeHost(stdin: Readable, stdout: Writable, deps: HostDeps): Promise<number>;
package/lib/native.mjs ADDED
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { validateRequest, FREE_GUIDES } from "../shared/contract.mjs";
3
+ import { EXTENSION_IDS } from "../shared/extension-ids.mjs";
4
+ import { openVault, tooLongFor } from "./vault.mjs";
5
+ import { makeServer } from "./server.mjs";
6
+ import { writeEnv } from "./envfile.mjs";
7
+ import { runTest } from "./testcall.mjs";
8
+ import { readSession, finishSession, isExpired } from "./sessions.mjs";
9
+
10
+ // Nothing here writes a key value to stdout: responses carry env var names and statuses only.
11
+ const MAX_FRAME = 1048576;
12
+ const BAD = { __bad: true };
13
+ const HELPER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
+
15
+ async function readBytes(stream, n) {
16
+ for (;;) {
17
+ const c = stream.read(n);
18
+ if (c) return c;
19
+ if (stream.readableEnded || stream.destroyed) return null;
20
+ await new Promise((resolve) => {
21
+ const done = () => {
22
+ stream.off("readable", done); stream.off("end", done); stream.off("close", done); stream.off("error", done);
23
+ resolve();
24
+ };
25
+ stream.on("readable", done); stream.on("end", done); stream.on("close", done); stream.on("error", done);
26
+ });
27
+ }
28
+ }
29
+
30
+ export async function readMessage(stream) {
31
+ const head = await readBytes(stream, 4);
32
+ if (!head || head.length < 4) return null;
33
+ const len = head.readUInt32LE(0);
34
+ if (len === 0 || len > MAX_FRAME) return { ...BAD };
35
+ const body = await readBytes(stream, len);
36
+ if (!body || body.length < len) return { ...BAD };
37
+ try { return JSON.parse(body.toString("utf8")); } catch { return { ...BAD }; }
38
+ }
39
+
40
+ export function writeMessage(stream, obj) {
41
+ const body = Buffer.from(JSON.stringify(obj), "utf8");
42
+ const head = Buffer.alloc(4);
43
+ head.writeUInt32LE(body.length, 0);
44
+ return new Promise((resolve, reject) => stream.write(Buffer.concat([head, body]), (e) => (e ? reject(e) : resolve())));
45
+ }
46
+
47
+ const fail = (error) => ({ ok: false, error });
48
+
49
+ export async function handle(req, deps) {
50
+ const v = validateRequest(req);
51
+ if (!v.ok) return fail("bad message");
52
+ try {
53
+ return await dispatchRequest(v.req, deps);
54
+ } catch {
55
+ return fail("Something went wrong in the Footgun Keys helper.");
56
+ }
57
+ }
58
+
59
+ async function dispatchRequest(req, deps) {
60
+ const { store, home, fetch: fetchFn } = deps;
61
+ const now = deps.now ?? (() => new Date());
62
+ const server = makeServer({ store, home, fetch: fetchFn, now });
63
+
64
+ if (req.type === "hello") {
65
+ return { ok: true, helperVersion: HELPER_VERSION, signedIn: (await server.apiKey()) !== null, entitlement: await server.entitlement() };
66
+ }
67
+ if (req.type === "guide") return server.guide(req.slug);
68
+ if (req.type === "session") {
69
+ const s = await readSession(req.id, home);
70
+ if (!s) return fail("unknown session");
71
+ if (isExpired(s, now())) return fail("expired");
72
+ return { ok: true, provider: s.provider, envVars: s.envVars };
73
+ }
74
+
75
+ // save
76
+ if (!FREE_GUIDES.includes(req.guide)) {
77
+ const ent = await server.entitlement();
78
+ if (ent?.allGuides !== true) return fail("This guide is part of Footgun Pro.");
79
+ }
80
+ const g = await server.guide(req.guide);
81
+ if (!g.ok) return fail(g.error);
82
+ const guide = g.guide;
83
+ const secretOf = new Map(guide.captures.map((c) => [c.envVar, c.secret]));
84
+ if (req.values.some((x) => !secretOf.has(x.envVar))) return fail("unknown value");
85
+
86
+ let session = null;
87
+ if (req.session !== undefined) {
88
+ session = await readSession(req.session, home);
89
+ if (!session) return fail("unknown session");
90
+ if (isExpired(session, now())) return fail("expired");
91
+ if (session.provider !== req.guide) return fail("unknown session");
92
+ }
93
+
94
+ const label = session ? session.label : req.label;
95
+ const platform = deps.platform ?? process.platform;
96
+ // Check every value first, so a too-long one stores nothing (not even earlier values in this request).
97
+ for (const x of req.values) {
98
+ const tooLong = tooLongFor(x.value, platform);
99
+ if (tooLong) return fail(tooLong);
100
+ }
101
+ const vault = openVault({ store, home, platform });
102
+ for (const x of req.values) await vault.put(req.guide, label, x.envVar, x.value, secretOf.get(x.envVar));
103
+ if (!req.final) return { ok: true, saved: req.values.map((x) => x.envVar) };
104
+
105
+ // With a session, only the env vars it asked for are written and reported; without one, every capture.
106
+ const wanted = session ? session.envVars : guide.captures.map((c) => c.envVar);
107
+ if (wanted.some((n) => !secretOf.has(n))) return fail("unknown value");
108
+ const all = {};
109
+ for (const c of guide.captures) {
110
+ const val = await vault.get(req.guide, label, c.envVar);
111
+ if (val !== null) all[c.envVar] = val;
112
+ }
113
+ const chosen = Object.fromEntries(Object.entries(all).filter(([name]) => wanted.includes(name)));
114
+ // A final save reports every value gathered for this request, not just this message's.
115
+ const saved = Object.keys(chosen);
116
+ // The test call may need a capture the session did not ask for, so it sees every saved one.
117
+ const test = await runTest(guide, all, { fetch: fetchFn });
118
+ const result = { saved, test };
119
+ if (session?.projectPath) {
120
+ try {
121
+ result.wrote = await writeEnv(session.projectPath, Object.entries(chosen).map(([name, value]) => ({ name, value })));
122
+ } catch (e) {
123
+ const error = e instanceof Error ? e.message : "Could not write the env file.";
124
+ await finishSession(session.id, { saved, error }, home);
125
+ return fail(error);
126
+ }
127
+ for (const envVar of saved) await vault.addProject(req.guide, label, envVar, session.projectPath);
128
+ }
129
+ if (session) await finishSession(session.id, result, home);
130
+ return { ok: true, ...result };
131
+ }
132
+
133
+ export async function runNativeHost(stdin, stdout, deps) {
134
+ const ids = deps.extensionIds ?? EXTENSION_IDS;
135
+ const m = /^chrome-extension:\/\/([A-Za-z0-9]{1,64})\/?$/.exec(deps.origin ?? "");
136
+ if (!m || !ids.includes(m[1])) {
137
+ await writeMessage(stdout, fail("unknown extension"));
138
+ return 1;
139
+ }
140
+ for (;;) {
141
+ const msg = await readMessage(stdin);
142
+ if (msg === null) return 0;
143
+ if (msg.__bad) {
144
+ // The frame is unreadable, so the stream can no longer be trusted: answer once and stop.
145
+ await writeMessage(stdout, fail("bad message"));
146
+ return 1;
147
+ }
148
+ await writeMessage(stdout, await handle(msg, deps));
149
+ }
150
+ }
@@ -0,0 +1,4 @@
1
+ export function SITE(): string;
2
+ export function supportDir(home?: string, platform?: string, env?: NodeJS.ProcessEnv): string;
3
+ export function ensureSupportDir(home?: string, platform?: string, env?: NodeJS.ProcessEnv): Promise<string>;
4
+ export function writePrivate(file: string, data: string | Uint8Array): Promise<void>;
package/lib/paths.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { homedir } from "node:os";
2
+ import { join, win32 } from "node:path";
3
+ import { mkdir, chmod, writeFile, rename, rm } from "node:fs/promises";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ export function SITE() { return process.env.FOOTGUN_KEYS_SITE ?? "https://getfootgun.com"; }
7
+
8
+ export function supportDir(home = homedir(), platform = process.platform, env = process.env) {
9
+ if (platform === "win32") return win32.join(env.LOCALAPPDATA || win32.join(home, "AppData", "Local"), "Footgun Keys");
10
+ return join(home, "Library", "Application Support", "Footgun Keys");
11
+ }
12
+
13
+ // mkdir's mode is masked by the umask, so chmod each folder explicitly.
14
+ // Windows has no POSIX modes, so the chmod is skipped there.
15
+ export async function ensureSupportDir(home = homedir(), platform = process.platform, env = process.env) {
16
+ const dir = supportDir(home, platform, env);
17
+ const j = platform === "win32" ? win32.join : join;
18
+ for (const d of [dir, j(dir, "sessions"), j(dir, "guides")]) {
19
+ await mkdir(d, { recursive: true, mode: 0o700 });
20
+ if (platform !== "win32") await chmod(d, 0o700);
21
+ }
22
+ return dir;
23
+ }
24
+
25
+ // Atomic: write a sibling temp file with mode 0600, then rename over the target.
26
+ export async function writePrivate(file, data) {
27
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
28
+ try {
29
+ await writeFile(tmp, data, { mode: 0o600 });
30
+ await chmod(tmp, 0o600);
31
+ await rename(tmp, file);
32
+ } catch (e) {
33
+ await rm(tmp, { force: true });
34
+ throw e;
35
+ }
36
+ }
@@ -0,0 +1,5 @@
1
+ type Exec = (file: string, ...rest: any[]) => unknown;
2
+
3
+ export const WIN_NOTIFY_SCRIPT: string;
4
+ export function notify(title: string, body: string, opts?: { platform?: string; exec?: Exec }): Promise<void>;
5
+ export function openUrl(url: string, opts?: { platform?: string; exec?: Exec }): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Title and body travel as argv (Mac) or environment variables (Windows), never inside the script text.
4
+ const MAC_SCRIPT = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run";
5
+
6
+ // Joined with "; " because a .cmd launcher carries it on one line. It must stay free of newlines, double quotes and %.
7
+ // Not executed on Windows in tests (this runs on a Mac); the tests check how it is called, not that a toast appears.
8
+ // The app id is PowerShell's own: a toast from an unregistered app id shows nothing on Windows.
9
+ export const WIN_NOTIFY_SCRIPT = [
10
+ "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
11
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
12
+ "$text = $xml.GetElementsByTagName('text')",
13
+ "[void]$text.Item(0).AppendChild($xml.CreateTextNode($env:FOOTGUN_NOTIFY_TITLE))",
14
+ "[void]$text.Item(1).AppendChild($xml.CreateTextNode($env:FOOTGUN_NOTIFY_BODY))",
15
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
16
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)",
17
+ ].join("; ");
18
+
19
+ export function notify(title, body, { platform = process.platform, exec = execFile } = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const done = (e) => (e ? reject(e) : resolve());
22
+ if (platform === "win32") {
23
+ exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WIN_NOTIFY_SCRIPT],
24
+ { env: { ...process.env, FOOTGUN_NOTIFY_TITLE: title, FOOTGUN_NOTIFY_BODY: body } }, done);
25
+ } else {
26
+ exec("osascript", ["-e", MAC_SCRIPT, title, body], done);
27
+ }
28
+ });
29
+ }
30
+
31
+ export function openUrl(url, { platform = process.platform, exec = execFile } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ const done = (e) => (e ? reject(e) : resolve());
34
+ // rundll32 takes the url as one argument, so an & in it needs no shell quoting.
35
+ if (platform === "win32") exec("rundll32", ["url.dll,FileProtocolHandler", url], done);
36
+ else exec("open", [url], done);
37
+ });
38
+ }
@@ -0,0 +1,6 @@
1
+ export const DENY_RULES: string[];
2
+ export type ProtectResult = { changed: boolean; file: string; added: string[] };
3
+ export function protect(projectPath: string, confirm: () => Promise<boolean>): Promise<ProtectResult>;
4
+ export function protectSummary(r: ProtectResult): string;
5
+ export function run(args: string[]): Promise<number>;
6
+ export function isProtected(projectPath: string): Promise<boolean>;
@@ -0,0 +1,63 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+
5
+ // Syntax per https://code.claude.com/docs/en/settings (permissions.deny, "Read(./.env)").
6
+ export const DENY_RULES = ["Read(./.env)", "Read(./.env.local)", "Read(./.env.*)"];
7
+
8
+ // True when the project's Claude Code settings already deny reading every env file pattern.
9
+ export async function isProtected(projectPath) {
10
+ try {
11
+ const deny = JSON.parse(await readFile(join(projectPath, ".claude", "settings.json"), "utf8"))?.permissions?.deny;
12
+ return Array.isArray(deny) && DENY_RULES.every((r) => deny.includes(r));
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export async function protect(projectPath, confirm) {
19
+ const file = join(projectPath, ".claude", "settings.json");
20
+ let settings = {};
21
+ try {
22
+ const text = await readFile(file, "utf8");
23
+ try { settings = JSON.parse(text); } catch { throw new Error(`${file} is not valid JSON; fix it first. Nothing was changed.`); }
24
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error(`${file} is not a JSON object. Nothing was changed.`);
25
+ } catch (e) {
26
+ if (e.code !== "ENOENT") throw e;
27
+ }
28
+ const deny = Array.isArray(settings.permissions?.deny) ? settings.permissions.deny : [];
29
+ const missing = DENY_RULES.filter((r) => !deny.includes(r));
30
+ if (missing.length === 0) return { changed: false, file, added: [] };
31
+ if (!(await confirm())) return { changed: false, file, added: [] };
32
+ settings.permissions = { ...settings.permissions, deny: [...deny, ...missing] };
33
+ await mkdir(join(projectPath, ".claude"), { recursive: true });
34
+ await writeFile(file, JSON.stringify(settings, null, 2) + "\n");
35
+ return { changed: true, file, added: missing };
36
+ }
37
+
38
+ // The deny rules only stop Claude Code's Read tool, so the message says that and no more.
39
+ export function protectSummary(r) {
40
+ if (!r.changed) return `No changes made to ${r.file}.`;
41
+ return [
42
+ "Added read-deny rules for env files to .claude/settings.json. Claude Code's Read tool will not open them; shell commands can still read them.",
43
+ ...r.added.map((rule) => ` ${rule}`),
44
+ ].join("\n");
45
+ }
46
+
47
+ export async function run(args) {
48
+ const project = resolve(args[0] ?? process.cwd());
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ const r = await protect(project, async () => {
52
+ const a = (await rl.question("Stop Claude Code's Read tool from opening env files in this project? (y/n) ")).trim().toLowerCase();
53
+ return a === "y" || a === "yes";
54
+ });
55
+ console.log(protectSummary(r));
56
+ return 0;
57
+ } catch (e) {
58
+ console.error(e.message);
59
+ return 1;
60
+ } finally {
61
+ rl.close();
62
+ }
63
+ }
@@ -0,0 +1,11 @@
1
+ type Exec = (file: string, args: string[]) => Promise<unknown>;
2
+
3
+ export function registerNativeHost(opts: {
4
+ platform: string;
5
+ exec: Exec;
6
+ home: string;
7
+ support: string;
8
+ manifest: { name: string; [key: string]: unknown };
9
+ }): Promise<void>;
10
+ export function healthTaskXml(launcher: string, now?: Date): string;
11
+ export function scheduleDaily(opts: { platform: string; exec: Exec; home: string; launcher: string; uid?: number; support?: string; now?: () => Date }): Promise<void>;