@llamaventures/cli 1.3.1 → 1.4.2
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/bin/llama-mcp.mjs +131 -22
- package/bin/llama.mjs +198 -0
- package/lib/external.mjs +36 -0
- package/package.json +1 -1
package/bin/llama-mcp.mjs
CHANGED
|
@@ -40,6 +40,34 @@ async function callApi(method, path, body) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Append a block to a deal brief. The /blocks route only accepts atomic
|
|
44
|
+
// full-array PUTs (no POST), so we GET current blocks, prepend the new
|
|
45
|
+
// one (matches UI default since 2026-05-03), and PUT the merged array.
|
|
46
|
+
// Server stamps identity meta on PUT; we don't send any.
|
|
47
|
+
async function addBriefBlock(dealId, block) {
|
|
48
|
+
try {
|
|
49
|
+
const id = globalThis.crypto.randomUUID();
|
|
50
|
+
const cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
|
|
51
|
+
const existing = Array.isArray(cur?.blocks) ? cur.blocks : [];
|
|
52
|
+
const result = await request(
|
|
53
|
+
"PUT",
|
|
54
|
+
`/api/deals/${encodeURIComponent(dealId)}/blocks`,
|
|
55
|
+
{ blocks: [{ id, ...block }, ...existing] }
|
|
56
|
+
);
|
|
57
|
+
const text = JSON.stringify(
|
|
58
|
+
{ ok: result?.ok ?? true, id, count: result?.count ?? existing.length + 1 },
|
|
59
|
+
null,
|
|
60
|
+
2
|
|
61
|
+
);
|
|
62
|
+
return { content: [{ type: "text", text }] };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
const server = new McpServer({
|
|
44
72
|
name: "llama-mcp",
|
|
45
73
|
version: PKG_VERSION,
|
|
@@ -202,7 +230,7 @@ server.registerTool(
|
|
|
202
230
|
"brief_add_text",
|
|
203
231
|
{
|
|
204
232
|
description:
|
|
205
|
-
"
|
|
233
|
+
"Prepend a markdown text block to a deal brief. Supports markdown + mermaid diagrams.",
|
|
206
234
|
inputSchema: {
|
|
207
235
|
dealId: z.string(),
|
|
208
236
|
heading: z.string().optional().describe("optional block heading"),
|
|
@@ -210,18 +238,14 @@ server.registerTool(
|
|
|
210
238
|
},
|
|
211
239
|
},
|
|
212
240
|
async ({ dealId, heading, body }) =>
|
|
213
|
-
|
|
214
|
-
type: "text",
|
|
215
|
-
heading,
|
|
216
|
-
body,
|
|
217
|
-
})
|
|
241
|
+
addBriefBlock(dealId, { type: "text", heading: heading ?? "", body })
|
|
218
242
|
);
|
|
219
243
|
|
|
220
244
|
server.registerTool(
|
|
221
245
|
"brief_add_link",
|
|
222
246
|
{
|
|
223
247
|
description:
|
|
224
|
-
"
|
|
248
|
+
"Prepend a link block to a deal brief. Server fetches og:image + title via /api/link-preview.",
|
|
225
249
|
inputSchema: {
|
|
226
250
|
dealId: z.string(),
|
|
227
251
|
url: z.string(),
|
|
@@ -229,18 +253,14 @@ server.registerTool(
|
|
|
229
253
|
},
|
|
230
254
|
},
|
|
231
255
|
async ({ dealId, url, label }) =>
|
|
232
|
-
|
|
233
|
-
type: "link",
|
|
234
|
-
url,
|
|
235
|
-
label,
|
|
236
|
-
})
|
|
256
|
+
addBriefBlock(dealId, { type: "link", url, label: label ?? "" })
|
|
237
257
|
);
|
|
238
258
|
|
|
239
259
|
server.registerTool(
|
|
240
260
|
"brief_add_callout",
|
|
241
261
|
{
|
|
242
262
|
description:
|
|
243
|
-
"
|
|
263
|
+
"Prepend a callout block to a deal brief. Use for emphasized insights or warnings.",
|
|
244
264
|
inputSchema: {
|
|
245
265
|
dealId: z.string(),
|
|
246
266
|
tone: z.string().describe("insight | warning | info | success"),
|
|
@@ -249,12 +269,7 @@ server.registerTool(
|
|
|
249
269
|
},
|
|
250
270
|
},
|
|
251
271
|
async ({ dealId, tone, heading, body }) =>
|
|
252
|
-
|
|
253
|
-
type: "callout",
|
|
254
|
-
tone,
|
|
255
|
-
heading,
|
|
256
|
-
body,
|
|
257
|
-
})
|
|
272
|
+
addBriefBlock(dealId, { type: "callout", tone, heading: heading ?? "", body })
|
|
258
273
|
);
|
|
259
274
|
|
|
260
275
|
// ============================================================
|
|
@@ -279,15 +294,23 @@ server.registerTool(
|
|
|
279
294
|
{
|
|
280
295
|
description:
|
|
281
296
|
"Create or update a wiki page. Content should be markdown with attribution " +
|
|
282
|
-
"blocks (**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability."
|
|
297
|
+
"blocks (**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability. " +
|
|
298
|
+
"`sources` is a separate citation list (URLs, doc names, or meeting references) " +
|
|
299
|
+
"— at least one is required; URLs embedded inside `content` do not count.",
|
|
283
300
|
inputSchema: {
|
|
284
301
|
slug: z.string().describe("kebab-case slug"),
|
|
285
302
|
title: z.string(),
|
|
286
303
|
content: z.string().describe("markdown content"),
|
|
304
|
+
sources: z
|
|
305
|
+
.array(z.string())
|
|
306
|
+
.min(1)
|
|
307
|
+
.describe(
|
|
308
|
+
"citation list — URLs, doc names, or meeting references. At least one required."
|
|
309
|
+
),
|
|
287
310
|
},
|
|
288
311
|
},
|
|
289
|
-
async ({ slug, title, content }) =>
|
|
290
|
-
callApi("POST", "/api/wiki/save", { slug, title, content })
|
|
312
|
+
async ({ slug, title, content, sources }) =>
|
|
313
|
+
callApi("POST", "/api/wiki/save", { slug, title, content, sources })
|
|
291
314
|
);
|
|
292
315
|
|
|
293
316
|
// ============================================================
|
|
@@ -517,6 +540,92 @@ server.registerTool(
|
|
|
517
540
|
}
|
|
518
541
|
);
|
|
519
542
|
|
|
543
|
+
// ============================================================
|
|
544
|
+
// Memo — long-form HTML investment memo (the Memo tab in the UI)
|
|
545
|
+
// ============================================================
|
|
546
|
+
|
|
547
|
+
server.registerTool(
|
|
548
|
+
"memo_show",
|
|
549
|
+
{
|
|
550
|
+
description:
|
|
551
|
+
"Fetch the current memo for a deal. Returns the envelope: memo " +
|
|
552
|
+
"(html, version, source, updated_by, updated_at), mode " +
|
|
553
|
+
"('composed' = server-generated, 'override' = hand-written), and " +
|
|
554
|
+
"inflight (if a server-side regeneration is in progress). html " +
|
|
555
|
+
"can be 50-100KB — be deliberate about including it in your reply.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
dealId: z.string().describe("deal uuid"),
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
async ({ dealId }) =>
|
|
561
|
+
callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/memo`)
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
server.registerTool(
|
|
565
|
+
"memo_regenerate",
|
|
566
|
+
{
|
|
567
|
+
description:
|
|
568
|
+
"Trigger server-side regeneration of the deal memo. Synchronous: " +
|
|
569
|
+
"returns the final result (version, model, duration_ms, degraded) " +
|
|
570
|
+
"once the composer finishes. Typical duration 2-3 minutes. Use " +
|
|
571
|
+
"tier='opus' for high-stakes deals (higher cost, deeper analysis).",
|
|
572
|
+
inputSchema: {
|
|
573
|
+
dealId: z.string().describe("deal uuid"),
|
|
574
|
+
tier: z
|
|
575
|
+
.enum(["sonnet", "opus"])
|
|
576
|
+
.optional()
|
|
577
|
+
.describe("LLM tier (default: sonnet)"),
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
async ({ dealId, tier }) =>
|
|
581
|
+
callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/memo`, {
|
|
582
|
+
action: "regenerate",
|
|
583
|
+
stream: false,
|
|
584
|
+
model: tier ?? "sonnet",
|
|
585
|
+
})
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
server.registerTool(
|
|
589
|
+
"memo_save",
|
|
590
|
+
{
|
|
591
|
+
description:
|
|
592
|
+
"Save hand-written HTML as a manual override for a deal's memo. " +
|
|
593
|
+
"Manual overrides take precedence over auto-composed memos on " +
|
|
594
|
+
"read. Pass the full HTML document including <!DOCTYPE html>, " +
|
|
595
|
+
"<style>, and <body> — it's rendered as-is in a sandboxed iframe.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
dealId: z.string().describe("deal uuid"),
|
|
598
|
+
html: z
|
|
599
|
+
.string()
|
|
600
|
+
.describe("full HTML document"),
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
async ({ dealId, html }) =>
|
|
604
|
+
callApi("PUT", `/api/deals/${encodeURIComponent(dealId)}/memo`, { html })
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
server.registerTool(
|
|
608
|
+
"memo_reset",
|
|
609
|
+
{
|
|
610
|
+
description:
|
|
611
|
+
"Reset memo state. Default drops only the manual override row " +
|
|
612
|
+
"(next read falls back to the auto-composed version, if any). " +
|
|
613
|
+
"Pass scope='all' to drop every version for the deal — destructive, " +
|
|
614
|
+
"use sparingly.",
|
|
615
|
+
inputSchema: {
|
|
616
|
+
dealId: z.string().describe("deal uuid"),
|
|
617
|
+
scope: z
|
|
618
|
+
.enum(["override_only", "all"])
|
|
619
|
+
.optional()
|
|
620
|
+
.describe("default: override_only"),
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
async ({ dealId, scope }) =>
|
|
624
|
+
callApi("DELETE", `/api/deals/${encodeURIComponent(dealId)}/memo`, {
|
|
625
|
+
scope: scope ?? "override_only",
|
|
626
|
+
})
|
|
627
|
+
);
|
|
628
|
+
|
|
520
629
|
// ============================================================
|
|
521
630
|
// Prompts
|
|
522
631
|
// ============================================================
|
package/bin/llama.mjs
CHANGED
|
@@ -256,6 +256,12 @@ Wiki:
|
|
|
256
256
|
llama wiki read <slug>
|
|
257
257
|
llama wiki save <slug> --title "..." --content "..." --sources "url1;url2" [--type company] [--related "A;B"]
|
|
258
258
|
|
|
259
|
+
Memo (long-form HTML investment memo — Memo tab in the UI):
|
|
260
|
+
llama memo show <dealId> [--out <path>] [--json] # default: html → stdout (pipeable to file / browser)
|
|
261
|
+
llama memo regenerate <dealId> [--opus] # streams panel progress to stderr; result version → stdout
|
|
262
|
+
llama memo save <dealId> --file <path> # paste a hand-written HTML as manual override
|
|
263
|
+
llama memo reset <dealId> [--all] # default drops manual override; --all drops every version
|
|
264
|
+
|
|
259
265
|
Admin (system admin only — server returns 403 for non-admin tokens):
|
|
260
266
|
llama admin auth-events [--kind X] [--actor email] [--subject email] [--since 24h|7d|30d|<ISO>] [--limit 100]
|
|
261
267
|
llama admin deal-events [--kind X] [--actor email] [--deal <uuid>] [--since 24h] [--limit 100]
|
|
@@ -1588,6 +1594,198 @@ https://command.llamaventures.vc/settings/tokens, run
|
|
|
1588
1594
|
);
|
|
1589
1595
|
}
|
|
1590
1596
|
|
|
1597
|
+
// ----- Memo (long-form HTML investment memo) -----
|
|
1598
|
+
// The Memo tab in the deal page renders HTML stored in deal_memos.
|
|
1599
|
+
// Two sources of memo content:
|
|
1600
|
+
// - composed: generated by the server-side memo composer on demand
|
|
1601
|
+
// - manual: a hand-written HTML you paste in
|
|
1602
|
+
// Manual always beats composed on read; reset to drop the manual row
|
|
1603
|
+
// and fall back to the composed one.
|
|
1604
|
+
if (area === "memo") {
|
|
1605
|
+
const sub = action;
|
|
1606
|
+
|
|
1607
|
+
// show — fetch the current memo. Default: print HTML to stdout
|
|
1608
|
+
// (pipeable to file or browser). --out writes to a path. --json
|
|
1609
|
+
// returns the full envelope (memo + mode + inflight info).
|
|
1610
|
+
if (sub === "show") {
|
|
1611
|
+
const dealId = rest[0];
|
|
1612
|
+
if (!dealId) {
|
|
1613
|
+
throw new Error("Usage: llama memo show <dealId> [--out <path>] [--json]");
|
|
1614
|
+
}
|
|
1615
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
1616
|
+
const data = await request(
|
|
1617
|
+
"GET",
|
|
1618
|
+
`/api/deals/${encodeURIComponent(dealId)}/memo`
|
|
1619
|
+
);
|
|
1620
|
+
if (flags.json) {
|
|
1621
|
+
print(data);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const html = data?.memo?.html;
|
|
1625
|
+
if (!html) {
|
|
1626
|
+
if (data?.requires_compose) {
|
|
1627
|
+
throw new Error(
|
|
1628
|
+
"No memo for this deal yet — run `llama memo regenerate <dealId>` to compose one."
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
throw new Error("Memo response missing html field.");
|
|
1632
|
+
}
|
|
1633
|
+
if (flags.out) {
|
|
1634
|
+
const { writeFileSync } = await import("fs");
|
|
1635
|
+
writeFileSync(String(flags.out), html);
|
|
1636
|
+
console.error(`Wrote ${html.length} bytes → ${flags.out}`);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
// Stdout — supports `llama memo show <id> > memo.html` and piping
|
|
1640
|
+
// to e.g. `open -f -a Safari` for quick preview.
|
|
1641
|
+
process.stdout.write(html);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// regenerate — kick off the server-side composer. Streams panel
|
|
1646
|
+
// progress events to stderr so you can see live status; prints
|
|
1647
|
+
// final summary JSON (version, model, duration) to stdout.
|
|
1648
|
+
if (sub === "regenerate") {
|
|
1649
|
+
const dealId = rest[0];
|
|
1650
|
+
if (!dealId) {
|
|
1651
|
+
throw new Error("Usage: llama memo regenerate <dealId> [--opus]");
|
|
1652
|
+
}
|
|
1653
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
1654
|
+
const tier = flags.opus ? "opus" : "sonnet";
|
|
1655
|
+
const authHeaders = await getAuthHeaders();
|
|
1656
|
+
if (Object.keys(authHeaders).length === 0) {
|
|
1657
|
+
throw new Error(
|
|
1658
|
+
"Not authenticated. Run `gcloud auth login` or `llama token set <llc_...>` first."
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
const res = await fetch(
|
|
1662
|
+
`${getBaseUrl()}/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
1663
|
+
{
|
|
1664
|
+
method: "POST",
|
|
1665
|
+
headers: { "Content-Type": "application/json", ...authHeaders },
|
|
1666
|
+
body: JSON.stringify({
|
|
1667
|
+
action: "regenerate",
|
|
1668
|
+
stream: true,
|
|
1669
|
+
model: tier,
|
|
1670
|
+
}),
|
|
1671
|
+
}
|
|
1672
|
+
);
|
|
1673
|
+
if (!res.ok || !res.body) {
|
|
1674
|
+
const text = await res.text().catch(() => "");
|
|
1675
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const reader = res.body.getReader();
|
|
1679
|
+
const decoder = new TextDecoder();
|
|
1680
|
+
let buffer = "";
|
|
1681
|
+
let doneEvent = null;
|
|
1682
|
+
const startedAt = Date.now();
|
|
1683
|
+
const progress = { done: 0, total: 12, placeholders: 0, retries: 0 };
|
|
1684
|
+
|
|
1685
|
+
while (true) {
|
|
1686
|
+
const { value, done: streamDone } = await reader.read();
|
|
1687
|
+
if (streamDone) break;
|
|
1688
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1689
|
+
let idx;
|
|
1690
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
1691
|
+
const frame = buffer.slice(0, idx);
|
|
1692
|
+
buffer = buffer.slice(idx + 2);
|
|
1693
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
1694
|
+
if (!dataLine) continue;
|
|
1695
|
+
let event;
|
|
1696
|
+
try {
|
|
1697
|
+
event = JSON.parse(dataLine.replace(/^data:\s?/, ""));
|
|
1698
|
+
} catch {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
1702
|
+
const phase = event.phase || "?";
|
|
1703
|
+
if (phase === "panel_done") {
|
|
1704
|
+
progress.done = event.panels_completed ?? progress.done + 1;
|
|
1705
|
+
progress.total = event.panels_total ?? progress.total;
|
|
1706
|
+
if (event.status === "placeholder") progress.placeholders += 1;
|
|
1707
|
+
if (event.status === "retry-recovered") progress.retries += 1;
|
|
1708
|
+
const mark =
|
|
1709
|
+
event.status === "ok"
|
|
1710
|
+
? "✓"
|
|
1711
|
+
: event.status === "retry-recovered"
|
|
1712
|
+
? "↻"
|
|
1713
|
+
: "⚠";
|
|
1714
|
+
console.error(
|
|
1715
|
+
`${elapsed}s ${mark} ${event.panel} [${progress.done}/${progress.total}]`
|
|
1716
|
+
);
|
|
1717
|
+
} else if (phase === "anchor_done") {
|
|
1718
|
+
console.error(
|
|
1719
|
+
`${elapsed}s anchor → ${event.verdict_label || event.verdict}`
|
|
1720
|
+
);
|
|
1721
|
+
} else if (phase === "assembling") {
|
|
1722
|
+
console.error(`${elapsed}s assembling…`);
|
|
1723
|
+
} else if (phase === "done") {
|
|
1724
|
+
doneEvent = event;
|
|
1725
|
+
} else if (phase === "error") {
|
|
1726
|
+
throw new Error(`Memo composer error: ${event.error}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (!doneEvent) {
|
|
1732
|
+
throw new Error("Stream ended without 'done' event.");
|
|
1733
|
+
}
|
|
1734
|
+
print({
|
|
1735
|
+
ok: true,
|
|
1736
|
+
version: doneEvent.version,
|
|
1737
|
+
degraded: doneEvent.degraded,
|
|
1738
|
+
model: doneEvent.model,
|
|
1739
|
+
duration_ms: doneEvent.duration_ms,
|
|
1740
|
+
placeholders: progress.placeholders,
|
|
1741
|
+
retries: progress.retries,
|
|
1742
|
+
});
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// save — upload hand-written HTML as a manual override.
|
|
1747
|
+
if (sub === "save") {
|
|
1748
|
+
const { flags } = parseFlags(rest);
|
|
1749
|
+
const dealId = rest[0];
|
|
1750
|
+
if (!dealId || !flags.file) {
|
|
1751
|
+
throw new Error("Usage: llama memo save <dealId> --file <path>");
|
|
1752
|
+
}
|
|
1753
|
+
const { readFileSync } = await import("fs");
|
|
1754
|
+
const html = readFileSync(String(flags.file), "utf-8");
|
|
1755
|
+
if (!html.trim()) throw new Error(`File ${flags.file} is empty.`);
|
|
1756
|
+
print(
|
|
1757
|
+
await request(
|
|
1758
|
+
"PUT",
|
|
1759
|
+
`/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
1760
|
+
{ html }
|
|
1761
|
+
)
|
|
1762
|
+
);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// reset — default drops only the manual override (next read returns
|
|
1767
|
+
// the composed row, if any); --all drops every version for this deal.
|
|
1768
|
+
if (sub === "reset") {
|
|
1769
|
+
const dealId = rest[0];
|
|
1770
|
+
if (!dealId) {
|
|
1771
|
+
throw new Error("Usage: llama memo reset <dealId> [--all]");
|
|
1772
|
+
}
|
|
1773
|
+
const { flags } = parseFlags(rest.slice(1));
|
|
1774
|
+
print(
|
|
1775
|
+
await request(
|
|
1776
|
+
"DELETE",
|
|
1777
|
+
`/api/deals/${encodeURIComponent(dealId)}/memo`,
|
|
1778
|
+
{ scope: flags.all ? "all" : "override_only" }
|
|
1779
|
+
)
|
|
1780
|
+
);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
throw new Error(
|
|
1785
|
+
`Unknown memo subcommand "${sub || ""}". Use: show / regenerate / save / reset.`
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1591
1789
|
usage();
|
|
1592
1790
|
process.exitCode = 1;
|
|
1593
1791
|
}
|
package/lib/external.mjs
CHANGED
|
@@ -317,6 +317,40 @@ function guessMimeType(filename) {
|
|
|
317
317
|
return ALLOWED_MIME_BY_EXT[ext] || "application/octet-stream";
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// Paths the upload helper refuses to read. The intent is "user must point
|
|
321
|
+
// at a real, intentional document" — symlinks and well-known config
|
|
322
|
+
// directories are rejected so an automated caller cannot ferry an
|
|
323
|
+
// unintended file into the upload by handing over a misleading path.
|
|
324
|
+
const DISALLOWED_PATH_PREFIXES = [
|
|
325
|
+
".ssh",
|
|
326
|
+
".llama",
|
|
327
|
+
".aws",
|
|
328
|
+
".config/gcloud",
|
|
329
|
+
".gnupg",
|
|
330
|
+
".kube",
|
|
331
|
+
".docker",
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
function assertSafeUploadPath(filePath) {
|
|
335
|
+
const lstat = fs.lstatSync(filePath);
|
|
336
|
+
if (lstat.isSymbolicLink()) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"Upload path is a symbolic link; pass the real file path instead."
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (!lstat.isFile()) {
|
|
342
|
+
throw new Error("Upload path is not a regular file.");
|
|
343
|
+
}
|
|
344
|
+
const resolved = fs.realpathSync(filePath);
|
|
345
|
+
const home = os.homedir();
|
|
346
|
+
for (const prefix of DISALLOWED_PATH_PREFIXES) {
|
|
347
|
+
const denied = path.join(home, prefix);
|
|
348
|
+
if (resolved === denied || resolved.startsWith(denied + path.sep)) {
|
|
349
|
+
throw new Error("Upload path is not allowed.");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
export async function uploadExternalFile(filePath) {
|
|
321
355
|
const session = readExternalSession();
|
|
322
356
|
if (!session) {
|
|
@@ -329,6 +363,8 @@ export async function uploadExternalFile(filePath) {
|
|
|
329
363
|
throw new Error(`File not found: ${filePath}`);
|
|
330
364
|
}
|
|
331
365
|
|
|
366
|
+
assertSafeUploadPath(filePath);
|
|
367
|
+
|
|
332
368
|
const fileData = fs.readFileSync(filePath);
|
|
333
369
|
const filename = path.basename(filePath);
|
|
334
370
|
const mimetype = guessMimeType(filename);
|