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
This commit is contained in:
Danila Poyarkov 2026-10-06 14:52:12 +00:00 committed by GitHub
parent 6324f8e396
commit 22d5dfe6b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 217 additions and 35 deletions

View file

@ -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.

View file

@ -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-<name>` entry with fixed `/c <name> …` 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.

View file

@ -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",

View file

@ -65,6 +65,7 @@
}
},
"plugins": {
"fs": { "requireLiteralLeadingDot": true },
"deep-link": {
"desktop": { "schemes": ["openpencil"] }
},

View file

@ -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()

View file

@ -60,7 +60,7 @@ function toggleFill(index: number) {
<PropertySectionActions class="text-[var(--vp-c-text-2)]">⌘ L</PropertySectionActions>
</PropertySectionHeader>
<PropertySectionContent class="border-t border-[var(--vp-c-divider)] px-3 py-2">
Collapsible content
Section content
</PropertySectionContent>
</PropertySectionRoot>

View file

@ -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/<Variant>.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.

View file

@ -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<HTMLElement>('[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')
}
}

View file

@ -155,7 +155,7 @@ const states: Array<{
Layer Tree states
</div>
<div :class="adjacent ? 'space-y-0' : 'space-y-1'">
<div v-for="state in states" :key="state.label" :aria-label="state.label">
<div v-for="state in states" :key="state.label">
<LayerTreeNodeRow
:node="state.node"
:level="1"

View file

@ -107,7 +107,7 @@ export const Interaction: Story = {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'New fixture' }))
await userEvent.type(
canvas.getByRole('textbox', { name: 'Describe a change' }),
canvas.getByRole('textbox', { name: /^Describe a change/ }),
'Make a dashboard'
)
await userEvent.click(canvas.getByRole('button', { name: 'Send message' }))

View file

@ -6,6 +6,8 @@ import type { MCPFailure, MCPFailureCode } from '@/app/automation/mcp/failure'
import MCPFailureAlert from './MCPFailureAlert.vue'
/** One representative failure per reason, mirroring what each path records. */
const TIMEOUT_DETAIL = 'no health response from http://127.0.0.1:7600/health'
const reasons: Record<MCPFailureCode, MCPFailure> = {
'not-installed': {
code: 'not-installed',
@ -19,7 +21,7 @@ const reasons: Record<MCPFailureCode, MCPFailure> = {
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()
}
}

View file

@ -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 = {
</template>
<PanelGrid :columns="2">
<PanelFieldGroup label="Width">
<AppInput v-model="width" tone="panel" data-story-control data-state="idle" aria-label="Width" />
<AppInput v-model="width" tone="panel" size="xs" data-story-control data-state="idle" aria-label="Width" />
</PanelFieldGroup>
<PanelFieldGroup label="Height">
<AppInput v-model="height" tone="panel" data-story-control data-state="focus" aria-label="Height" />
<AppInput v-model="height" tone="panel" size="xs" data-story-control data-state="focus" aria-label="Height" />
</PanelFieldGroup>
<template #actions>
<IconButton label="Constrain proportions" size="md"><LinkIcon class="size-3.5" /></IconButton>
@ -142,7 +142,7 @@ export const StateMatrix: Story = {
<AppSelect v-model="blendMode" :options="blendModes" data-story-control aria-label="Blend mode" />
</PanelFieldGroup>
<PanelFieldGroup label="Opacity">
<AppInput v-model="mixed" tone="panel" state="mixed" readonly data-story-control aria-label="Mixed opacity" />
<AppInput v-model="mixed" tone="panel" size="xs" state="mixed" readonly data-story-control aria-label="Mixed opacity" />
</PanelFieldGroup>
<template #actions>
<IconButton label="Toggle visibility"><EyeIcon class="size-3.5" /></IconButton>
@ -153,10 +153,10 @@ export const StateMatrix: Story = {
<PanelSection label="States">
<div class="grid grid-cols-2 gap-1.5">
<PanelFieldGroup label="Bound">
<AppInput v-model="bound" tone="panel" state="bound" readonly data-story-control aria-label="Bound value" />
<AppInput v-model="bound" tone="panel" size="xs" state="bound" readonly data-story-control aria-label="Bound value" />
</PanelFieldGroup>
<PanelFieldGroup label="Disabled">
<AppInput v-model="disabled" tone="panel" disabled data-story-control aria-label="Disabled value" />
<AppInput v-model="disabled" tone="panel" size="xs" disabled data-story-control aria-label="Disabled value" />
</PanelFieldGroup>
<PanelFieldGroup label="Alignment" class="col-span-2">
<SegmentedControl v-model="alignment" class="w-full" :options="alignmentOptions" label="Alignment" data-story-control />
@ -171,7 +171,7 @@ export const StateMatrix: Story = {
const canvas = within(canvasElement)
const controls = Array.from(canvasElement.querySelectorAll<HTMLElement>('[data-story-control]'))
for (const control of controls) await expect(control).toHaveStyle({ height: '26px' })
for (const control of controls) await expect(control).toHaveStyle({ height: '24px' })
await userEvent.click(canvas.getByLabelText('Height'))
await userEvent.hover(canvas.getByLabelText('Width'))

View file

@ -1,7 +1,7 @@
const segmentedControlTheme = {
slots: {
root: 'inline-flex items-center gap-0.5 rounded bg-panel-field p-0.5 hover:bg-panel-field-hover',
item: 'flex h-[22px] min-w-max flex-auto cursor-pointer items-center justify-center gap-1 rounded-sm text-muted outline-none hover:bg-hover hover:text-surface focus-visible:ring-1 focus-visible:ring-panel-focus data-[state=on]:bg-hover data-[state=on]:text-surface data-[state=on]:hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50'
item: 'flex h-5 min-w-max flex-auto cursor-pointer items-center justify-center gap-1 rounded-sm text-muted outline-none hover:bg-hover hover:text-surface focus-visible:ring-1 focus-visible:ring-panel-focus data-[state=on]:bg-hover data-[state=on]:text-surface data-[state=on]:hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50'
},
variants: {
size: {

View file

@ -0,0 +1,48 @@
import { strict as assert } from 'node:assert'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { invokeNative, writeNativeFile } from '#tests/helpers/tauri/invoke'
const NAME = 'openpencil-native-test-scope.txt'
const DISCOVERY = join(homedir(), 'Library', 'Application Support', 'OpenPencil', 'mcp.json')
describe('desktop file scope', () => {
// The protected paths below are macOS ones; other platforms are not claimed.
before(function () {
if (process.platform !== 'darwin') this.skip()
})
it('saves documents but not where a written file would run', async () => {
const documents = mkdtempSync(join(tmpdir(), 'openpencil-scope-'))
const refused = [
join(homedir(), 'Library', 'LaunchAgents', NAME),
join(homedir(), `.${NAME}`),
join(homedir(), '.openpencil', 'mcp.json')
]
// Only files this test would have created are removed afterwards.
const created = refused.filter((path) => !existsSync(path))
try {
assert.equal(await writeNativeFile(join(documents, 'design.fig'), 'design'), null)
for (const path of refused) {
const error = await writeNativeFile(path, 'not allowed')
assert.match(error ?? '', /forbidden|not allowed/i, `${path} was writable`)
}
} finally {
rmSync(documents, { recursive: true, force: true })
for (const path of created) if (existsSync(path)) rmSync(path)
}
})
it('refuses to open the MCP discovery file for writing', async () => {
// The scope is checked before the file is opened, so a refusal does not depend on the file
// existing; opening without truncating changes nothing even if the scope let it through.
const opened = await invokeNative<number>('plugin:fs|open', {
path: DISCOVERY,
options: { write: true }
}).then((rid) => rid, String)
if (typeof opened === 'number') await invokeNative('plugin:resources|close', { rid: opened })
assert.match(String(opened), /forbidden|not allowed/i, `${DISCOVERY} opened for writing`)
})
})

View file

@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test'
import * as v from 'valibot'
const StoryIndex = v.object({
entries: v.record(v.string(), v.object({ id: v.string(), type: v.string() }))
})
/** How long one story may take to render and run its play function. */
const STORY_TIMEOUT_MS = 20_000
// Storybook's index exists only once its server runs, after Playwright has collected tests,
// so one test walks every story and reports each failure separately.
test('every story renders and passes its play function', async ({ page, request }) => {
const text = await (await request.get('/index.json')).text()
const index = v.parse(v.pipe(v.string(), v.parseJson(), StoryIndex), text)
const stories = Object.values(index.entries).filter((entry) => entry.type === 'story')
test.setTimeout(stories.length * STORY_TIMEOUT_MS)
expect(stories.length).toBeGreaterThan(0)
for (const story of stories) {
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`)
const outcome = await page.evaluate(
(timeout) =>
new Promise<string>((resolve) => {
const channel: unknown = Reflect.get(window, '__STORYBOOK_ADDONS_CHANNEL__')
if (typeof channel !== 'object' || channel === null || !('on' in channel)) {
resolve('Storybook channel is missing')
return
}
const on = channel.on as (event: string, listener: (detail?: unknown) => void) => void
// Exceptions name what failed. storyFinished comes last, after afterEach, and its
// reporters say whether anything else failed; accessibility reports are left to axe.
const failures: string[] = []
const record = (kind: string) => (detail?: unknown) => {
const message =
detail instanceof Object && 'message' in detail ? detail.message : detail
failures.push(`${kind}: ${String(message)}`)
}
on.call(channel, 'playFunctionThrewException', record('play function'))
on.call(channel, 'storyThrewException', record('render'))
on.call(channel, 'storyErrored', record('story'))
on.call(channel, 'storyFinished', (detail?: unknown) => {
const reporters =
detail instanceof Object && 'reporters' in detail && Array.isArray(detail.reporters)
? detail.reporters
: []
for (const report of reporters) {
const { type, status } = report as { type?: unknown; status?: unknown }
if (type !== 'a11y' && status === 'failed')
failures.push(`${String(type)} report failed`)
}
resolve(failures[0] ?? 'ok')
})
setTimeout(() => resolve('timed out'), timeout)
}),
STORY_TIMEOUT_MS
)
expect.soft(outcome, story.id).toBe('ok')
}
})

View file

@ -31,3 +31,23 @@ export async function invokeNativeBytes(
args
)
}
/** Writes a file through the fs plugin the way `writeFile` does, and returns its error if any. */
export async function writeNativeFile(path: string, text: string): Promise<string | null> {
return browser.execute(
async (path, text) => {
const core = window.__TAURI__?.core
if (!core) throw new Error('Native test requires app.withGlobalTauri')
try {
await core.invoke('plugin:fs|write_file', new TextEncoder().encode(text), {
headers: { path: encodeURIComponent(path), options: JSON.stringify({}) }
})
return null
} catch (error) {
return String(error)
}
},
path,
text
)
}