@1agh/maude 0.38.1 → 0.39.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.
@@ -658,10 +658,30 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
658
658
  // Phase 31 follow-up — persist an image pasted straight into the ACP composer
659
659
  // (a clipboard screenshot has no path), returning an absolute path the chip
660
660
  // expands to so Claude can Read it. MAIN-ORIGIN ONLY: sameOriginWrite CSRF gate
661
- // + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes, so the
662
- // untrusted canvas iframe is 403'd. The disk caps live in api.saveChatAttachment
663
- // (magic-byte sniff / 10 MB / content-addressed name / session write budget).
661
+ // on POST + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes,
662
+ // so the untrusted canvas iframe is 403'd. The disk caps live in
663
+ // api.saveChatAttachment (magic-byte sniff / 10 MB / content-addressed name /
664
+ // session write budget).
665
+ //
666
+ // GET ?name=<sha8>.<ext> serves the pasted image back to the chat feed
667
+ // (thumbnail + lightbox). The name is regex-allowlisted in
668
+ // api.resolveChatAttachment — content-addressed, never a caller path, so
669
+ // traversal-proof by construction; content-addressed ⇒ immutable cache. No
670
+ // CSRF gate on GET (sameOriginWrite is the POST/CSRF boundary) — the
671
+ // main-origin posture above is what keeps the canvas origin out.
664
672
  '/_api/acp/attachment': async (req: Request) => {
673
+ if (req.method === 'GET') {
674
+ const name = new URL(req.url).searchParams.get('name');
675
+ const abs = await api.resolveChatAttachment(name);
676
+ if (!abs) return new Response('Not found', { status: 404 });
677
+ return serveFile(abs, {
678
+ 'Cache-Control': 'public, max-age=31536000, immutable',
679
+ // Uploaded bytes on the privileged main origin — never let the browser
680
+ // MIME-sniff a polyglot into a richer type (mirrors the DDR-088 static
681
+ // lane; the sniff/allowlist on write is the primary gate).
682
+ 'X-Content-Type-Options': 'nosniff',
683
+ });
684
+ }
665
685
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
666
686
  if (!sameOriginWrite(req))
667
687
  return new Response('cross-origin write rejected', { status: 403 });
@@ -0,0 +1,74 @@
1
+ // GET /_api/acp/attachment — the content-addressed serve route behind the ACP
2
+ // chat image thumbnails + lightbox. The name is the ONLY input and must match
3
+ // our own `<sha8>.<ext>` shape (api.resolveChatAttachment) — traversal-proof by
4
+ // construction, so these tests pin the allowlist: a real uploaded attachment
5
+ // serves 200 with an image content-type + immutable cache; every malformed /
6
+ // hostile name 404s; non-GET/POST methods 405. The canvas-origin 403 lives in
7
+ // canvas-origin-gate.test.ts (dual-allowlist invariant, DDR-054/DDR-088).
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
12
+
13
+ // Minimal valid PNG magic bytes, padded so length checks pass (asset-api idiom).
14
+ const pad = (header: number[], len = 64): Uint8Array => {
15
+ const out = new Uint8Array(len);
16
+ out.set(header, 0);
17
+ return out;
18
+ };
19
+ const PNG = pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
20
+
21
+ describe('acp-attachment-serve / GET /_api/acp/attachment', () => {
22
+ test('serves an uploaded attachment by name; rejects hostile names; gates methods', async () => {
23
+ const sandbox = makeSandbox();
24
+ const port = nextPort();
25
+ const proc = await bootServer(sandbox.root, port);
26
+ const base = `http://localhost:${port}/_api/acp/attachment`;
27
+ try {
28
+ // Write side first — the serve route only ever serves what saveChatAttachment
29
+ // itself named (content-addressed <sha8>.<ext>).
30
+ const post = await fetch(base, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'image/png' },
33
+ body: PNG,
34
+ });
35
+ expect(post.status).toBe(201);
36
+ const { path: absPath } = (await post.json()) as { path: string };
37
+ const name = absPath.split('/').pop() as string;
38
+ expect(name).toMatch(/^[0-9a-f]{8}\.png$/);
39
+
40
+ // Happy path — 200, image content-type, immutable cache (content-addressed).
41
+ const ok = await fetch(`${base}?name=${name}`);
42
+ expect(ok.status).toBe(200);
43
+ expect(ok.headers.get('content-type')).toBe('image/png');
44
+ expect(ok.headers.get('cache-control')).toContain('immutable');
45
+ expect(new Uint8Array(await ok.arrayBuffer())).toEqual(PNG);
46
+
47
+ // Name allowlist — anything that is not our own <sha8>.(png|jpe?g|gif|webp)
48
+ // shape must 404 (never resolved against the filesystem).
49
+ for (const bad of [
50
+ '../../etc/passwd',
51
+ '..%2f..%2fetc%2fpasswd',
52
+ `/etc/passwd`,
53
+ 'abc',
54
+ 'deadbeef.svg', // SVG is never written — scriptable, stays unservable
55
+ 'deadbeef.png.svg',
56
+ 'DEADBEEF.png', // uppercase hex — writer emits lowercase only
57
+ 'deadbeef1.png', // 9 hex chars
58
+ `_chat/attachments/${name}`,
59
+ ]) {
60
+ const res = await fetch(`${base}?name=${encodeURIComponent(bad)}`);
61
+ expect(res.status).toBe(404);
62
+ }
63
+ // Missing name / valid-shaped but nonexistent file → 404 too.
64
+ expect((await fetch(base)).status).toBe(404);
65
+ expect((await fetch(`${base}?name=00000000.png`)).status).toBe(404);
66
+
67
+ // Method gate — only GET (serve) + POST (upload) exist.
68
+ expect((await fetch(base, { method: 'DELETE' })).status).toBe(405);
69
+ expect((await fetch(base, { method: 'PUT' })).status).toBe(405);
70
+ } finally {
71
+ await killProc(proc);
72
+ }
73
+ });
74
+ });
@@ -101,9 +101,11 @@ describe('ACP bridge origin gate', () => {
101
101
  expect(crossOrigin.status).toBe(403);
102
102
 
103
103
  // (6) Phase 31 follow-up — /_api/acp/attachment (clipboard-image paste):
104
- // POST-only, main-origin only. A valid PNG → 201 with an absolute path under
105
- // the runtime _chat/attachments/; GET is 405; the canvas origin + a
106
- // cross-origin drive-by POST are 403.
104
+ // main-origin only. A valid PNG → 201 with an absolute path under the
105
+ // runtime _chat/attachments/; GET (the thumbnail serve branch) 404s
106
+ // without a valid content-addressed name full serve coverage lives in
107
+ // acp-attachment-serve.test.ts; the canvas origin + a cross-origin
108
+ // drive-by POST are 403.
107
109
  const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
108
110
  const up = await fetch(`${main}/_api/acp/attachment`, {
109
111
  method: 'POST',
@@ -115,7 +117,7 @@ describe('ACP bridge origin gate', () => {
115
117
  const upJson = await up.json();
116
118
  expect(typeof upJson.path).toBe('string');
117
119
  expect(upJson.path).toContain('_chat/attachments/');
118
- expect(await status(main, '/_api/acp/attachment')).toBe(405); // GET not allowed
120
+ expect(await status(main, '/_api/acp/attachment')).toBe(404); // GET without a name → 404
119
121
  expect(await status(canvas, '/_api/acp/attachment')).toBe(403); // off the canvas origin
120
122
  const attDriveBy = await fetch(`${main}/_api/acp/attachment`, {
121
123
  method: 'POST',
@@ -114,6 +114,11 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
114
114
  // the untrusted canvas origin must never reach it (it writes the global
115
115
  // ~/.config/maude/hubs.json token store).
116
116
  '/_api/hub/link',
117
+ // ACP chat attachments (POST upload + GET thumbnail serve) are MAIN-ORIGIN
118
+ // ONLY — absent from CANVAS_SAFE_API + startCanvasServer's routes. The
119
+ // untrusted canvas origin must never read (or write) the user's pasted
120
+ // chat images; a GET here 403s at the gate, not 404 from the handler.
121
+ '/_api/acp/attachment',
117
122
  '/package.json',
118
123
  ]) {
119
124
  expect(await code(p)).toBe(403);
@@ -0,0 +1,117 @@
1
+ // ACP chat image thumbnails — the pure ref helpers behind the two render paths:
2
+ // a LIVE user bubble carries the collapsed chip token ([image-1]), a RELOADED
3
+ // bubble (transcript) carries the expanded absolute `_chat/attachments/` path.
4
+ // Both must resolve to the same content-addressed `<sha8>.<ext>` name the GET
5
+ // serve route accepts — and nothing else may match (a random project path must
6
+ // never render as a thumbnail).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import {
11
+ attachmentName,
12
+ designImageRefs,
13
+ extractAttachmentRefs,
14
+ } from '../client/panels/acp-runtime.js';
15
+
16
+ const ABS = '/Users/x/proj/.design/_chat/attachments/ab12cd34.png';
17
+
18
+ describe('chat-attachments / attachmentName', () => {
19
+ test('extracts the content-addressed basename from attachment paths', () => {
20
+ expect(attachmentName(ABS)).toBe('ab12cd34.png');
21
+ expect(attachmentName('_chat/attachments/00ff00ff.webp')).toBe('00ff00ff.webp');
22
+ expect(attachmentName(` ${ABS} `)).toBe('ab12cd34.png'); // trimmed
23
+ expect(attachmentName('/x/_chat/attachments/deadbeef.jpeg')).toBe('deadbeef.jpeg');
24
+ });
25
+
26
+ test('rejects everything that is not a servable attachment path', () => {
27
+ expect(attachmentName('/Users/x/proj/assets/ab12cd34.png')).toBeNull(); // wrong dir
28
+ expect(attachmentName('/x/_chat/attachments/ab12cd34.svg')).toBeNull(); // never served
29
+ expect(attachmentName('/x/_chat/attachments/AB12CD34.png')).toBeNull(); // uppercase hex
30
+ expect(attachmentName('/x/_chat/attachments/abc.png')).toBeNull(); // short hash
31
+ expect(attachmentName(`${ABS}/../secret.png`)).toBeNull(); // trailing junk
32
+ expect(attachmentName('')).toBeNull();
33
+ expect(attachmentName(null)).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('chat-attachments / extractAttachmentRefs', () => {
38
+ test('chip-only string (live bubble)', () => {
39
+ expect(extractAttachmentRefs('[image-1]')).toEqual([
40
+ { type: 'chip', token: '[image-1]', kind: 'image' },
41
+ ]);
42
+ });
43
+
44
+ test('expanded absolute path (reloaded bubble) — path never leaks as text', () => {
45
+ expect(extractAttachmentRefs(`look at ${ABS}`)).toEqual([
46
+ { type: 'text', text: 'look at ' },
47
+ { type: 'attachment', name: 'ab12cd34.png', raw: ABS },
48
+ ]);
49
+ });
50
+
51
+ test('mixed text, multiple ref kinds, order preserved', () => {
52
+ const segs = extractAttachmentRefs(`fix [image-2] and [file-1] then ${ABS} done`);
53
+ expect(segs).toEqual([
54
+ { type: 'text', text: 'fix ' },
55
+ { type: 'chip', token: '[image-2]', kind: 'image' },
56
+ { type: 'text', text: ' and ' },
57
+ { type: 'chip', token: '[file-1]', kind: 'file' },
58
+ { type: 'text', text: ' then ' },
59
+ { type: 'attachment', name: 'ab12cd34.png', raw: ABS },
60
+ { type: 'text', text: ' done' },
61
+ ]);
62
+ });
63
+
64
+ test('non-attachment paths and near-miss tokens stay plain text', () => {
65
+ expect(extractAttachmentRefs('/Users/x/proj/assets/ab12cd34.png')).toEqual([
66
+ { type: 'text', text: '/Users/x/proj/assets/ab12cd34.png' },
67
+ ]);
68
+ expect(extractAttachmentRefs('[image-x] [screenshot-1]')).toEqual([
69
+ { type: 'text', text: '[image-x] [screenshot-1]' },
70
+ ]);
71
+ expect(extractAttachmentRefs('')).toEqual([]);
72
+ });
73
+ });
74
+
75
+ describe('chat-attachments / designImageRefs (assistant-mentioned images — DDR-145)', () => {
76
+ test('the /design:screenshot reply shape — backticked relative path', () => {
77
+ expect(
78
+ designImageRefs('Saved to: `.design/_history/.design-ui-smoke/screenshots/001-smoke.png`')
79
+ ).toEqual(['/.design/_history/.design-ui-smoke/screenshots/001-smoke.png']);
80
+ });
81
+
82
+ test('absolute path is clamped to the designRel-relative URL', () => {
83
+ expect(designImageRefs('see /Users/x/proj/.design/exports/hero.png done')).toEqual([
84
+ '/.design/exports/hero.png',
85
+ ]);
86
+ });
87
+
88
+ test('custom designRel + trailing punctuation + dedup + cap', () => {
89
+ expect(designImageRefs('wrote mocks/a.png.', 'mocks')).toEqual(['/mocks/a.png']);
90
+ expect(designImageRefs('x .design/a.png and .design/a.png again')).toEqual(['/.design/a.png']);
91
+ const many = Array.from({ length: 9 }, (_, i) => `.design/s/${i}0000000.png`).join(' ');
92
+ expect(designImageRefs(many)).toHaveLength(6);
93
+ });
94
+
95
+ test('never matches outside designRel, non-images, SVG, or lookalike dirs', () => {
96
+ expect(designImageRefs('site/public/logo.png')).toEqual([]);
97
+ expect(designImageRefs('.design/ui/Canvas.tsx')).toEqual([]);
98
+ expect(designImageRefs('.design/assets/mark.svg')).toEqual([]); // scriptable — excluded
99
+ expect(designImageRefs('not-.design/a.png')).toEqual([]); // prefix must be a path segment
100
+ expect(designImageRefs('', undefined)).toEqual([]);
101
+ });
102
+
103
+ test('traversal is rejected before it can escape designRel (DDR-145 A9 fix)', () => {
104
+ // A `..` dot-segment would collapse to /etc/x.png in the browser.
105
+ expect(designImageRefs('.design/../../etc/secret.png')).toEqual([]);
106
+ expect(designImageRefs('.design/../.git/config.png')).toEqual([]);
107
+ // Percent-encoding would decode past <rel>/ server-side — reject any `%`.
108
+ expect(designImageRefs('.design/..%2f..%2fsecrets/cam.png')).toEqual([]);
109
+ expect(designImageRefs('.design/%2e%2e/x.png')).toEqual([]);
110
+ // Backslash: WHATWG rewrites `\`→`/` for http(s), so `..\..\` would climb out
111
+ // AFTER a naive guard — the canonical-form allowlist catches it (attacker F).
112
+ expect(designImageRefs('.design/..\\..\\secret.png')).toEqual([]);
113
+ expect(designImageRefs('.design/a\\..\\..\\x.png')).toEqual([]);
114
+ // A legit path that merely CONTAINS "dotdot"-ish text (not a segment) is fine.
115
+ expect(designImageRefs('.design/a..b/hero.png')).toEqual(['/.design/a..b/hero.png']);
116
+ });
117
+ });
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "acp-chat-image-thumbnails",
6
+ "version": "0.39.0",
7
+ "date": "2026-07-03",
8
+ "kind": "feature",
9
+ "title": "See images right in the chat",
10
+ "summary": "Images now show up as thumbnails in the Assistant panel — paste a screenshot into the box and it appears in your message, and when Claude points to an image it made (like a /design:screenshot capture) that shows up too. Click any thumbnail to open it full-size in a lightbox.",
11
+ "surface": "design-ui"
12
+ },
4
13
  {
5
14
  "id": "acp-context-hardening",
6
15
  "version": "0.38.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,13 +50,13 @@
50
50
  "test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@1agh/maude-darwin-arm64": "0.38.1",
54
- "@1agh/maude-darwin-x64": "0.38.1",
55
- "@1agh/maude-linux-arm64": "0.38.1",
56
- "@1agh/maude-linux-arm64-musl": "0.38.1",
57
- "@1agh/maude-linux-x64": "0.38.1",
58
- "@1agh/maude-linux-x64-musl": "0.38.1",
59
- "@1agh/maude-win32-x64": "0.38.1"
53
+ "@1agh/maude-darwin-arm64": "0.39.0",
54
+ "@1agh/maude-darwin-x64": "0.39.0",
55
+ "@1agh/maude-linux-arm64": "0.39.0",
56
+ "@1agh/maude-linux-arm64-musl": "0.39.0",
57
+ "@1agh/maude-linux-x64": "0.39.0",
58
+ "@1agh/maude-linux-x64-musl": "0.39.0",
59
+ "@1agh/maude-win32-x64": "0.39.0"
60
60
  },
61
61
  "files": [
62
62
  "cli",