From 22d5dfe6b5caae2d052ef24c1f351cac6231a458 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 6 Oct 2026 14:52:12 +0000 Subject: [PATCH] fix: refuse risky desktop writes and run every Storybook play function (#933) * fix(desktop): refuse writes where a written file would run The fs scope let the webview write, create folders, and delete anywhere. Documents may still be saved anywhere, but the global scope now denies login items and startup folders, PowerShell profiles, and the global package and executable folders where coding agents and OpenPencil's companions live, and writes to the MCP discovery files agents trust. requireLiteralLeadingDot keeps hidden files and folders, such as shell profiles and agent settings, out of ** on Windows as Tauri already does on macOS and Linux. A native test saves a document and is refused a LaunchAgents file, a home dotfile, and the discovery file. * fix(ui): draw segmented controls at panel field height Panel fields moved to 24px when sizing tokens became plain utilities, but segmented control items stayed 22px inside a 2px padding, so the Typography and resizing controls stood 2px taller than the fields beside them. The panel foundation story renders its inputs at the panel size and checks 24px. * test(storybook): run every story and its play function No test ran the play functions, and five had gone stale: the layer tree example labelled a wrapper with the same name as its row, the chat composer's label gained an ellipsis, the MCP failure story queried a test id attribute the app does not use, and the property primitives story still collapsed sections whose titles are static now. bun run test:storybook now renders every story and fails on a story or play function that throws. * fix(desktop): deny protected folders themselves and writable opens of the discovery files Each protected folder is denied alongside its contents, so a recursive remove cannot target the folder itself, and the MCP discovery files are denied to open as well as write, since opening with truncate would empty them. The native test opens the discovery file for writing without truncating, and removes only files it created. The story test waits for storyFinished, which follows afterEach, and judges exceptions and non-accessibility reports. * test(desktop): run the file scope check only on macOS Its protected paths are macOS ones, so other platforms skip it rather than pass for another reason. * docs: note that documents opened from hidden folders can still be saved --- CHANGELOG.md | 2 + desktop/AGENTS.md | 1 + desktop/capabilities/default.json | 60 ++++++++++++++++++- desktop/tauri.conf.json | 1 + .../PropertyPrimitives.stories.ts | 7 +-- .../PropertySection/examples/States.vue | 2 +- src/AGENTS.md | 1 + src/components/LayerTree/LayerTree.stories.ts | 22 +++---- src/components/LayerTree/examples/States.vue | 2 +- src/components/chat/ChatPlayground.stories.ts | 2 +- .../settings/mcp/MCPFailureAlert.stories.ts | 8 ++- .../ui/panel/PanelFoundation.stories.ts | 14 ++--- src/theme/select/segmented-control.ts | 2 +- tests/e2e/native/fs-scope.spec.ts | 48 +++++++++++++++ tests/e2e/storybook/stories.spec.ts | 60 +++++++++++++++++++ tests/helpers/tauri/invoke.ts | 20 +++++++ 16 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 tests/e2e/native/fs-scope.spec.ts create mode 100644 tests/e2e/storybook/stories.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce22430b3..fbcd9ab79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ ### Fixed +- Draw segmented controls in the properties panel at the height of the fields beside them. - Keep saving AI chat history in Safari Private Browsing after a message with an image or a reply that changed the document. Safari cannot store image data that way in a private window, so the conversation stopped saving from that point and showed "Chat history could not be saved". - `figma.combineAsVariants` derives variant properties from components named as Figma names variants, such as `State=On, Size=Large`, as Combine as variants in the editor now does too; before, only slash-separated names gave the set any properties. - HTML and Tailwind export place layers of frames without auto layout at their coordinates instead of stacking them, leave the size of hugging auto layout frames and auto-sizing text to their content, and round ellipses. @@ -181,6 +182,7 @@ ### Security +- Refuse writes from the desktop app to places where a written file would run: login items and startup folders, PowerShell profiles, global package and executable folders such as Homebrew, `/usr/local`, npm, Volta, and Scoop, and the MCP discovery files coding agents trust. On Windows, hidden files and folders such as shell profiles and agent settings are now off-limits too, as on macOS and Linux, except a document you open there yourself, which can still be saved. - Limit the programs the desktop app may start to the exact command lines of the supported coding agents, the MCP server, and the Harness companion. On Windows the app could run any command through `cmd /c`, so any code running in the editor's webview could start arbitrary programs. - Update the desktop app to Tauri 2.12, which binds large IPC channel responses to the webview that requested them instead of letting another webview fetch them (GHSA-w28w-mhc8-qvjv). - Install a desktop update only when its signature names the version the update server announces, so a tampered update manifest cannot pair a newer version number with an older signed build. diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 8bacb5d2c..afab90f78 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -6,6 +6,7 @@ Check `desktop/Cargo.toml`, `desktop/capabilities/**`, and `desktop/tauri.conf.j - Dev tools: add or use a menu item to toggle them; do not rely on keyboard shortcuts. - `desktop/src/credentials.rs` stores secrets in the native system credential store; failures must surface, never fall back to browser or plaintext storage (`src/AGENTS.md`, Settings). - ACP and harness process changes require checking `desktop/capabilities/**`. +- The fs scope allows documents anywhere but denies, in the global `fs:scope`, every place where a written file would run, and `requireLiteralLeadingDot` keeps hidden files out of `**` on every platform; a new write path must not reopen either (`tests/e2e/native/fs-scope.spec.ts`). - A `shell:allow-spawn` entry pins the whole command line: no `"args": true`, and a Windows `.cmd` shim runs through its own `cmd-` entry with fixed `/c …` arguments, which `resolvePlatformCommand` selects (`tests/engine/tauri/command.test.ts`). - `desktop/generated/menu.json` is produced by `bun run generate:tauri-menu` from `src/app/shell/menu/schema.ts`; do not edit or import it directly. - Run `bun run generate:icons --target desktop` before direct Cargo checks; native icons are generated, not committed. diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 234640066..fe39ba63f 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -15,6 +15,42 @@ "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-html", "clipboard-manager:allow-write-text", + "fs:deny-default", + { + "identifier": "fs:scope", + "deny": [ + { "path": "$HOME/Library/LaunchAgents" }, + { "path": "$HOME/Library/LaunchAgents/**" }, + { "path": "$DATA/Microsoft/Windows/Start Menu/Programs/Startup" }, + { "path": "$DATA/Microsoft/Windows/Start Menu/Programs/Startup/**" }, + { "path": "$DOCUMENT/PowerShell" }, + { "path": "$DOCUMENT/PowerShell/**" }, + { "path": "$DOCUMENT/WindowsPowerShell" }, + { "path": "$DOCUMENT/WindowsPowerShell/**" }, + { "path": "/usr/local" }, + { "path": "/usr/local/**" }, + { "path": "/opt/homebrew" }, + { "path": "/opt/homebrew/**" }, + { "path": "$HOME/bin" }, + { "path": "$HOME/bin/**" }, + { "path": "$HOME/n" }, + { "path": "$HOME/n/**" }, + { "path": "$DATA/npm" }, + { "path": "$DATA/npm/**" }, + { "path": "$DATA/nvm" }, + { "path": "$DATA/nvm/**" }, + { "path": "$DATA/fnm" }, + { "path": "$DATA/fnm/**" }, + { "path": "$LOCALDATA/Volta" }, + { "path": "$LOCALDATA/Volta/**" }, + { "path": "$LOCALDATA/Programs" }, + { "path": "$LOCALDATA/Programs/**" }, + { "path": "$LOCALDATA/Microsoft/WindowsApps" }, + { "path": "$LOCALDATA/Microsoft/WindowsApps/**" }, + { "path": "$HOME/scoop" }, + { "path": "$HOME/scoop/**" } + ] + }, { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] @@ -25,7 +61,13 @@ }, { "identifier": "fs:allow-open", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, "fs:allow-fstat", "fs:allow-seek", @@ -41,7 +83,13 @@ }, { "identifier": "fs:allow-write-file", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, { "identifier": "fs:allow-exists", @@ -58,7 +106,13 @@ }, { "identifier": "fs:allow-remove", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, { "identifier": "fs:allow-watch", diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 044b96e23..c2bc23c9a 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -65,6 +65,7 @@ } }, "plugins": { + "fs": { "requireLiteralLeadingDot": true }, "deep-link": { "desktop": { "schemes": ["openpencil"] } }, diff --git a/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts index f7adf3939..73d796349 100644 --- a/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts +++ b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts @@ -24,10 +24,9 @@ export const StateMatrix: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - const layer = canvas.getByRole('button', { name: 'Layer' }) - await expect(canvas.getByText('Collapsible content')).toBeVisible() - await userEvent.click(layer) - await expect(layer).toHaveAttribute('data-state', 'closed') + // Section titles are static, as in Figma: clicking one keeps the section open. + await userEvent.click(canvas.getByText('Layer', { exact: true })) + await expect(canvas.getByText('Section content')).toBeVisible() await userEvent.click(canvas.getByRole('button', { name: 'Add first effect' })) await expect(canvas.getByText('Drop shadow')).toBeVisible() diff --git a/packages/vue/src/primitives/PropertySection/examples/States.vue b/packages/vue/src/primitives/PropertySection/examples/States.vue index 2614dea50..c83f62660 100644 --- a/packages/vue/src/primitives/PropertySection/examples/States.vue +++ b/packages/vue/src/primitives/PropertySection/examples/States.vue @@ -60,7 +60,7 @@ function toggleFill(index: number) { ⌘ L - Collapsible content + Section content diff --git a/src/AGENTS.md b/src/AGENTS.md index 95613adaf..7fe7906e5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -82,4 +82,5 @@ The supported browser baseline lives in `src/app/shell/support/baseline.ts` and ### Storybook - Colocate `ComponentName.stories.ts` with `ComponentName.vue`; multipart compositions may use a descriptive family name. Preserve explicit titles and exported story names during moves. Default playgrounds stay static; interaction flows get named stories. Prefer inline story fixtures for small app-local states; use colocated `examples/.vue` SFCs for substantial templates or fixtures that need SFC template/slot typing, including app-only fixtures (shared SDK examples follow `packages/vue/AGENTS.md`, Documentation). Story templates compile at runtime, so they must be plain JavaScript with no TypeScript syntax, and `icon-lucide-*` tags do not resolve there; import icons from `~icons/...` and register them. +- Every story must render and pass its play function: `tests/e2e/storybook/stories.spec.ts` runs them all in `bun run test:storybook`, so a play function asserts behavior that stays true, not copy or obsolete markup. - Isolated visual states of feedback components belong in stories, not Playwright application screenshots. Do not add automated tests or snapshot baselines for CSS-only changes (spacing, sizing, colors, breakpoints); verify those visually. Settings E2E covers integration behavior: feedback appearance, validation and focus, retained drafts, successful retries. diff --git a/src/components/LayerTree/LayerTree.stories.ts b/src/components/LayerTree/LayerTree.stories.ts index 65fbd5b88..7d05a8ae7 100644 --- a/src/components/LayerTree/LayerTree.stories.ts +++ b/src/components/LayerTree/LayerTree.stories.ts @@ -33,19 +33,13 @@ export const AdjacentRows: Story = { args: { adjacent: true } } export const StateMatrix: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - await expect(canvas.getByLabelText('Selected focused').firstElementChild).toHaveAttribute( - 'data-focused' - ) - await expect(canvas.getByLabelText('Selected unfocused').firstElementChild).toHaveAttribute( - 'data-selected' - ) - await expect(canvas.getByLabelText('Hidden').firstElementChild).toHaveAttribute('data-hidden') - await expect(canvas.getByLabelText('Dragging').firstElementChild).toHaveAttribute( - 'data-dragging' - ) - await expect(canvas.getByLabelText('Child drop').firstElementChild).toHaveAttribute( - 'data-drop-position', - 'child' - ) + // Each row's disclosure is named after its layer. + const row = (name: string) => + canvas.getByRole('button', { name }).closest('[data-slot="row"]') + await expect(row('Selected focused')).toHaveAttribute('data-focused') + await expect(row('Selected unfocused')).toHaveAttribute('data-selected') + await expect(row('Hidden')).toHaveAttribute('data-hidden') + await expect(row('Dragging')).toHaveAttribute('data-dragging') + await expect(row('Child drop')).toHaveAttribute('data-drop-position', 'child') } } diff --git a/src/components/LayerTree/examples/States.vue b/src/components/LayerTree/examples/States.vue index 43f4b0538..e5b28e317 100644 --- a/src/components/LayerTree/examples/States.vue +++ b/src/components/LayerTree/examples/States.vue @@ -155,7 +155,7 @@ const states: Array<{ Layer Tree states
-
+
= { 'not-installed': { code: 'not-installed', @@ -19,7 +21,7 @@ const reasons: Record = { code: 'exited', detail: 'Error: listen EADDRINUSE: address already in use 127.0.0.1:7600' }, - timeout: { code: 'timeout', detail: 'no health response from http://127.0.0.1:7600/health' }, + timeout: { code: 'timeout', detail: TIMEOUT_DETAIL }, rejected: { code: 'rejected', detail: 'HTTP 401' }, malformed: { code: 'malformed', detail: 'HTTP 200' }, unreachable: { code: 'unreachable', detail: 'http://127.0.0.1:7600/mcp' }, @@ -70,10 +72,10 @@ export const DetailsExpandOnDemand: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) // Collapsed payloads stay unmounted, so the alert announces only its summary. - await expect(canvas.queryByTestId('settings-mcp-failure-detail')).toBeNull() + await expect(canvas.queryByText(TIMEOUT_DETAIL)).toBeNull() const trigger = canvas.getByRole('button', { name: 'Details' }) await expect(trigger).toHaveAttribute('aria-expanded', 'false') await userEvent.click(trigger) - await expect(canvas.getByTestId('settings-mcp-failure-detail')).toBeVisible() + await expect(canvas.getByText(TIMEOUT_DETAIL)).toBeVisible() } } diff --git a/src/components/ui/panel/PanelFoundation.stories.ts b/src/components/ui/panel/PanelFoundation.stories.ts index 69c7d963f..632671189 100644 --- a/src/components/ui/panel/PanelFoundation.stories.ts +++ b/src/components/ui/panel/PanelFoundation.stories.ts @@ -27,7 +27,7 @@ const meta = { docs: { description: { component: - 'The 26px properties-panel foundation: strict grids, field groups, action rails, and semantic field states.' + 'The 24px properties-panel foundation: strict grids, field groups, action rails, and semantic field states.' } } } @@ -125,10 +125,10 @@ export const StateMatrix: Story = { - + - +