@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.26",
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-svg": "0.1.0",
19
- "@broberg/lens-engine": "0.13.1"
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
- const sparse = await cutFrames(vid, nodePath.join(dir, 'sparse'), { fps: body.fps ?? 2 });
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, join } from 'node:path';
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
- return join(this.root, key);
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> {