@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.
- package/apps/studio/api.ts +21 -0
- package/apps/studio/client/app.jsx +1 -0
- package/apps/studio/client/panels/ChatPanel.jsx +190 -4
- package/apps/studio/client/panels/acp-runtime.js +108 -0
- package/apps/studio/client/styles/6-acp-chat.css +123 -1
- package/apps/studio/dist/client.bundle.js +14 -14
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +23 -3
- package/apps/studio/test/acp-attachment-serve.test.ts +74 -0
- package/apps/studio/test/acp-origin-gate.test.ts +6 -4
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/chat-attachments.test.ts +117 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -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,
|
|
662
|
-
// untrusted canvas iframe is 403'd. The disk caps live in
|
|
663
|
-
// (magic-byte sniff / 10 MB / content-addressed name /
|
|
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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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(
|
|
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.
|
|
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.
|
|
54
|
-
"@1agh/maude-darwin-x64": "0.
|
|
55
|
-
"@1agh/maude-linux-arm64": "0.
|
|
56
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
57
|
-
"@1agh/maude-linux-x64": "0.
|
|
58
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
59
|
-
"@1agh/maude-win32-x64": "0.
|
|
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",
|