@lllanyard/cli 0.0.0-stage → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,829 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { Writable } from 'node:stream';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AgentStore, APP_NAME, createMcpHandler, serveMcpStdio, allProviders, FileBackend, LicenseStore, refreshDue, refreshLicense, MIN_PASSPHRASE, backendKind, defaultBackend, setVaultPassphrase, vaultPassphrase, createUsageLog, parseSince, readUsage, renderDashboard, summarize, usageLogPath, BUILTIN_PROVIDERS, DEFAULT_PORT, KeyringBackend, KeyringStore, ProviderStore, ToolStore, defaultRoots, parseKeyRef, redactFile, rotationStatus, runTool, sandboxSelfTest, scan, createBackup, describeItem, readBackup, restoreBackup, pinScript, findCommand, pinFiles, bindingsForKeys, bindingsForAllKeys, scriptOf, neededEnv, resolveScriptDirs, planScriptRun, describePlan, confirmDialog, stableNodePath, withClaudeDesktopClosed, claudeDesktopRunning, canRestartClaudeDesktop, claudeDesktopStatus, connectClaudeDesktop, disconnectClaudeDesktop, claudeCodeStatus, connectClaudeCode, disconnectClaudeCode, } from '@lllanyard/core';
8
+ import { startEgressProxy, startProxy } from '@lllanyard/proxy';
9
+ import { openBrowser, startUi } from '@lllanyard/ui';
10
+ const BOOL = new Set(['run', 'remove', 'restart', 'clear', 'stdin', 'allow-argv', 'import', 'redact', 'apply', 'json', 'help', 'allow-local', 'no-scrub', 'no-resolve', 'kill-on-leak', 'inherit-env', 'sandbox', 'no-open', 'print-url', 'overwrite', 'dry-run', 'allow-args']);
11
+ const MULTI = new Set(['scripts-dir', 'pin', 'env', 'allow-host', 'pass-env', 'share-ro', 'share-rw']);
12
+ function parse(argv) {
13
+ const pos = [];
14
+ const flags = {};
15
+ const multi = {};
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (!a.startsWith('--')) {
19
+ pos.push(a);
20
+ continue;
21
+ }
22
+ const [k, v] = a.slice(2).split(/=(.*)/s);
23
+ if (BOOL.has(k)) {
24
+ flags[k] = true;
25
+ continue;
26
+ }
27
+ const val = v ?? argv[++i] ?? '';
28
+ if (MULTI.has(k))
29
+ (multi[k] ??= []).push(val);
30
+ else
31
+ flags[k] = val;
32
+ }
33
+ return { pos, flags, multi };
34
+ }
35
+ const out = (s = '') => process.stdout.write(s + '\n');
36
+ const fail = (s) => { throw new Error(s); };
37
+ const str = (v) => (typeof v === 'string' ? v : undefined);
38
+ const HELP = `${APP_NAME} - keep API keys out of AI agents
39
+
40
+ Keys
41
+ add <provider> [--label NAME] [--rotate-days N] [--env-name VAR] store a key (hidden prompt; or pipe it with --stdin)
42
+ env <provider>[:label] VAR | --clear the variable name tools get this key as (e.g. TRMM_API_KEY)
43
+ list show stored keys and their variable names (never shows secrets)
44
+ remove <provider> [--label NAME]
45
+ rotate <provider> [--label NAME] replace a key and reset its rotation clock
46
+ status rotation status for every key (exit 1 if any overdue)
47
+
48
+ Run scripts with your keys
49
+ exec <command> [args...] run a script with the keys it uses, e.g.
50
+ lllanyard exec pwsh -File Onboard-Client.ps1 -DryRun
51
+ It reads the script (and helper files it names) for your keys' variable
52
+ names and passes only those. Keys are never printed: any in the output
53
+ are redacted. --keys trmm,itflow -- <command> names them yourself.
54
+
55
+ Agents
56
+ agent create <name> [--client NAME] [--providers a,b] [--expires 30d] placeholder token; --providers limits what it can reach
57
+ agent list | agent revoke <name>
58
+
59
+ Tools (GitHub, Slack, Stripe, your own APIs...)
60
+ provider add <id> --upstream https://api.x.com [--auth bearer|header] [--header NAME] [--prefix TXT] [--allow-local]
61
+ provider list | provider remove <id> custom API hosts the proxy may call with a stored key
62
+ tool add <name> --keys trmm,itflow [safeguards] -- <command> [args...]
63
+ approve a command (e.g. a script or MCP server) to receive keys;
64
+ --keys uses each key's variable name, --env VAR=provider[:label] picks one
65
+ safeguards: --allow-host api.x.com (repeat; limits where the tool can connect, best-effort)
66
+ --kill-on-leak --pass-env VAR (repeat) --inherit-env --no-scrub
67
+ sandbox: --sandbox (OS-enforced: no network except --allow-host, empty home, only the current folder writable;
68
+ refuses to run if no sandbox is available) --share-ro DIR / --share-rw DIR (repeat)
69
+ scripts: for python/node/sh/pwsh... the script file is pinned: run refuses if it changes (approve again to update)
70
+ --allow-args lets "run <name> -- args" add arguments after the stored ones
71
+ --pin FILE (repeat) also pins files the script loads, e.g. a shared helper
72
+ tool list | tool remove <name>
73
+ run <name> [-- args...] run an approved tool with its keys injected (never printed)
74
+ run --env VAR=provider -- <command> [args...] one-off run; asks for confirmation in a terminal
75
+
76
+ Connect AI apps
77
+ setup show which apps are connected
78
+ setup claude-desktop [--scripts-dir DIR] [--remove] [--restart]
79
+ let Claude Desktop run your approved tools, and with --scripts-dir (repeat)
80
+ any script in DIR after you click Run in a dialog. Claude Desktop must be
81
+ closed (it rewrites its settings on quit); --restart quits and reopens it (macOS)
82
+ setup claude-code [--remove] tell Claude Code (~/.claude/CLAUDE.md) to use lllanyard exec, not .env files
83
+
84
+ Hygiene
85
+ scan [paths...] [--import --apply [--redact]] find plaintext keys; move them into the keychain
86
+
87
+ Backup
88
+ backup [--out FILE] [--passphrase-file F] encrypted backup of keys, providers, tools, agents and license
89
+ restore <file> [--overwrite] [--dry-run] [--passphrase-file F]
90
+ merge a backup in; existing items are kept unless --overwrite
91
+
92
+ Run
93
+ start [--port N] start the local proxy (127.0.0.1 only)
94
+ usage [--since 7d] [--json] request counts, errors, latency by provider/agent/client
95
+ dashboard [--since 30d] [--out file] visual HTML dashboard (Pro)
96
+ license activate|status|refresh|remove unlock Pro/Team with a signed license key; refresh fetches
97
+ the renewed key (done for you at start-up in the last week)
98
+ ui [--no-open] desktop-style window in your browser: keys, agents, usage, rotation assistant
99
+ mcp [--client NAME] [--run [--timeout SECS]] MCP server for Claude Desktop etc.: key status, never shows keys.
100
+ --run also lets it run your approved tools (lllanyard tool add) by name,
101
+ with keys injected and output redacted (default time limit 600s)
102
+ --scripts-dir DIR (repeat) also lets it run any script in DIR,
103
+ after you click Run in a dialog that shows the script and its keys
104
+ serve --host H --port P --tls-cert F --tls-key F --allow-host NAME share the proxy with your team (Pro; keys stay in this keychain)
105
+ doctor check OS keychain and sandbox access
106
+ `;
107
+ async function readStdin() {
108
+ let s = '';
109
+ process.stdin.setEncoding('utf8');
110
+ for await (const c of process.stdin)
111
+ s += c;
112
+ return s.trim();
113
+ }
114
+ async function readSecret(prompt, forceStdin = false) {
115
+ if (forceStdin || !process.stdin.isTTY)
116
+ return readStdin();
117
+ process.stderr.write(prompt);
118
+ return new Promise((resolve, reject) => {
119
+ const stdin = process.stdin;
120
+ let buf = '';
121
+ const done = () => { stdin.setRawMode(false); stdin.pause(); stdin.off('data', onData); process.stderr.write('\n'); };
122
+ const onData = (chunk) => {
123
+ for (const c of chunk) {
124
+ if (c === '\r' || c === '\n') {
125
+ done();
126
+ return resolve(buf.trim());
127
+ }
128
+ if (c === '\u0003') {
129
+ done();
130
+ return reject(new Error('Cancelled'));
131
+ }
132
+ if (c === '\u007f' || c === '\b') {
133
+ buf = buf.slice(0, -1);
134
+ continue;
135
+ }
136
+ buf += c;
137
+ }
138
+ };
139
+ stdin.setRawMode(true);
140
+ stdin.setEncoding('utf8');
141
+ stdin.resume();
142
+ stdin.on('data', onData);
143
+ });
144
+ }
145
+ const PREFIX = { openai: 'sk-', anthropic: 'sk-ant-' };
146
+ const day = (iso) => iso.slice(0, 10);
147
+ function envSnippet(token, providers) {
148
+ const port = process.env.LLLANYARD_PORT ?? DEFAULT_PORT;
149
+ const base = `http://127.0.0.1:${port}`;
150
+ const want = (p) => !providers || providers.includes(p);
151
+ const rows = [];
152
+ if (want('anthropic'))
153
+ rows.push(['ANTHROPIC_BASE_URL', `${base}/anthropic`], ['ANTHROPIC_API_KEY', token]);
154
+ if (want('openai'))
155
+ rows.push(['OPENAI_BASE_URL', `${base}/openai/v1`], ['OPENAI_API_KEY', token]);
156
+ const lines = process.platform === 'win32'
157
+ ? rows.map(([k, v]) => ` $env:${k}="${v}"`)
158
+ : rows.map(([k, v]) => ` export ${k}=${v}`);
159
+ for (const p of providers ?? []) {
160
+ if (p !== 'anthropic' && p !== 'openai')
161
+ lines.push(` # ${p}: API base URL ${base}/${p} (use the token above wherever the tool wants that API's key)`);
162
+ }
163
+ return lines.join('\n');
164
+ }
165
+ async function maybeEgress(name, hosts, sandbox = false) {
166
+ if (!hosts?.length)
167
+ return undefined;
168
+ const debug = Boolean(process.env.LLLANYARD_DEBUG);
169
+ const usage = createUsageLog();
170
+ const log = (r) => { if (r.decision === 'deny')
171
+ usage.write(r); if (r.decision === 'deny' || debug)
172
+ process.stderr.write(`lllanyard: ${JSON.stringify(r)}\n`); };
173
+ if (sandbox && process.platform === 'linux') {
174
+ // The sandbox has no network; its only way out is this unix socket, in a private directory.
175
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'lly-'));
176
+ const p = await startEgressProxy({ allow: hosts, label: name, log, unixSocket: path.join(dir, 'egress.sock') });
177
+ return { ...p, close: () => { p.close(); rmSync(dir, { recursive: true, force: true }); } };
178
+ }
179
+ return startEgressProxy({ allow: hosts, label: name, log });
180
+ }
181
+ function describeSafeguards(t) {
182
+ const pinned = (t.scriptSha256 ? 1 : 0) + Object.keys(t.pins ?? {}).length;
183
+ return [
184
+ ...(pinned ? [`${pinned} file(s) pinned`] : []),
185
+ ...(t.allowArgs ? ['extra args allowed'] : []),
186
+ t.sandbox ? 'OS sandbox' : 'no OS sandbox',
187
+ t.scrub ? 'output scrubbed' : 'output NOT scrubbed',
188
+ t.allowHosts?.length ? `network limited to ${t.allowHosts.join(', ')}` : 'network unrestricted',
189
+ t.inheritEnv ? 'full environment inherited' : `minimal environment${t.passEnv?.length ? ` + ${t.passEnv.join(', ')}` : ''}`,
190
+ t.killOnLeak ? 'kill-on-leak' : 'warn-on-leak',
191
+ ].join('; ');
192
+ }
193
+ function parseBindings(items) {
194
+ const env = {};
195
+ for (const it of items ?? []) {
196
+ const i = it.indexOf('=');
197
+ if (i < 1)
198
+ fail(`Bad --env "${it}" (use VAR=provider or VAR=provider:label)`);
199
+ const name = it.slice(0, i), ref = it.slice(i + 1);
200
+ parseKeyRef(ref);
201
+ env[name] = ref;
202
+ }
203
+ return env;
204
+ }
205
+ async function confirm(question) {
206
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
207
+ return false;
208
+ const rl = (await import('node:readline')).createInterface({ input: process.stdin, output: process.stderr });
209
+ return new Promise((r) => rl.question(question, (a) => { rl.close(); r(/^y(es)?$/i.test(a.trim())); }));
210
+ }
211
+ process.stdout.on('error', (e) => { if (e.code !== 'EPIPE')
212
+ throw e; process.exit(0); }); // e.g. `lllanyard usage | head`
213
+ async function main() {
214
+ const [cmd, ...all] = process.argv.slice(2);
215
+ const dd = all.indexOf('--');
216
+ const rest = dd >= 0 ? all.slice(0, dd) : all;
217
+ const tail = dd >= 0 ? all.slice(dd + 1) : [];
218
+ const { pos, flags, multi } = parse(rest);
219
+ if (!cmd || cmd === 'help' || (flags.help && cmd !== 'exec'))
220
+ return out(HELP);
221
+ // File vault: get the passphrase (from a file/env if configured, else a hidden prompt on a terminal).
222
+ if (backendKind() === 'file' && !vaultPassphrase() && cmd !== 'mcp') {
223
+ if (!process.stdin.isTTY || flags.stdin)
224
+ fail('LLLANYARD_BACKEND=file needs a passphrase: set LLLANYARD_VAULT_PASSPHRASE_FILE to a chmod 600 file (a terminal prompt is not possible here).');
225
+ const fresh = !new FileBackend().exists();
226
+ const p1 = await readSecret(fresh ? `Choose a vault passphrase (min ${MIN_PASSPHRASE} characters, input hidden): ` : 'Vault passphrase (input hidden): ');
227
+ if (fresh) {
228
+ if (p1.length < MIN_PASSPHRASE)
229
+ fail(`Passphrase must be at least ${MIN_PASSPHRASE} characters.`);
230
+ if ((await readSecret('Repeat passphrase: ')) !== p1)
231
+ fail('Passphrases did not match.');
232
+ }
233
+ setVaultPassphrase(p1);
234
+ }
235
+ const store = () => new KeyringStore(undefined, { maxKeys: new LicenseStore().limits().maxKeys });
236
+ const label = str(flags.label);
237
+ switch (cmd) {
238
+ case 'add': {
239
+ const provider = pos[0] ?? fail('Usage: lllanyard add <provider> [--label NAME] [--rotate-days N]');
240
+ let secret;
241
+ if (pos[1]) {
242
+ if (!flags['allow-argv'])
243
+ fail('Refusing a key on the command line: it would end up in shell history and process lists.\n' +
244
+ 'Run without the key for a hidden prompt, or pipe it with --stdin. (Override: --allow-argv)');
245
+ secret = pos[1];
246
+ }
247
+ else
248
+ secret = await readSecret(`${provider} API key (input hidden): `, Boolean(flags.stdin));
249
+ const want = PREFIX[provider.toLowerCase()];
250
+ if (want && !secret.startsWith(want))
251
+ process.stderr.write(`Warning: ${provider} keys normally start with "${want}". Stored anyway.\n`);
252
+ const days = str(flags['rotate-days']);
253
+ const m = store().set(provider, secret, { label, rotateAfterDays: days ? Number(days) : undefined, envName: str(flags['env-name']) });
254
+ return out(`Stored ${m.id} (…${m.last4})${m.envName ? ` as ${m.envName}` : ''}. Rotate by ${day(new Date(Date.parse(m.createdAt) + m.rotateAfterDays * 86_400_000).toISOString())}.`);
255
+ }
256
+ case 'list': {
257
+ const keys = store().list();
258
+ if (!keys.length)
259
+ return out('No keys stored. Add one with: lllanyard add openai');
260
+ for (const k of keys)
261
+ out(`${k.id.padEnd(28)} …${k.last4} ${(k.envName ?? '-').padEnd(26)} added ${day(k.createdAt)}`);
262
+ return;
263
+ }
264
+ case 'env': {
265
+ const ref = pos[0] ?? fail('Usage: lllanyard env <provider>[:label] VARIABLE_NAME (or --clear)');
266
+ const { provider: p, label: l } = parseKeyRef(ref);
267
+ const name = flags.clear ? undefined : pos[1] ?? fail('Give the variable name, e.g. lllanyard env trmm TRMM_API_KEY (or --clear)');
268
+ const m = store().setEnvName(p, l, name);
269
+ return out(m.envName ? `${m.id} is given to tools as ${m.envName}.` : `${m.id} has no variable name now.`);
270
+ }
271
+ case 'remove': {
272
+ const p = pos[0] ?? fail('Usage: lllanyard remove <provider> [--label NAME]');
273
+ return out(store().remove(p, label) ? `Removed ${p}:${label ?? 'default'}` : 'Nothing to remove.');
274
+ }
275
+ case 'rotate': {
276
+ const p = pos[0] ?? fail('Usage: lllanyard rotate <provider> [--label NAME]');
277
+ const s = store();
278
+ if (!s.get(p, label))
279
+ fail(`No ${p}:${label ?? 'default'} key to rotate. Use "add" first.`);
280
+ const secret = await readSecret(`NEW ${p} API key (input hidden): `, Boolean(flags.stdin));
281
+ const m = s.set(p, secret, { label });
282
+ out(`Rotated ${m.id} (…${m.last4}). Now revoke the OLD key at the provider so it stops working.`);
283
+ const st = rotationStatus(m);
284
+ return st.rotateUrl && out(` ${st.rotateUrl}`);
285
+ }
286
+ case 'status': {
287
+ const keys = store().list();
288
+ if (!keys.length)
289
+ return out('No keys stored.');
290
+ let overdue = false;
291
+ for (const k of keys) {
292
+ const s = rotationStatus(k);
293
+ if (s.state === 'overdue')
294
+ overdue = true;
295
+ const when = s.dueInDays < 0 ? `${-s.dueInDays}d overdue` : `due in ${s.dueInDays}d`;
296
+ out(`${s.state === 'ok' ? 'OK ' : s.state === 'due-soon' ? 'SOON ' : 'OVERDUE'} ${s.id.padEnd(26)} age ${s.ageDays}d, ${when}${s.state !== 'ok' && s.rotateUrl ? ` ${s.rotateUrl}` : ''}`);
297
+ }
298
+ if (overdue)
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+ case 'agent': {
303
+ const [sub, name] = pos;
304
+ const agents = new AgentStore();
305
+ if (sub === 'create') {
306
+ if (!name)
307
+ fail('Usage: lllanyard agent create <name> [--client NAME]');
308
+ const client = str(flags.client) ?? 'default';
309
+ const provs = str(flags.providers)?.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);
310
+ const lic = new LicenseStore();
311
+ const limit = lic.maxAgents();
312
+ if (!agents.list().some((a) => a.name === name.toLowerCase()) && agents.list().length >= limit)
313
+ fail(`${lic.status().tier === 'free' ? 'Free tier allows' : 'Your license allows'} ${limit} agents. Revoke one${lic.status().tier === 'free' ? ' or upgrade (lllanyard license activate)' : ' or add seats'}.`);
314
+ const exp = str(flags.expires);
315
+ const token = agents.create(name, client, provs, exp ? { expiresAt: new Date(2 * Date.now() - parseSince(exp)) } : {});
316
+ out(`Agent "${name}" created (client: ${client}${provs ? `, providers: ${provs.join(', ')}` : ''}${exp ? `, expires in ${exp}` : ''}). This token is shown ONCE and cannot be recovered:\n\n ${token}\n`);
317
+ out(`Point the tool at the proxy:\n${envSnippet(token, provs)}\n`);
318
+ return out('The token is worthless outside the proxy. The real key never leaves your keychain.');
319
+ }
320
+ if (sub === 'list') {
321
+ const l = agents.list();
322
+ return l.length ? l.forEach((a) => out(`${a.name.padEnd(24)} client=${a.client} providers=${a.providers?.join(',') ?? 'all'} created ${day(a.createdAt)}${a.expiresAt ? ` expires ${day(a.expiresAt)}` : ''}`)) : out('No agents.');
323
+ }
324
+ if (sub === 'revoke') {
325
+ if (!name)
326
+ fail('Usage: lllanyard agent revoke <name>');
327
+ return out(agents.revoke(name) ? `Revoked ${name} (takes effect within ~10s on a running proxy).` : 'No such agent.');
328
+ }
329
+ return fail('Usage: lllanyard agent create|list|revoke');
330
+ }
331
+ case 'scan': {
332
+ const findings = await scan({ roots: pos.length ? pos : defaultRoots() });
333
+ if (flags.json)
334
+ return out(JSON.stringify(findings, null, 2)); // secrets are never serialised
335
+ if (!findings.length)
336
+ return out('No plaintext keys found.');
337
+ for (const f of findings)
338
+ out(`${f.provider.padEnd(10)} ${f.masked.padEnd(16)} ${f.file}:${f.line}${f.importable ? '' : ' (detected only)'}`);
339
+ if (!flags.import)
340
+ return out(`\n${findings.length} found. Run again with --import to move supported keys into the keychain (dry run unless --apply).`);
341
+ return importFindings(findings, store(), Boolean(flags.apply), Boolean(flags.redact));
342
+ }
343
+ case 'provider': {
344
+ const [sub, id] = pos;
345
+ const ps = new ProviderStore();
346
+ if (sub === 'add') {
347
+ if (!id)
348
+ fail('Usage: lllanyard provider add <id> --upstream https://api.example.com [--auth bearer|header] [--header NAME] [--prefix TXT] [--allow-local]');
349
+ const kind = str(flags.auth) ?? 'bearer';
350
+ let auth;
351
+ if (kind === 'bearer')
352
+ auth = { type: 'bearer' };
353
+ else if (kind === 'header') {
354
+ const name = str(flags.header) ?? fail('--auth header needs --header NAME (e.g. X-Api-Key)');
355
+ auth = { type: 'header', name, ...(str(flags.prefix) !== undefined ? { prefix: str(flags.prefix) } : {}) };
356
+ }
357
+ else
358
+ return fail('--auth must be bearer or header');
359
+ const v = ps.set({ id, upstream: str(flags.upstream) ?? fail('--upstream is required'), auth, allowLocal: Boolean(flags['allow-local']) });
360
+ out(`Provider "${v.id}" saved: ${v.upstream} (${v.auth.type === 'bearer' ? 'Authorization: Bearer' : `${v.auth.name} header`}).`);
361
+ return out(`Store its key: lllanyard add ${v.id}\nThen call it via http://127.0.0.1:${process.env.LLLANYARD_PORT ?? DEFAULT_PORT}/${v.id}/...`);
362
+ }
363
+ if (sub === 'list') {
364
+ for (const p of BUILTIN_PROVIDERS)
365
+ out(`${p.id.padEnd(14)} ${p.upstream} (built-in)`);
366
+ for (const p of ps.list())
367
+ out(`${p.id.padEnd(14)} ${p.upstream}${p.allowLocal ? ' (local)' : ''}`);
368
+ return;
369
+ }
370
+ if (sub === 'remove')
371
+ return out(id && ps.remove(id) ? `Removed provider ${id}` : 'No such custom provider.');
372
+ return fail('Usage: lllanyard provider add|list|remove');
373
+ }
374
+ case 'tool': {
375
+ const [sub, name] = pos;
376
+ const ts = new ToolStore();
377
+ if (sub === 'add') {
378
+ if (!name)
379
+ fail('Usage: lllanyard tool add <name> --env VAR=provider[:label] -- <command> [args...]');
380
+ const [command, ...rawArgs] = tail;
381
+ if (!command)
382
+ fail('Put the command after "--", e.g. tool add github-mcp --env GITHUB_PERSONAL_ACCESS_TOKEN=github -- npx -y @modelcontextprotocol/server-github');
383
+ const exe = flags['no-resolve'] ? command : findCommand(command);
384
+ const { args, scriptSha256 } = pinScript(exe, rawArgs);
385
+ const fromKeys = bindingsForKeys(str(flags.keys)?.split(',').map((x) => x.trim()).filter(Boolean) ?? [], store());
386
+ const t = ts.set({
387
+ name, command: exe, args, scriptSha256, pins: multi.pin?.length ? pinFiles(multi.pin) : undefined, allowArgs: Boolean(flags['allow-args']),
388
+ env: { ...fromKeys, ...parseBindings(multi.env) }, scrub: !flags['no-scrub'],
389
+ allowHosts: multi['allow-host'], passEnv: multi['pass-env'], killOnLeak: Boolean(flags['kill-on-leak']), inheritEnv: Boolean(flags['inherit-env']),
390
+ sandbox: Boolean(flags.sandbox), shareRo: multi['share-ro'], shareRw: multi['share-rw'],
391
+ });
392
+ out(`Tool "${t.name}" approved: ${[t.command, ...t.args].join(' ')}`);
393
+ out(`Keys: ${Object.entries(t.env).map(([k, v]) => `${k}<-${v}`).join(', ')}`);
394
+ out(`Safeguards: ${describeSafeguards(t)}`);
395
+ if (t.sandbox && !t.allowHosts?.length)
396
+ out('Sandboxed with NO network (no --allow-host). Add --allow-host if the tool must reach an API.');
397
+ else if (!t.allowHosts?.length)
398
+ out('Tip: add --allow-host api.example.com so the tool can only reach the hosts it needs (npx tools also need registry.npmjs.org).');
399
+ out(`\nMCP client config (no secrets in the file):\n { "command": "lllanyard", "args": ["run", "${t.name}"] }`);
400
+ return out('If your app can\'t find "lllanyard", use its absolute path (run: which lllanyard / where lllanyard).');
401
+ }
402
+ if (sub === 'list') {
403
+ const l = ts.list();
404
+ return l.length ? l.forEach((t) => out(`${t.name.padEnd(18)} ${[t.command, ...t.args].join(' ')} keys: ${Object.entries(t.env).map(([k, v]) => `${k}<-${v}`).join(', ')}\n${' '.repeat(19)}${describeSafeguards(t)}`)) : out('No tools approved yet.');
405
+ }
406
+ if (sub === 'remove')
407
+ return out(name && ts.remove(name) ? `Removed tool ${name}` : 'No such tool.');
408
+ return fail('Usage: lllanyard tool add|list|remove');
409
+ }
410
+ case 'exec': {
411
+ // Everything after "exec" (and an optional "--keys a,b") is the command, passed through untouched.
412
+ let argv = process.argv.slice(3);
413
+ let only;
414
+ if (argv[0] === '--keys') {
415
+ only = (argv[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean);
416
+ argv = argv.slice(2);
417
+ }
418
+ if (argv[0] === '--')
419
+ argv = argv.slice(1);
420
+ const [command, ...args] = argv;
421
+ if (!command)
422
+ fail('Usage: lllanyard exec <command> [args...] e.g. lllanyard exec pwsh -File ./Onboard-Client.ps1 -DryRun\n lllanyard exec --keys trmm,itflow -- <command> [args...] to name the keys yourself');
423
+ const keys = store();
424
+ // The script being run: the file after an interpreter (python3 x.py, pwsh -File x.ps1), or the command itself.
425
+ let script = null;
426
+ try {
427
+ script = scriptOf({ command, args: args.map((a) => (a.startsWith('-') ? a : path.resolve(a))) });
428
+ }
429
+ catch { /* not a plain script run */ }
430
+ // A command given as a path counts only if it's a text script (./sync.sh), not a program (/usr/bin/env).
431
+ if (!script && /[\\/]/.test(command)) {
432
+ try {
433
+ if (statSync(command).isFile() && !readFileSync(command).subarray(0, 4096).includes(0))
434
+ script = path.resolve(command);
435
+ }
436
+ catch { /* not a file */ }
437
+ }
438
+ let env;
439
+ if (only)
440
+ env = bindingsForKeys(only, keys);
441
+ else {
442
+ if (!script)
443
+ fail(`Can't tell which keys "${command}" needs (it isn't a script file). Name them: lllanyard exec --keys trmm,itflow -- ${command} ...`);
444
+ const all = bindingsForAllKeys(keys);
445
+ const { needed, files } = neededEnv(script, Object.keys(all));
446
+ env = Object.fromEntries(needed.map((n) => [n, all[n]]));
447
+ const shown = files.map((f) => path.basename(f));
448
+ process.stderr.write(`lllanyard: giving ${needed.length ? needed.join(', ') : 'no keys'} (checked ${shown.slice(0, 4).join(', ')}${shown.length > 4 ? ` +${shown.length - 4} more` : ''})\n`);
449
+ }
450
+ const what = path.basename(command) + (script && path.resolve(command) !== script ? ` ${path.basename(script)}` : '');
451
+ createUsageLog().write({ ts: new Date().toISOString(), event: 'exec', tool: what, cwd: process.cwd(), keys: Object.keys(env) });
452
+ process.exitCode = await runTool({ command, args, env, scrub: true, inheritEnv: true }, { store: keys });
453
+ return;
454
+ }
455
+ case 'run': {
456
+ const keys = store();
457
+ const name = pos[0];
458
+ if (name) {
459
+ const t = new ToolStore().get(name) ?? fail(`No approved tool "${name}". See: lllanyard tool list`);
460
+ if (tail.length && !t.allowArgs)
461
+ fail(`Tool "${t.name}" does not accept extra arguments. To allow them, approve it again with --allow-args.`);
462
+ if (tail.length > 100 || tail.some((a) => a.length > 1000 || a.includes('\0')))
463
+ fail('Too many or too long extra arguments.');
464
+ process.exitCode = await runTool({ ...t, args: [...t.args, ...tail] }, { store: keys, egress: await maybeEgress(t.name, t.allowHosts, t.sandbox) });
465
+ return;
466
+ }
467
+ // One-off mode: injecting keys into an arbitrary command is exactly what a hijacked agent would ask for,
468
+ // so it needs a human at a terminal and is refused when run non-interactively.
469
+ const [command, ...args] = tail;
470
+ const env = parseBindings(multi.env);
471
+ if (!command || !Object.keys(env).length)
472
+ fail('Usage: lllanyard run <approved-tool> or lllanyard run --env VAR=provider -- <command> [args...]');
473
+ const ok = await confirm(`Give ${Object.values(env).join(', ')} to \`${[command, ...args].join(' ')}\`? [y/N] `);
474
+ if (!ok)
475
+ fail('Not confirmed (one-off runs need an interactive terminal). To allow this command permanently: lllanyard tool add');
476
+ const hosts = multi['allow-host'];
477
+ process.exitCode = await runTool({ command, args, env, scrub: !flags['no-scrub'], killOnLeak: Boolean(flags['kill-on-leak']), inheritEnv: Boolean(flags['inherit-env']), passEnv: multi['pass-env'], allowHosts: hosts, sandbox: Boolean(flags.sandbox), shareRo: multi['share-ro'], shareRw: multi['share-rw'] }, { store: keys, egress: await maybeEgress(command, hosts, Boolean(flags.sandbox)) });
478
+ return;
479
+ }
480
+ case 'start': {
481
+ autoRefreshLicense();
482
+ const port = str(flags.port) ? Number(flags.port) : undefined;
483
+ const s = await startProxy({ port });
484
+ out(`${APP_NAME} proxy on http://127.0.0.1:${s.port} (loopback only). JSON request log on stdout. Ctrl+C to stop.`);
485
+ process.on('SIGINT', () => { s.close(); process.exit(0); });
486
+ return;
487
+ }
488
+ case 'ui': {
489
+ autoRefreshLicense();
490
+ const ui = await startUi({ port: str(flags.port) ? Number(str(flags.port)) : 0, proxyPort: Number(process.env.LLLANYARD_PORT ?? DEFAULT_PORT), launch: launch() });
491
+ if (flags['print-url'])
492
+ out(ui.url); // used by the desktop app
493
+ else
494
+ out(`${APP_NAME} is running. Open this private link (it works only for you, on this computer):\n\n ${ui.url}\n`);
495
+ if (!flags['no-open'])
496
+ openBrowser(ui.url);
497
+ process.on('SIGINT', () => { ui.close(); process.exit(0); });
498
+ process.on('SIGTERM', () => { ui.close(); process.exit(0); });
499
+ return;
500
+ }
501
+ case 'mcp': {
502
+ // stdout is the protocol channel: nothing else may be printed to it.
503
+ const base = `http://127.0.0.1:${process.env.LLLANYARD_PORT ?? DEFAULT_PORT}`;
504
+ const ks = new KeyringStore();
505
+ const timeout = str(flags.timeout) ? Number(str(flags.timeout)) : 600;
506
+ if (!Number.isFinite(timeout) || timeout <= 0)
507
+ fail('--timeout is a number of seconds, e.g. --timeout 600');
508
+ if (multi['scripts-dir'] && !flags.run)
509
+ fail('--scripts-dir needs --run.');
510
+ const scriptDirs = resolveScriptDirs(multi['scripts-dir'] ?? []);
511
+ // One dialog at a time: requests queue rather than stacking dialogs on top of each other.
512
+ let queue = Promise.resolve();
513
+ const handler = createMcpHandler({
514
+ listKeys: () => ks.list(),
515
+ providerIds: () => Object.keys(allProviders(new ProviderStore())),
516
+ usage: (since) => summarize(readUsage(usageLogPath(), since), since),
517
+ proxyBase: base, client: str(flags.client),
518
+ runner: flags.run ? {
519
+ // Read on every call, so tools approved while the app is open show up without restarting it.
520
+ list: () => new ToolStore().list().map((t) => ({
521
+ name: t.name, runs: [path.basename(t.command), ...t.args.map((a) => (path.isAbsolute(a) ? path.basename(a) : a))].join(' '),
522
+ keys: Object.keys(t.env), acceptsArgs: Boolean(t.allowArgs), sandbox: Boolean(t.sandbox),
523
+ })),
524
+ run: (name, args) => runForMcp(new ToolStore().get(name) ?? fail(`No approved tool "${name}".`), args, ks, timeout),
525
+ scriptDirs,
526
+ runFile: (file, args) => {
527
+ const next = queue.then(async () => {
528
+ const plan = planScriptRun(file, args, scriptDirs, bindingsForAllKeys(ks), { pathVar: process.env.PATH });
529
+ const allowed = await confirmDialog(describePlan(plan, args));
530
+ createUsageLog().write({ ts: new Date().toISOString(), event: 'mcp-file', tool: plan.script, decision: allowed ? 'allow' : 'deny', keys: Object.keys(plan.env) });
531
+ if (!allowed)
532
+ return null;
533
+ const { script, helpers, modifiedRecently, ...def } = plan;
534
+ return runForMcp({ name: path.basename(script), ...def, scrub: true }, [], ks, timeout);
535
+ });
536
+ queue = next.catch(() => { });
537
+ return next;
538
+ },
539
+ } : undefined,
540
+ });
541
+ process.stderr.write(`${APP_NAME} MCP server (${flags.run ? `runs approved tools${scriptDirs.length ? ` and, after a dialog, scripts in ${scriptDirs.join(', ')}` : ''}` : 'read-only'}, never reveals keys) on stdio${flags.client ? `, client "${str(flags.client)}"` : ''}.\n`);
542
+ await serveMcpStdio(handler, process.stdin, process.stdout);
543
+ return;
544
+ }
545
+ case 'serve': {
546
+ const host = str(flags.host) ?? fail('Usage: lllanyard serve --host 0.0.0.0 --port 3443 --tls-cert cert.pem --tls-key key.pem --allow-host keys.example.com');
547
+ const certFile = str(flags['tls-cert']), keyFile = str(flags['tls-key']);
548
+ let tls;
549
+ if (certFile && keyFile) {
550
+ if (process.platform !== 'win32' && (statSync(keyFile).mode & 0o077) !== 0)
551
+ fail(`TLS key ${keyFile} is readable by other users. Fix with: chmod 600 ${keyFile}`);
552
+ tls = { cert: readFileSync(certFile), key: readFileSync(keyFile) };
553
+ }
554
+ const allowed = multi['allow-host']?.map((h) => h.toLowerCase());
555
+ const port = str(flags.port) ? Number(str(flags.port)) : 3443;
556
+ const s = await startProxy({ port, host, tls, allowedHosts: allowed });
557
+ out(`${APP_NAME} shared proxy on ${tls ? 'https' : 'http'}://${host}:${s.port} (hosts: ${allowed?.join(', ') ?? 'loopback'}). Keys stay in this computer's keychain.`);
558
+ out(`Teammates use: base URL https://${allowed?.[0] ?? host}:${s.port}/openai/v1 with their own agent token as the API key.`);
559
+ out('Create a token per person/device: lllanyard agent create alice-laptop --client acme --expires 90d');
560
+ process.on('SIGINT', () => { s.close(); process.exit(0); });
561
+ return;
562
+ }
563
+ case 'usage': {
564
+ const now = Date.now();
565
+ const since = parseSince(str(flags.since) ?? '7d', now);
566
+ const sum = summarize(readUsage(usageLogPath(), since), since, now);
567
+ if (flags.json)
568
+ return out(JSON.stringify(sum, null, 2));
569
+ out(`Usage since ${new Date(since).toISOString().slice(0, 16).replace('T', ' ')} UTC (from ${usageLogPath()})`);
570
+ out(`Requests ${sum.requests} errors ${sum.errors} rate-limited ${sum.rateLimited} kill-switch trips ${sum.killSwitch} blocked ${sum.denied}`);
571
+ const table = (title, rows) => {
572
+ if (!rows.length)
573
+ return;
574
+ out(`\n${title}`);
575
+ for (const r of rows.slice(0, 15))
576
+ out(` ${r.name.padEnd(22).slice(0, 22)} ${String(r.requests).padStart(7)} req ${String(r.errors).padStart(4)} err ${String(r.rateLimited).padStart(4)} 429 p50 ${r.p50}ms p95 ${r.p95}ms`);
577
+ };
578
+ table('By provider', sum.byProvider);
579
+ table('By agent', sum.byAgent);
580
+ table('By client', sum.byClient);
581
+ for (const e of sum.egressDenied.slice(0, 10))
582
+ out(` blocked egress: ${e.tool} -> ${e.host} x${e.count}`);
583
+ if (sum.scriptRuns.length)
584
+ out('\nScripts run with your keys (lllanyard exec)');
585
+ for (const r of sum.scriptRuns)
586
+ out(` ${r.tool.padEnd(22).slice(0, 22)} ${String(r.count).padStart(5)}x last ${r.last.slice(0, 16).replace('T', ' ')} UTC`);
587
+ if (!sum.requests && !sum.scriptRuns.length)
588
+ out('\nNo requests recorded yet. Start the proxy with: lllanyard start');
589
+ return out('\nVisual dashboard (Pro): lllanyard dashboard');
590
+ }
591
+ case 'dashboard': {
592
+ const lic = new LicenseStore().status();
593
+ if (lic.tier === 'free')
594
+ fail('The visual dashboard is a Pro feature. See: lllanyard license activate (the text report is free: lllanyard usage)');
595
+ const now = Date.now();
596
+ const since = parseSince(str(flags.since) ?? '30d', now);
597
+ const html = renderDashboard(summarize(readUsage(usageLogPath(), since), since, now), lic.tier);
598
+ const file = path.resolve(str(flags.out) ?? path.join(os.homedir(), '.lllanyard', 'dashboard.html'));
599
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
600
+ writeFileSync(file, html, { mode: 0o600 });
601
+ return out(`Dashboard written to ${file}. Open it in your browser (it is self-contained and makes no network requests).`);
602
+ }
603
+ case 'license': {
604
+ const [sub] = pos;
605
+ const ls = new LicenseStore();
606
+ if (sub === 'activate') {
607
+ const token = flags.stdin ? (await readStdin()).trim() : str(flags.key) ?? pos[1] ?? fail('Usage: lllanyard license activate <license> (or --key <license>, or --stdin)');
608
+ const p = ls.activate(token);
609
+ return out(`License active: ${p.tier}, ${p.seats} seat(s), valid until ${new Date(p.exp * 1000).toISOString().slice(0, 10)}.`);
610
+ }
611
+ if (sub === 'remove') {
612
+ ls.remove();
613
+ return out('License removed. Back to the free tier (existing keys keep working).');
614
+ }
615
+ if (sub === 'refresh') {
616
+ const r = await refreshLicense(ls);
617
+ if (r.kind === 'renewed')
618
+ return out(`Renewed: ${r.payload.tier}, ${r.payload.seats} seat(s), valid until ${new Date(r.payload.exp * 1000).toISOString().slice(0, 10)}.`);
619
+ if (r.kind === 'current')
620
+ return out(r.message);
621
+ if (r.kind === 'none')
622
+ return out('No license from lllanyard.app is stored, so there is nothing to refresh.');
623
+ return fail(r.message);
624
+ }
625
+ if (sub === 'status' || !sub) {
626
+ const st = ls.status();
627
+ out(st.payload ? `Tier: ${st.tier} (${st.payload.seats} seat(s), until ${new Date(st.payload.exp * 1000).toISOString().slice(0, 10)}). Key limit: ${ls.limits().maxKeys}.` : `Tier: free. Key limit: ${ls.limits().maxKeys}.`);
628
+ if (st.problem)
629
+ out(`Note: stored license not applied: ${st.problem}`);
630
+ return;
631
+ }
632
+ return fail('Usage: lllanyard license activate|status|refresh|remove');
633
+ }
634
+ case 'backup': {
635
+ const file = path.resolve(str(flags.out) ?? path.join(os.homedir(), `lllanyard-backup-${new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')}.enc`));
636
+ const pass = await backupPassphrase(str(flags['passphrase-file']), true);
637
+ const { text, items } = createBackup(pass);
638
+ if (!items.length)
639
+ fail('Nothing to back up yet.');
640
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
641
+ try {
642
+ writeFileSync(file, text, { mode: 0o600, flag: 'wx' });
643
+ }
644
+ catch (e) {
645
+ fail(e.code === 'EEXIST' ? `${file} already exists; choose another --out.` : e.message);
646
+ }
647
+ out(`Backed up ${items.length} item(s) to ${file}:`);
648
+ for (const it of items)
649
+ out(` ${describeItem(it)}`);
650
+ return out('\nThe file holds your keys, encrypted with the passphrase. Keep both safe and separate: without the passphrase it cannot be restored.');
651
+ }
652
+ case 'restore': {
653
+ const file = pos[0] ?? fail('Usage: lllanyard restore <file> [--overwrite] [--dry-run]');
654
+ const payload = readBackup(readFileSync(file, 'utf8'), await backupPassphrase(str(flags['passphrase-file']), false));
655
+ const dry = Boolean(flags['dry-run']);
656
+ const r = restoreBackup(payload, undefined, { overwrite: Boolean(flags.overwrite), dryRun: dry, maxKeys: new LicenseStore().limits().maxKeys });
657
+ out(`Backup from ${payload.createdAt.slice(0, 16).replace('T', ' ')} UTC${dry ? ' (dry run: nothing written)' : ''}:`);
658
+ for (const it of r.added)
659
+ out(` ${dry ? 'would add ' : 'added '} ${describeItem(it)}`);
660
+ for (const it of r.replaced)
661
+ out(` ${dry ? 'would replace' : 'replaced '} ${describeItem(it)}`);
662
+ for (const it of r.kept)
663
+ out(` kept current ${describeItem(it)}`);
664
+ for (const { item, reason } of r.invalid)
665
+ out(` SKIPPED ${describeItem(item)}: ${reason}`);
666
+ if (r.kept.length)
667
+ out(`\n${r.kept.length} item(s) already exist and were left as they are. Use --overwrite to replace them with the backup's copy.`);
668
+ if (r.invalid.length)
669
+ process.exitCode = 1;
670
+ return;
671
+ }
672
+ case 'setup': {
673
+ const app = pos[0];
674
+ if (!app) {
675
+ const d = claudeDesktopStatus(), c = claudeCodeStatus();
676
+ out(`Claude Desktop ${d.connected ? `connected${d.scriptDirs.length ? `; scripts in ${d.scriptDirs.join(', ')} run after a dialog` : ''}` : 'not connected'} (${d.file})`);
677
+ if (d.problem)
678
+ out(` ${d.problem}`);
679
+ out(`Claude Code ${c.connected ? 'connected' : 'not connected'} (${c.file})`);
680
+ return out('\nConnect with: lllanyard setup claude-desktop [--scripts-dir DIR] or lllanyard setup claude-code');
681
+ }
682
+ if (app === 'claude-desktop') {
683
+ // Claude Desktop saves its settings over ours when it quits, so change them only while it's closed.
684
+ let restart = Boolean(flags.restart);
685
+ if (!restart && claudeDesktopRunning() && canRestartClaudeDesktop())
686
+ restart = await confirm('Claude Desktop is open. It rewrites its settings when it quits, which would undo this. Quit it, make the change and reopen it? [y/N] ');
687
+ if (flags.remove) {
688
+ const { result: r, restarted } = await withClaudeDesktopClosed(() => disconnectClaudeDesktop(), { restart });
689
+ return out(r.removed ? `Removed LLLanyard from ${r.file} (old file kept as ${r.backup}).${restarted ? ' Claude Desktop was restarted.' : ''}` : 'Claude Desktop was not connected.');
690
+ }
691
+ const scriptDirs = multi['scripts-dir'] ?? [];
692
+ const { result: r, restarted } = await withClaudeDesktopClosed(() => connectClaudeDesktop(launch(), scriptDirs), { restart });
693
+ out(`Connected Claude Desktop: ${r.file}${r.backup ? ` (old file kept as ${r.backup})` : ''}.`);
694
+ out(r.scriptDirs.length ? `Claude can run your approved tools, and any script in ${r.scriptDirs.join(', ')} after you click Run in a dialog.` : 'Claude can run your approved tools (lllanyard tool add). Add --scripts-dir DIR to let it run scripts from a folder, with a dialog each time.');
695
+ return out(restarted ? 'Claude Desktop was restarted and has loaded it.' : 'Open Claude Desktop to use it.');
696
+ }
697
+ if (app === 'claude-code') {
698
+ if (flags.remove) {
699
+ const r = disconnectClaudeCode();
700
+ return out(r.removed ? `Removed the LLLanyard section from ${r.file}.` : 'Claude Code was not connected.');
701
+ }
702
+ const r = connectClaudeCode();
703
+ return out(`Claude Code will use lllanyard exec for keys: added to ${r.file}${r.backup ? ` (old file kept as ${r.backup})` : ''}. New sessions pick it up.`);
704
+ }
705
+ return fail('Usage: lllanyard setup [claude-desktop [--scripts-dir DIR] | claude-code] [--remove]');
706
+ }
707
+ case 'doctor': {
708
+ const b = backendKind() === 'file' ? defaultBackend() : new KeyringBackend();
709
+ b.set('__doctor__', 'ok');
710
+ const ok = b.get('__doctor__') === 'ok';
711
+ b.delete('__doctor__');
712
+ out(ok ? `OK: ${backendKind() === 'file' ? 'encrypted file vault' : 'OS keychain'} is usable on ${process.platform}.` : 'FAILED: keychain read-back mismatch.');
713
+ const sb = sandboxSelfTest();
714
+ return out(sb.ok ? `OK: tool sandbox works (${sb.kind}). Use --sandbox on tools.` : `Sandbox not available: ${sb.reason}`);
715
+ }
716
+ default:
717
+ return fail(`Unknown command "${cmd}". Try: lllanyard help`);
718
+ }
719
+ }
720
+ /** Backup passphrase: from a chmod 600 file (scripts), else a hidden prompt (asked twice when creating). */
721
+ /** How an app should start this CLI: this Node and this file, by absolute path, with the PATH it was set up from. */
722
+ function launch() {
723
+ const pathVar = process.env.PATH ?? '';
724
+ return { node: stableNodePath(process.execPath, pathVar), cli: fileURLToPath(import.meta.url), pathVar };
725
+ }
726
+ /**
727
+ * Runs an approved tool for the MCP server: no input (stdin is the protocol channel), output captured and returned rather
728
+ * than printed (stdout is the protocol channel too), a time limit, and each run logged like `exec`.
729
+ */
730
+ async function runForMcp(t, extra, keys, timeoutSec) {
731
+ const LIMIT = 100_000;
732
+ let output = '', dropped = false;
733
+ const capture = () => new Writable({ write(chunk, _enc, done) {
734
+ output += String(chunk);
735
+ if (output.length > LIMIT) {
736
+ output = output.slice(-LIMIT);
737
+ dropped = true;
738
+ }
739
+ done();
740
+ } });
741
+ // Scripts run from their own folder; anything else from an empty temp folder (never the app's own working directory).
742
+ const script = scriptOf(t);
743
+ const tmp = script ? undefined : mkdtempSync(path.join(os.tmpdir(), 'lllanyard-mcp-'));
744
+ const ac = new AbortController();
745
+ let timedOut = false;
746
+ const timer = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutSec * 1000);
747
+ createUsageLog().write({ ts: new Date().toISOString(), event: 'exec', tool: `mcp ${t.name}`, cwd: script ? path.dirname(script) : tmp, keys: Object.keys(t.env) });
748
+ try {
749
+ const exitCode = await runTool({ ...t, args: [...t.args, ...extra] }, {
750
+ store: keys, stdout: capture(), stderr: capture(), noStdin: true, tty: false, signal: ac.signal,
751
+ cwd: script ? path.dirname(script) : tmp, egress: await maybeEgress(t.name, t.allowHosts, t.sandbox),
752
+ });
753
+ return { exitCode, output, truncated: dropped, timedOut };
754
+ }
755
+ finally {
756
+ clearTimeout(timer);
757
+ if (tmp)
758
+ rmSync(tmp, { recursive: true, force: true });
759
+ }
760
+ }
761
+ async function backupPassphrase(file, creating) {
762
+ if (file) {
763
+ if (process.platform !== 'win32' && (statSync(file).mode & 0o077) !== 0)
764
+ fail(`Passphrase file ${file} is readable by other users. Fix with: chmod 600 ${file}`);
765
+ return readFileSync(file, 'utf8').replace(/\r?\n$/, '');
766
+ }
767
+ if (!process.stdin.isTTY)
768
+ fail('Backups need a passphrase: run in a terminal, or pass --passphrase-file F (a chmod 600 file).');
769
+ const p1 = await readSecret(creating ? `Choose a backup passphrase (min ${MIN_PASSPHRASE} characters, input hidden): ` : 'Backup passphrase (input hidden): ');
770
+ if (creating) {
771
+ if (p1.length < MIN_PASSPHRASE)
772
+ fail(`Passphrase must be at least ${MIN_PASSPHRASE} characters.`);
773
+ if ((await readSecret('Repeat passphrase: ')) !== p1)
774
+ fail('Passphrases did not match.');
775
+ }
776
+ return p1;
777
+ }
778
+ async function importFindings(findings, store, apply, redact) {
779
+ if (redact && !apply)
780
+ fail('--redact requires --import --apply.');
781
+ const existing = new Set(store.list().map((m) => `${m.provider}\0${store.get(m.provider, m.label)}`));
782
+ const plan = new Map();
783
+ for (const f of findings) {
784
+ if (f.importable && !existing.has(`${f.provider}\0${f.reveal()}`))
785
+ plan.set(f.reveal(), plan.get(f.reveal()) ?? f);
786
+ }
787
+ if (!plan.size)
788
+ return out('\nNothing new to import.');
789
+ out(`\n${apply ? 'Importing' : 'Would import'} ${plan.size} key(s):`);
790
+ const used = new Set(store.list().map((m) => m.id));
791
+ for (const [secret, f] of plan) {
792
+ const lbl = used.has(`${f.provider}:default`) ? `imported-${secret.slice(-4).toLowerCase()}` : 'default';
793
+ used.add(`${f.provider}:${lbl}`);
794
+ out(` ${f.provider}:${lbl} ${f.masked}`);
795
+ if (!apply)
796
+ continue;
797
+ store.set(f.provider, secret, { label: lbl });
798
+ if (store.get(f.provider, lbl) !== secret)
799
+ fail('Read-back check failed; stopping before touching any file.');
800
+ if (redact) {
801
+ for (const file of new Set(findings.filter((x) => x.reveal() === secret).map((x) => x.file))) {
802
+ out(` redacted ${await redactFile(file, [secret])}x in ${file}`);
803
+ }
804
+ }
805
+ }
806
+ if (!apply)
807
+ return out('\nDry run only. Add --apply to import (and --redact to blank the keys out of those files).');
808
+ out('\nImportant: these keys sat in plaintext, so treat them as exposed. Create new ones at the provider,');
809
+ out('then run "lllanyard rotate <provider>" and revoke the old key.');
810
+ }
811
+ main().catch((e) => {
812
+ process.stderr.write(`Error: ${e.message}\n`);
813
+ if (process.env.LLLANYARD_DEBUG)
814
+ process.stderr.write(`${e.stack}\n`);
815
+ process.exit(1);
816
+ });
817
+ /** In the last week of a paid license, fetch its renewed key in the background (LLLANYARD_NO_LICENSE_REFRESH=1 turns this off). */
818
+ function autoRefreshLicense() {
819
+ const ls = new LicenseStore();
820
+ if (!refreshDue(ls))
821
+ return;
822
+ void refreshLicense(ls).then((r) => {
823
+ if (r.kind === 'renewed')
824
+ process.stderr.write(`License renewed: valid until ${new Date(r.payload.exp * 1000).toISOString().slice(0, 10)}.\n`);
825
+ else if (r.kind === 'failed')
826
+ process.stderr.write(`License renewal check: ${r.message} Run lllanyard license refresh later.\n`);
827
+ }).catch(() => { });
828
+ }
829
+ //# sourceMappingURL=index.js.map