@broberg/lens-daemon 0.1.26 → 0.1.28
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/package.json +3 -3
- package/src/frames.ts +5 -0
- package/src/http.ts +3 -0
- package/src/local-guard.ts +41 -0
- package/src/mcp.ts +2 -0
- package/src/record-run.ts +9 -1
- package/src/storage.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"sharp": "^0.33.5",
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
|
-
"@broberg/lens-
|
|
19
|
-
"@broberg/lens-
|
|
18
|
+
"@broberg/lens-engine": "0.14.0",
|
|
19
|
+
"@broberg/lens-svg": "0.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/frames.ts
CHANGED
|
@@ -49,6 +49,11 @@ export async function cutFrames(
|
|
|
49
49
|
bin = 'ffmpeg',
|
|
50
50
|
): Promise<CutResult> {
|
|
51
51
|
const fps = opts.fps ?? 4;
|
|
52
|
+
// fps is pasted into an ffmpeg filter string: a non-number there is a second
|
|
53
|
+
// filter (H4, 2026-10-21 — drawtext=textfile=… drew a file into a frame).
|
|
54
|
+
if (typeof fps !== 'number' || !Number.isFinite(fps) || fps <= 0 || fps > 120) {
|
|
55
|
+
throw new Error(`cutFrames: fps must be a number above 0 and at most 120 — got ${JSON.stringify(fps)}`);
|
|
56
|
+
}
|
|
52
57
|
if (!(await ffmpegAvailable(bin))) {
|
|
53
58
|
return { frames: [], unavailable: `${bin} is not installed — video kept, no stills` };
|
|
54
59
|
}
|
package/src/http.ts
CHANGED
|
@@ -31,9 +31,12 @@ import {
|
|
|
31
31
|
import { drainUploads, uploadStats } from './artifact-uploads.ts';
|
|
32
32
|
import { backfillFromDisk } from './artifact-backfill.ts';
|
|
33
33
|
import { artifactContentType } from './storage.ts';
|
|
34
|
+
import { localRequestGuard } from './local-guard.ts';
|
|
34
35
|
|
|
35
36
|
export function createLensRouter(deps: LensDeps): Hono {
|
|
36
37
|
const r = new Hono();
|
|
38
|
+
// Security sweep 2026-10-21 (H1–H4): loopback Host + JSON writes only. First.
|
|
39
|
+
r.use('*', localRequestGuard(deps.port));
|
|
37
40
|
// lens F001.5 — the daemon's sandbox is injected, never copied (sandbox.ts).
|
|
38
41
|
if (deps.resolveSandboxedPath) setSandboxResolver(deps.resolveSandboxedPath);
|
|
39
42
|
// lens F001.5 — the routes that used to be registered one by one in the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Security sweep 2026-10-21, high findings H1–H4: the daemon on 127.0.0.1 took
|
|
2
|
+
// a call from anyone who could make a browser send one — a DNS-rebound page
|
|
3
|
+
// (it never checked Host) or any site's "simple" text/plain POST (it parsed JSON
|
|
4
|
+
// whatever the content type). Through that door a page could read any file
|
|
5
|
+
// (/lens/artifact), drive the owner's logged-in profile, mint a secret to its
|
|
6
|
+
// own URL, or approve a baseline.
|
|
7
|
+
//
|
|
8
|
+
// Two rules close it without a token, because a browser cannot break either:
|
|
9
|
+
// · Host must name this loopback daemon — a rebound page sends its own name.
|
|
10
|
+
// · A write (POST/PUT/PATCH/DELETE) must be application/json — a cross-site
|
|
11
|
+
// page cannot send that without a CORS preflight, and the preflight is only
|
|
12
|
+
// granted to the daemon shell's own allowlisted origins.
|
|
13
|
+
// Lens' own Chromium visiting a hostile page is the same case as any browser.
|
|
14
|
+
//
|
|
15
|
+
// A non-browser caller (curl, an MCP client, the cardmem SPA via CORS) already
|
|
16
|
+
// sends both, so nothing legitimate changes.
|
|
17
|
+
|
|
18
|
+
import type { MiddlewareHandler } from 'hono';
|
|
19
|
+
|
|
20
|
+
const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
21
|
+
|
|
22
|
+
export function localRequestGuard(port: number): MiddlewareHandler {
|
|
23
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
24
|
+
return async (c, next) => {
|
|
25
|
+
// A server request carries Host; Bun builds c.req.url from it, so the URL's
|
|
26
|
+
// host is the same value when the header is not on the Request object.
|
|
27
|
+
const host = (c.req.header('host') ?? new URL(c.req.url).host).toLowerCase();
|
|
28
|
+
if (!hosts.has(host)) {
|
|
29
|
+
return c.json({ ok: false, error: `lens daemon: refused Host "${host}" — only this machine's loopback address may call it` }, 421);
|
|
30
|
+
}
|
|
31
|
+
if (WRITE.has(c.req.method)) {
|
|
32
|
+
const type = (c.req.header('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
|
|
33
|
+
// A body-less DELETE (the MCP session close) carries no content type.
|
|
34
|
+
const empty = c.req.method === 'DELETE' && !c.req.header('content-length') && !type;
|
|
35
|
+
if (type !== 'application/json' && !empty) {
|
|
36
|
+
return c.json({ ok: false, error: 'lens daemon: a write must be Content-Type: application/json' }, 415);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Hono } from 'hono';
|
|
11
11
|
import { MANUSCRIPT_VERBS, STEP_TYPES } from './flow.ts';
|
|
12
|
+
import { localRequestGuard } from './local-guard.ts';
|
|
12
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
14
|
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
14
15
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -546,6 +547,7 @@ async function dispatch(deps: LensDeps, name: string, args: any): Promise<unknow
|
|
|
546
547
|
|
|
547
548
|
export function createLensMcpRouter(deps: LensDeps): Hono {
|
|
548
549
|
const router = new Hono();
|
|
550
|
+
router.use('*', localRequestGuard(deps.port));
|
|
549
551
|
router.all('/', async (c) => {
|
|
550
552
|
const server = new Server(
|
|
551
553
|
{ name: 'cardmem-lens', version: '0.1.0' },
|
package/src/record-run.ts
CHANGED
|
@@ -85,6 +85,13 @@ export async function runRecord(raw: RecordRequest, deps: RecordDeps) {
|
|
|
85
85
|
seed_tabs?: { session: string; channel?: string; label?: string }[];
|
|
86
86
|
};
|
|
87
87
|
if (!body.url) return { ok: false as const, status: 400, error: 'url required' };
|
|
88
|
+
// Both reach an ffmpeg filter string. 0 = no stills (burst), never a string.
|
|
89
|
+
for (const k of ['fps', 'burst_fps'] as const) {
|
|
90
|
+
const v = body[k];
|
|
91
|
+
if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 120)) {
|
|
92
|
+
return { ok: false as const, status: 400, error: `${k} must be a number from 0 to 120` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
88
95
|
const project = body.project ?? 'lens';
|
|
89
96
|
try {
|
|
90
97
|
let auth = body.auth as AuthConfig | undefined;
|
|
@@ -311,7 +318,8 @@ export async function runRecord(raw: RecordRequest, deps: RecordDeps) {
|
|
|
311
318
|
const vid = nodePath.join(dir, 'run.webm');
|
|
312
319
|
await writeFile(vid, result.video);
|
|
313
320
|
const cuts: { t: number; file: string; near?: number }[] = [];
|
|
314
|
-
|
|
321
|
+
// fps 0 = no timeline stills (the documented off switch), not an ffmpeg call.
|
|
322
|
+
const sparse = body.fps === 0 ? { frames: [] } : await cutFrames(vid, nodePath.join(dir, 'sparse'), { fps: body.fps ?? 2 });
|
|
315
323
|
if (sparse.unavailable) framesUnavailable = sparse.unavailable;
|
|
316
324
|
cuts.push(...sparse.frames.map((f) => ({ t: f.t, file: f.file })));
|
|
317
325
|
const burstFps = body.burst_fps ?? 12;
|
package/src/storage.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { S3Client } from 'bun';
|
|
20
20
|
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
21
|
-
import { dirname,
|
|
21
|
+
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
22
22
|
|
|
23
23
|
export interface LensStorage {
|
|
24
24
|
readonly backend: 'local' | 'tigris';
|
|
@@ -31,8 +31,15 @@ class LocalDirStorage implements LensStorage {
|
|
|
31
31
|
readonly backend = 'local' as const;
|
|
32
32
|
constructor(private readonly root: string) {}
|
|
33
33
|
|
|
34
|
+
/** A key is a path INSIDE the store, never a way out of it (H2, 2026-10-21:
|
|
35
|
+
* `../../.ssh/id_rsa` was served by /lens/artifact). */
|
|
34
36
|
private path(key: string): string {
|
|
35
|
-
|
|
37
|
+
const root = resolve(this.root);
|
|
38
|
+
const p = resolve(root, key);
|
|
39
|
+
if (isAbsolute(key) || (p !== root && !p.startsWith(root + sep))) {
|
|
40
|
+
throw new Error(`lens storage: key escapes the artifact store: ${JSON.stringify(key)}`);
|
|
41
|
+
}
|
|
42
|
+
return p;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async put(key: string, bytes: Buffer): Promise<void> {
|