@1agh/maude 0.46.0 → 0.47.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.
Files changed (56) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +113 -5
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/api.ts +417 -2
  5. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  6. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  7. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  8. package/apps/studio/bin/_curl-local.mjs +349 -0
  9. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  10. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  11. package/apps/studio/bin/curl-local.sh +28 -0
  12. package/apps/studio/canvas-cursors.ts +6 -0
  13. package/apps/studio/canvas-edit.ts +678 -11
  14. package/apps/studio/canvas-icons.tsx +13 -0
  15. package/apps/studio/canvas-lib.tsx +3 -0
  16. package/apps/studio/canvas-shell.tsx +646 -26
  17. package/apps/studio/client/app.jsx +898 -52
  18. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  19. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  20. package/apps/studio/contextual-toolbar.tsx +5 -3
  21. package/apps/studio/dist/client.bundle.js +1103 -1103
  22. package/apps/studio/dist/comment-mount.js +2 -2
  23. package/apps/studio/dist/styles.css +1 -1
  24. package/apps/studio/grid-track-handles.ts +179 -0
  25. package/apps/studio/handoff.ts +35 -0
  26. package/apps/studio/http.ts +118 -0
  27. package/apps/studio/input-router.tsx +73 -17
  28. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  29. package/apps/studio/test/browse-posture.test.tsx +107 -0
  30. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  31. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  32. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  33. package/apps/studio/test/comment-mount.test.ts +2 -1
  34. package/apps/studio/test/component-map.test.ts +48 -0
  35. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  36. package/apps/studio/test/detach-component.test.ts +94 -0
  37. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  38. package/apps/studio/test/element-structural-api.test.ts +74 -0
  39. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  40. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  41. package/apps/studio/test/handoff.test.ts +48 -0
  42. package/apps/studio/test/input-router.test.ts +82 -8
  43. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  44. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  45. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  46. package/apps/studio/tool-palette.tsx +3 -1
  47. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  48. package/apps/studio/use-element-resize.tsx +3 -1
  49. package/apps/studio/use-grid-track-handles.tsx +364 -0
  50. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  51. package/apps/studio/use-tool-mode.tsx +30 -3
  52. package/apps/studio/web-overlay-content.tsx +52 -0
  53. package/apps/studio/whats-new.json +27 -0
  54. package/cli/commands/design.mjs +16 -0
  55. package/package.json +8 -8
  56. package/plugins/design/templates/_shell.html +4 -0
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ // _curl-local.mjs — loopback-only HTTP request, reached via
3
+ // `maude design curl-local` (DDR-062 dispatch; DDR-185, hardened per its
4
+ // security addendum's second round).
5
+ //
6
+ // WHY THIS EXISTS: an ACP chat session auto-approves `Bash(maude:*)` (DDR-184)
7
+ // but NOT a bare `curl` — every raw `curl` call still prompts, even one aimed
8
+ // at the user's own localhost dev server (a common ask mid design-workflow:
9
+ // "is my backend up on :3000?"). Claude Code's `Bash(prefix:*)` allowlist is a
10
+ // plain string-prefix match with no host awareness, so a prefix-list can't
11
+ // express "curl, but only to localhost" reliably. This verb does the real
12
+ // check instead. Covered for free by the EXISTING `Bash(maude:*)` allow-list
13
+ // rule — no widening of the session's Bash surface.
14
+ //
15
+ // SECURITY ADDENDUM, ROUND 2 — this file's FIRST hardening pass (an argv
16
+ // allowlist over raw curl flags: reject -K/--resolve/--connect-to/etc.,
17
+ // forward everything else to a real `curl` child) was itself bypassed, live,
18
+ // by the SAME class of gap it was written to close: curl supports
19
+ // CONCATENATED short-flag syntax (`-K<path>`, no space or `=`), which
20
+ // `validateArgv`'s `a.split('=')[0]` check never recognized — so `-K<path>`,
21
+ // `-x<url>`, `-T<path>` all sailed through unrejected, reproducing the
22
+ // EXACT original `-K`-config-smuggling bypass verbatim. A SEPARATE gap in
23
+ // the same pass: `--noproxy` (the flag this file forces to `*`) was never
24
+ // itself added to the reject list, so a caller-supplied `--noproxy ""`
25
+ // placed after the forced one silently re-enabled an ambient proxy
26
+ // (curl is last-flag-wins for repeated options).
27
+ //
28
+ // The lesson, stated plainly rather than patched around again: hand-parsing
29
+ // an EXTERNAL binary's own CLI grammar (short flags, concatenation,
30
+ // bundling, `=`-forms, config files, env-var equivalents) to decide what's
31
+ // "safe" is an unbounded surface — every fix closes the specific bypass
32
+ // found and leaves the next one for the next binary quirk nobody thought to
33
+ // test. So this file no longer accepts raw curl arguments AT ALL. It defines
34
+ // its OWN small, Maude-owned flag vocabulary (below) that this file fully
35
+ // parses itself (plain, unambiguous `--flag value` pairs — no short forms,
36
+ // no concatenation, no bundling, nothing to mis-parse), and constructs the
37
+ // real curl invocation from FIXED, hardcoded flag names with the caller's
38
+ // values riding as separate argv array elements (never string-interpolated,
39
+ // so a value containing e.g. "-K" or "; rm -rf" is inert data to curl, not a
40
+ // new flag token — spawnSync with an argv array bypasses the shell
41
+ // entirely). The caller can no longer name a curl flag, so there is nothing
42
+ // left to allowlist/rejectlist AGAINST — the bypass class this addendum is
43
+ // closing cannot recur here by construction, not by enumeration.
44
+ //
45
+ // SCOPE, named explicitly (not left implicit): this verb allows "any
46
+ // loopback address," not "only Maude's own dev-server port" — the user's
47
+ // explicit ask was checking THEIR OWN arbitrary local dev servers, not just
48
+ // Maude's, so narrowing to one port would defeat the feature. This means a
49
+ // call CAN reach another unauthenticated-by-convention loopback service
50
+ // (Docker's API proxy, `kubectl proxy`, Node's inspector protocol) if the
51
+ // ACP session is steered there — accepted, because enumerating every
52
+ // "trusted because it's local" service on every user's machine isn't
53
+ // tractable; recorded here and in DDR-185's addendum, not accepted
54
+ // implicitly.
55
+ //
56
+ // Reuses `_fetch-asset.mjs`'s battle-tested IPv4/IPv6 literal parsers
57
+ // (`parseIPv4`/`parseIPv6`) rather than reinventing address parsing, but with
58
+ // the OPPOSITE accept condition: fetch-asset's `classifyAddress` rejects
59
+ // loopback/private/link-local/etc. and allows everything else (it's fetching
60
+ // attacker-controlled URLs from the open web); curl-local requires STRICT
61
+ // loopback and rejects everything else, including ordinary private-LAN
62
+ // addresses (192.168.x.x etc.) — this verb is for "my own machine", not
63
+ // "anything reachable on my network".
64
+ //
65
+ // Exit: curl's own exit code on success · 2 usage/rejected argv · 3
66
+ // non-loopback target (or DNS resolution failure) rejected · 1 other.
67
+
68
+ import { spawnSync } from 'node:child_process';
69
+ import { lookup } from 'node:dns/promises';
70
+ import { isIP } from 'node:net';
71
+ import { pathToFileURL } from 'node:url';
72
+ import { parseIPv4, parseIPv6 } from './_fetch-asset.mjs';
73
+
74
+ /** True iff this parsed 4-byte IPv4 is in the loopback range 127.0.0.0/8. */
75
+ function isLoopbackIPv4(bytes) {
76
+ return bytes[0] === 127;
77
+ }
78
+
79
+ /** True iff this parsed 16-byte IPv6 is ::1, or an IPv4-mapped/NAT64 loopback. */
80
+ function isLoopbackIPv6(bytes) {
81
+ const allZeroThrough = (n) => bytes.slice(0, n).every((x) => x === 0);
82
+ if (allZeroThrough(15) && bytes[15] === 1) return true; // ::1
83
+ if (allZeroThrough(10) && bytes[10] === 0xff && bytes[11] === 0xff) {
84
+ return isLoopbackIPv4(bytes.slice(12, 16)); // ::ffff:127.x.x.x
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /** True iff this IP literal is strictly loopback — never any other private/reserved range. */
90
+ export function isLoopbackAddress(addr) {
91
+ const kind = isIP(addr);
92
+ if (kind === 4) {
93
+ const bytes = parseIPv4(addr);
94
+ return !!bytes && isLoopbackIPv4(bytes);
95
+ }
96
+ if (kind === 6) {
97
+ const bytes = parseIPv6(addr);
98
+ return !!bytes && isLoopbackIPv6(bytes);
99
+ }
100
+ return false;
101
+ }
102
+
103
+ export class CurlLocalError extends Error {
104
+ constructor(code, message) {
105
+ super(message);
106
+ this.code = code;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Pure check over an already-resolved record set (the shape `dns.lookup(host,
112
+ * {all:true})` returns): every record must be loopback, or this returns a
113
+ * block-reason string naming the first offending address. A single
114
+ * non-loopback record among several (multi-record DNS rebinding) fails the
115
+ * whole host, not just that one record — split out from `resolveLoopbackIp`
116
+ * so it's testable with a plain array literal, no DNS/module mocking needed.
117
+ */
118
+ export function classifyRecords(host, records) {
119
+ if (!records?.length) return `no DNS records for ${host}`;
120
+ for (const { address } of records) {
121
+ if (!isLoopbackAddress(address)) {
122
+ return `${host} resolves to non-loopback ${address} — refusing`;
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Resolve every DNS record for `host`, require ALL of them to be loopback,
130
+ * and return the address to PIN the connection to (the first record) — the
131
+ * caller must pass this to curl's own `--resolve` so the later, independent
132
+ * connection can't re-resolve to something different (closes the
133
+ * validate-then-reconnect DNS-rebinding TOCTOU, the same bug class as
134
+ * CVE-2026-27826). `lookupFn` is injectable (defaults to the real
135
+ * `node:dns/promises` `lookup`) purely so tests can supply a deterministic
136
+ * multi-record response without mocking `node:dns`.
137
+ */
138
+ export async function resolveLoopbackIp(host, { lookupFn = lookup } = {}) {
139
+ if (isIP(host)) {
140
+ if (!isLoopbackAddress(host)) {
141
+ throw new CurlLocalError(3, `${host} is not a loopback address`);
142
+ }
143
+ return host;
144
+ }
145
+ let records;
146
+ try {
147
+ records = await lookupFn(host, { all: true, verbatim: true });
148
+ } catch (err) {
149
+ throw new CurlLocalError(3, `DNS resolution failed for ${host}: ${err?.code ?? err?.message}`);
150
+ }
151
+ const blockReason = classifyRecords(host, records);
152
+ if (blockReason) throw new CurlLocalError(3, blockReason);
153
+ return records[0].address;
154
+ }
155
+
156
+ // ── Maude-owned request vocabulary ──────────────────────────────────────────
157
+ // A small, fully-Maude-parsed flag set — deliberately NOT curl's own syntax.
158
+ // Every flag here takes its value from the NEXT argv element (no `=`-form, no
159
+ // short/concatenated form, nothing to mis-parse); `--insecure`/`--include`/
160
+ // `--verbose` are boolean. Anything not in this set is rejected outright —
161
+ // an allowlist of RECOGNIZED tokens, not a rejectlist of known-bad ones, so
162
+ // an unanticipated curl-style flag simply never has a matching case instead
163
+ // of silently riding through unexamined.
164
+ const METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
165
+ const VALUE_FLAGS = Object.freeze([
166
+ '--method',
167
+ '--header',
168
+ '--data',
169
+ '--user-agent',
170
+ '--max-time',
171
+ '--output',
172
+ ]);
173
+ const BOOLEAN_FLAGS = Object.freeze(['--insecure', '--include', '--verbose']);
174
+
175
+ export class CurlLocalArgvError extends Error {}
176
+
177
+ /**
178
+ * Parse this file's OWN small flag vocabulary (never curl's). Returns
179
+ * `{url, method, headers, data, userAgent, maxTime, output, insecure,
180
+ * include, verbose}` or throws CurlLocalArgvError with a human-readable
181
+ * reason. `--header` may repeat. Exactly one positional (non-flag) argument
182
+ * is accepted: the target URL.
183
+ */
184
+ export function parseRequestArgv(argv) {
185
+ const out = {
186
+ url: null,
187
+ method: 'GET',
188
+ headers: [],
189
+ data: null,
190
+ userAgent: null,
191
+ maxTime: null,
192
+ output: null,
193
+ insecure: false,
194
+ include: false,
195
+ verbose: false,
196
+ };
197
+ for (let i = 0; i < argv.length; i += 1) {
198
+ const a = argv[i];
199
+ if (BOOLEAN_FLAGS.includes(a)) {
200
+ out[a.slice(2)] = true;
201
+ continue;
202
+ }
203
+ if (VALUE_FLAGS.includes(a)) {
204
+ const value = argv[i + 1];
205
+ if (value === undefined) throw new CurlLocalArgvError(`${a} requires a value`);
206
+ i += 1;
207
+ if (a === '--method') {
208
+ const m = value.toUpperCase();
209
+ if (!METHODS.includes(m)) {
210
+ throw new CurlLocalArgvError(
211
+ `--method must be one of ${METHODS.join(', ')} (got "${value}")`
212
+ );
213
+ }
214
+ out.method = m;
215
+ } else if (a === '--header') {
216
+ if (!/^[\w-]+:.*/.test(value)) {
217
+ throw new CurlLocalArgvError(`--header must look like "Name: value" (got "${value}")`);
218
+ }
219
+ out.headers.push(value);
220
+ } else if (a === '--data') {
221
+ out.data = value;
222
+ } else if (a === '--user-agent') {
223
+ out.userAgent = value;
224
+ } else if (a === '--max-time') {
225
+ const n = Number(value);
226
+ if (!Number.isFinite(n) || n <= 0)
227
+ throw new CurlLocalArgvError(`--max-time must be a positive number (got "${value}")`);
228
+ out.maxTime = n;
229
+ } else if (a === '--output') {
230
+ out.output = value;
231
+ }
232
+ continue;
233
+ }
234
+ if (a.startsWith('-')) {
235
+ throw new CurlLocalArgvError(
236
+ `unrecognized flag "${a}" — curl-local has its OWN small flag set, not curl's; run with --help`
237
+ );
238
+ }
239
+ if (out.url !== null)
240
+ throw new CurlLocalArgvError(`unexpected extra argument "${a}" (URL already given)`);
241
+ out.url = a;
242
+ }
243
+ if (!out.url) throw new CurlLocalArgvError('a target URL is required');
244
+ let parsed;
245
+ try {
246
+ parsed = new URL(out.url);
247
+ } catch {
248
+ throw new CurlLocalArgvError(`"${out.url}" is not a valid URL`);
249
+ }
250
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
251
+ throw new CurlLocalArgvError(
252
+ `only http:// and https:// are supported (got ${parsed.protocol}//)`
253
+ );
254
+ }
255
+ out.parsedUrl = parsed;
256
+ return out;
257
+ }
258
+
259
+ /** Build the fixed-shape curl argv for a parsed request. Every user-supplied
260
+ * value rides as its OWN argv array element (never concatenated into a
261
+ * flag), and every flag NAME is one of this file's own hardcoded literals —
262
+ * the caller never gets to name a curl flag. */
263
+ export function buildCurlArgs(req, pinIp, port) {
264
+ const args = [
265
+ '-q', // MUST be first — disables ~/.curlrc auto-load (config-file persistence)
266
+ '-sS',
267
+ '--proto',
268
+ '=http,https',
269
+ '--proto-redir',
270
+ '=http,https',
271
+ '--max-redirs',
272
+ '0', // no redirects — the validated target's response can't hand off elsewhere
273
+ '--noproxy',
274
+ '*', // ignore *_PROXY env — no egress via a poisoned proxy; this file OWNS every flag, so nothing can override it back
275
+ '--resolve',
276
+ `${req.parsedUrl.hostname}:${port}:${pinIp}`, // pin — defeats the validate-then-reconnect DNS-rebinding TOCTOU
277
+ '-X',
278
+ req.method,
279
+ ];
280
+ for (const h of req.headers) args.push('-H', h);
281
+ if (req.data !== null) args.push('--data-raw', req.data); // --data-raw (not -d/--data): never treats a leading "@" as a file path
282
+ if (req.userAgent) args.push('-A', req.userAgent);
283
+ if (req.maxTime) args.push('-m', String(req.maxTime));
284
+ if (req.output) args.push('-o', req.output);
285
+ if (req.insecure) args.push('-k');
286
+ if (req.include) args.push('-i');
287
+ if (req.verbose) args.push('-v');
288
+ args.push('--', req.url);
289
+ return args;
290
+ }
291
+
292
+ const HELP = `curl-local — loopback-only HTTP request (reached via \`maude design curl-local\`)
293
+
294
+ Usage:
295
+ maude design curl-local <url> [--method GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS]
296
+ [--header "Name: value"]... [--data <body>]
297
+ [--user-agent <ua>] [--max-time <seconds>]
298
+ [--output <path>] [--insecure] [--include] [--verbose]
299
+
300
+ This is curl-local's OWN small flag vocabulary, not curl's — it does not
301
+ accept raw curl arguments (see the file's own header comment for why).
302
+ Resolves the URL's host and refuses to run at all unless EVERY resolved
303
+ address is loopback (127.0.0.0/8 or ::1); pins the connection to the
304
+ validated address.
305
+
306
+ Exit: curl's own exit code · 2 usage/rejected argv · 3 non-loopback target
307
+ rejected · 1 other.`;
308
+
309
+ async function main() {
310
+ const argv = process.argv.slice(2);
311
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
312
+ process.stdout.write(`${HELP}\n`);
313
+ process.exit(argv.length === 0 ? 2 : 0);
314
+ }
315
+ let req;
316
+ try {
317
+ req = parseRequestArgv(argv);
318
+ } catch (err) {
319
+ process.stderr.write(`curl-local: ${err.message}\n`);
320
+ process.exit(2);
321
+ }
322
+ let pinIp;
323
+ try {
324
+ pinIp = await resolveLoopbackIp(req.parsedUrl.hostname);
325
+ } catch (err) {
326
+ process.stderr.write(`curl-local: ${err.message}\n`);
327
+ process.exit(err instanceof CurlLocalError ? err.code : 1);
328
+ }
329
+ const port = req.parsedUrl.port
330
+ ? Number(req.parsedUrl.port)
331
+ : req.parsedUrl.protocol === 'https:'
332
+ ? 443
333
+ : 80;
334
+ const curlArgs = buildCurlArgs(req, pinIp, port);
335
+ const result = spawnSync('curl', curlArgs, { stdio: 'inherit' });
336
+ if (result.error) {
337
+ process.stderr.write(`curl-local: failed to run curl: ${result.error.message}\n`);
338
+ process.exit(1);
339
+ }
340
+ process.exit(result.status ?? 1);
341
+ }
342
+
343
+ // Run only when invoked directly (not when imported by the test). This shim
344
+ // runs under real `node` on a real on-disk path (never embedded in
345
+ // `bun --compile`), so the classic argv[1] guard is correct here — see the
346
+ // v0.38.0 self-heal memory.
347
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
348
+ main();
349
+ }
@@ -0,0 +1,280 @@
1
+ // DDR-185 — the loopback-only curl gate behind `maude design curl-local`
2
+ // (apps/studio/bin/_curl-local.mjs). This suite locks the SECURITY CORE: the
3
+ // strict-loopback address classifier (accepts ONLY 127.0.0.0/8 / ::1 / the
4
+ // IPv4-mapped form — unlike `_fetch-asset.mjs`'s `classifyAddress`, an
5
+ // ordinary private-LAN address like 192.168.1.1 is REJECTED here, not
6
+ // allowed), the every-record DNS check (closes the multi-A-record rebinding
7
+ // gap), and — round 2 of the security addendum — the Maude-owned request
8
+ // parser (`parseRequestArgv`) that REPLACED the raw-curl-argv allowlist a
9
+ // live security fan-out found still bypassable via curl's own concatenated
10
+ // short-flag syntax (`-K<path>`, `-x<url>`, `-T<path>` all slipped past the
11
+ // `a.split('=')[0]` check) and a missing `--noproxy` reject entry. This file
12
+ // no longer recognizes ANY curl flag at all — only its own small vocabulary
13
+ // — so there is no curl-syntax bypass class left to enumerate against.
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import {
17
+ buildCurlArgs,
18
+ CurlLocalArgvError,
19
+ CurlLocalError,
20
+ classifyRecords,
21
+ isLoopbackAddress,
22
+ parseRequestArgv,
23
+ resolveLoopbackIp,
24
+ } from './_curl-local.mjs';
25
+
26
+ describe('isLoopbackAddress', () => {
27
+ test('accepts IPv4 loopback (127.0.0.0/8, not just 127.0.0.1)', () => {
28
+ expect(isLoopbackAddress('127.0.0.1')).toBe(true);
29
+ expect(isLoopbackAddress('127.9.9.9')).toBe(true);
30
+ expect(isLoopbackAddress('127.255.255.255')).toBe(true);
31
+ });
32
+
33
+ test('accepts IPv6 ::1 and the IPv4-mapped loopback form', () => {
34
+ expect(isLoopbackAddress('::1')).toBe(true);
35
+ expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
36
+ });
37
+
38
+ test("rejects ordinary private-LAN addresses — this is STRICTER than fetch-asset's classifier", () => {
39
+ // fetch-asset's classifyAddress blocks these too (as "not a safe egress
40
+ // target"), but for a different reason (SSRF from an external URL). Here
41
+ // the bar is "is this literally my own machine" — a private-LAN IP is a
42
+ // real, different host, not loopback, and must still prompt.
43
+ expect(isLoopbackAddress('192.168.1.1')).toBe(false);
44
+ expect(isLoopbackAddress('10.0.0.1')).toBe(false);
45
+ expect(isLoopbackAddress('172.16.0.1')).toBe(false);
46
+ expect(isLoopbackAddress('169.254.169.254')).toBe(false); // cloud IMDS
47
+ });
48
+
49
+ test('rejects public addresses and link-local/multicast/reserved ranges', () => {
50
+ for (const ip of ['8.8.8.8', '1.1.1.1', '224.0.0.1', '240.0.0.1', 'fe80::1']) {
51
+ expect(isLoopbackAddress(ip)).toBe(false);
52
+ }
53
+ });
54
+
55
+ test('rejects a non-IP string', () => {
56
+ expect(isLoopbackAddress('not-an-ip')).toBe(false);
57
+ expect(isLoopbackAddress('localhost')).toBe(false); // a hostname, not a literal — resolved separately
58
+ });
59
+ });
60
+
61
+ describe('classifyRecords — the every-record check (multi-A-record DNS rebinding)', () => {
62
+ test('all-loopback records pass', () => {
63
+ expect(
64
+ classifyRecords('localhost', [{ address: '127.0.0.1' }, { address: '127.0.0.2' }])
65
+ ).toBeNull();
66
+ });
67
+
68
+ test('a single non-loopback record among several loopback ones still fails the whole host', () => {
69
+ const reason = classifyRecords('rebind.example', [
70
+ { address: '127.0.0.1' },
71
+ { address: '93.184.216.34' },
72
+ ]);
73
+ expect(reason).toContain('93.184.216.34');
74
+ expect(reason).toContain('non-loopback');
75
+ });
76
+
77
+ test('empty record set fails', () => {
78
+ expect(classifyRecords('empty.example', [])).toContain('no DNS records');
79
+ });
80
+ });
81
+
82
+ describe('resolveLoopbackIp', () => {
83
+ test('an IP-literal host skips DNS entirely — loopback passes, returns itself', async () => {
84
+ await expect(resolveLoopbackIp('127.0.0.1')).resolves.toBe('127.0.0.1');
85
+ });
86
+
87
+ test('an IP-literal host that is not loopback throws CurlLocalError(3)', async () => {
88
+ await expect(resolveLoopbackIp('8.8.8.8')).rejects.toThrow(CurlLocalError);
89
+ try {
90
+ await resolveLoopbackIp('8.8.8.8');
91
+ throw new Error('should have thrown');
92
+ } catch (err) {
93
+ expect(err.code).toBe(3);
94
+ }
95
+ });
96
+
97
+ test('a hostname resolving to all-loopback records (injected lookup) passes, returns the pin IP', async () => {
98
+ const lookupFn = async () => [
99
+ { address: '127.0.0.1', family: 4 },
100
+ { address: '::1', family: 6 },
101
+ ];
102
+ await expect(resolveLoopbackIp('localhost', { lookupFn })).resolves.toBe('127.0.0.1');
103
+ });
104
+
105
+ test('a hostname with ONE non-loopback record among several is rejected (DNS-rebinding defense)', async () => {
106
+ const lookupFn = async () => [
107
+ { address: '127.0.0.1', family: 4 },
108
+ { address: '203.0.113.5', family: 4 }, // TEST-NET-3 — a real "other" address
109
+ ];
110
+ await expect(resolveLoopbackIp('rebind.example', { lookupFn })).rejects.toThrow(CurlLocalError);
111
+ });
112
+
113
+ test('DNS resolution failure is a rejection, not a silent pass', async () => {
114
+ const lookupFn = async () => {
115
+ const err = new Error('getaddrinfo ENOTFOUND nope.invalid');
116
+ err.code = 'ENOTFOUND';
117
+ throw err;
118
+ };
119
+ await expect(resolveLoopbackIp('nope.invalid', { lookupFn })).rejects.toThrow(CurlLocalError);
120
+ });
121
+ });
122
+
123
+ describe('parseRequestArgv — the Maude-owned flag vocabulary (never raw curl syntax)', () => {
124
+ test('a bare URL parses as a GET with no headers/body', () => {
125
+ const req = parseRequestArgv(['http://localhost:3000/api']);
126
+ expect(req.url).toBe('http://localhost:3000/api');
127
+ expect(req.method).toBe('GET');
128
+ expect(req.headers).toEqual([]);
129
+ expect(req.data).toBeNull();
130
+ });
131
+
132
+ test('--method, repeated --header, and --data all parse', () => {
133
+ const req = parseRequestArgv([
134
+ 'http://localhost:3000/api',
135
+ '--method',
136
+ 'post',
137
+ '--header',
138
+ 'Content-Type: application/json',
139
+ '--header',
140
+ 'X-Test: 1',
141
+ '--data',
142
+ '{"a":1}',
143
+ ]);
144
+ expect(req.method).toBe('POST'); // uppercased
145
+ expect(req.headers).toEqual(['Content-Type: application/json', 'X-Test: 1']);
146
+ expect(req.data).toBe('{"a":1}');
147
+ });
148
+
149
+ test('boolean flags (--insecure/--include/--verbose) parse with no value consumed', () => {
150
+ const req = parseRequestArgv([
151
+ 'http://localhost:3000/',
152
+ '--insecure',
153
+ '--include',
154
+ '--verbose',
155
+ ]);
156
+ expect(req.insecure).toBe(true);
157
+ expect(req.include).toBe(true);
158
+ expect(req.verbose).toBe(true);
159
+ });
160
+
161
+ test('rejects an unrecognized method', () => {
162
+ expect(() => parseRequestArgv(['http://localhost:3000/', '--method', 'TRACE'])).toThrow(
163
+ CurlLocalArgvError
164
+ );
165
+ });
166
+
167
+ test('rejects a malformed header (must look like "Name: value")', () => {
168
+ expect(() => parseRequestArgv(['http://localhost:3000/', '--header', 'not-a-header'])).toThrow(
169
+ CurlLocalArgvError
170
+ );
171
+ });
172
+
173
+ test('rejects a non-positive --max-time', () => {
174
+ expect(() => parseRequestArgv(['http://localhost:3000/', '--max-time', '0'])).toThrow(
175
+ CurlLocalArgvError
176
+ );
177
+ expect(() => parseRequestArgv(['http://localhost:3000/', '--max-time', 'nope'])).toThrow(
178
+ CurlLocalArgvError
179
+ );
180
+ });
181
+
182
+ test('rejects a non-http(s) URL scheme', () => {
183
+ expect(() => parseRequestArgv(['file:///etc/passwd'])).toThrow(CurlLocalArgvError);
184
+ });
185
+
186
+ test('rejects a value-taking flag with no value', () => {
187
+ expect(() => parseRequestArgv(['http://localhost:3000/', '--header'])).toThrow(
188
+ CurlLocalArgvError
189
+ );
190
+ });
191
+
192
+ test('rejects more than one positional argument', () => {
193
+ expect(() => parseRequestArgv(['http://localhost:3000/', 'http://localhost:4000/'])).toThrow(
194
+ CurlLocalArgvError
195
+ );
196
+ });
197
+
198
+ test('rejects when no URL is given at all', () => {
199
+ expect(() => parseRequestArgv(['--method', 'GET'])).toThrow(CurlLocalArgvError);
200
+ });
201
+
202
+ // ── Round-2 security addendum: these are exactly the argv forms that
203
+ // bypassed the PREVIOUS raw-curl-argv allowlist. None of them are
204
+ // "rejected flags" here — they're simply not part of this file's flag
205
+ // vocabulary at all, so they fall through to the generic
206
+ // "unrecognized flag" / "unexpected extra argument" path.
207
+ test('rejects the concatenated -K<path> config-file-smuggling PoC verbatim (round-2 finding #1)', () => {
208
+ expect(() => parseRequestArgv(['http://127.0.0.1:1/', '-Kevil.conf'])).toThrow(
209
+ CurlLocalArgvError
210
+ );
211
+ });
212
+
213
+ test('rejects a caller-supplied --noproxy override verbatim (round-2 finding #2)', () => {
214
+ expect(() => parseRequestArgv(['http://127.0.0.1:1/', '--noproxy', ''])).toThrow(
215
+ CurlLocalArgvError
216
+ );
217
+ });
218
+
219
+ test('rejects the concatenated -x<url>/-T<path> short forms (round-2 finding #3)', () => {
220
+ expect(() => parseRequestArgv(['-xhttp://evil.example', 'http://127.0.0.1:1/'])).toThrow(
221
+ CurlLocalArgvError
222
+ );
223
+ expect(() => parseRequestArgv(['http://127.0.0.1:1/', '-T/etc/passwd'])).toThrow(
224
+ CurlLocalArgvError
225
+ );
226
+ });
227
+
228
+ test('rejects the original --resolve/--connect-to target-override PoC verbatim', () => {
229
+ expect(() =>
230
+ parseRequestArgv(['--resolve', 'localhost:80:203.0.113.5', 'http://localhost/'])
231
+ ).toThrow(CurlLocalArgvError);
232
+ });
233
+
234
+ test('a value containing curl-flag-shaped text is inert data, not a flag — no injection via --data', () => {
235
+ // "@/etc/passwd" as a --data value must never be treated as curl's
236
+ // "read a file" shorthand (that's -d/--data's behavior, not
237
+ // --data-raw's, which buildCurlArgs always uses — see the live
238
+ // end-to-end test below for the full round-trip proof).
239
+ const req = parseRequestArgv([
240
+ 'http://localhost:3000/',
241
+ '--data',
242
+ '@/etc/passwd; -K evil.conf',
243
+ ]);
244
+ expect(req.data).toBe('@/etc/passwd; -K evil.conf');
245
+ });
246
+ });
247
+
248
+ describe("buildCurlArgs — every flag name is one of THIS file's own hardcoded literals", () => {
249
+ test('a bare GET pins the connection and forces the safety flags', () => {
250
+ const req = parseRequestArgv(['http://localhost:3000/']);
251
+ const args = buildCurlArgs(req, '127.0.0.1', 3000);
252
+ expect(args).toContain('--resolve');
253
+ expect(args[args.indexOf('--resolve') + 1]).toBe('localhost:3000:127.0.0.1');
254
+ expect(args).toContain('--noproxy');
255
+ expect(args).toContain('-q'); // curlrc disabled
256
+ expect(args).toEqual(expect.arrayContaining(['--max-redirs', '0']));
257
+ expect(args.at(-1)).toBe('http://localhost:3000/'); // the URL, always last, after `--`
258
+ expect(args.at(-2)).toBe('--');
259
+ });
260
+
261
+ test('POST + headers + data map to -X/-H/--data-raw (never -d, which treats a leading @ as a file path)', () => {
262
+ const req = parseRequestArgv([
263
+ 'http://localhost:3000/api',
264
+ '--method',
265
+ 'POST',
266
+ '--header',
267
+ 'Content-Type: application/json',
268
+ '--data',
269
+ '@/etc/passwd',
270
+ ]);
271
+ const args = buildCurlArgs(req, '127.0.0.1', 3000);
272
+ expect(args).toContain('-X');
273
+ expect(args[args.indexOf('-X') + 1]).toBe('POST');
274
+ expect(args).toContain('-H');
275
+ expect(args[args.indexOf('-H') + 1]).toBe('Content-Type: application/json');
276
+ expect(args).toContain('--data-raw');
277
+ expect(args).not.toContain('-d');
278
+ expect(args[args.indexOf('--data-raw') + 1]).toBe('@/etc/passwd');
279
+ });
280
+ });
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # agent-browser-safe.sh — hardened agent-browser wrapper. Thin shim over
3
+ # _agent-browser-safe.mjs; reached via `maude design agent-browser-safe`
4
+ # (DDR-062), never a raw bin path. See _agent-browser-safe.mjs for the full
5
+ # security rationale (DDR-185 security addendum).
6
+ #
7
+ # Usage:
8
+ # agent-browser-safe.sh <subcommand> [args...]
9
+ #
10
+ # Allowed subcommands: open, eval, screenshot, snapshot, get, wait, close.
11
+ # Forces --allowed-domains localhost,127.0.0.1 and a non-persistent profile.
12
+ # Exit: agent-browser's own exit code · 2 usage/rejected argv · 1 other.
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+
15
+ if ! command -v agent-browser >/dev/null 2>&1; then
16
+ echo "agent-browser-safe.sh: agent-browser is required." >&2
17
+ exit 1
18
+ fi
19
+
20
+ # Prefer node (always present with a maude install); fall back to bun in a dev
21
+ # tree that has bun but a shimmed node. The module is pure Node ESM — no .ts.
22
+ if command -v node >/dev/null 2>&1; then
23
+ exec node "$SCRIPT_DIR/_agent-browser-safe.mjs" "$@"
24
+ elif command -v bun >/dev/null 2>&1; then
25
+ exec bun run "$SCRIPT_DIR/_agent-browser-safe.mjs" "$@"
26
+ else
27
+ echo "agent-browser-safe.sh: node (or bun) is required." >&2
28
+ exit 1
29
+ fi