diff --git a/README.md b/README.md index 08cfa6d4..3f28f5c2 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,12 @@ Register the MCP server with Claude Code: claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp ``` +To keep an agent to one project's files, give it folders to read and write (the same flags as PhotoCraft): + +```sh +claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project +``` + The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md). For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md). diff --git a/ROADMAP.md b/ROADMAP.md index c54ae511..2af00977 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777), area type, threaded type and type on a path made whole from Illustrator files' text documents (part of #637), the missing-fonts dialog with Find Fonts and a folder search (#792), Effect › Stylize › Glowing Edges (#765), Offset Path's Joins and Zig Zag's Points as dropdowns and Relative/Absolute as radio buttons in the effect dialogs (#828), the control channel's `app.quit` reporting a pending save prompt (#830), blurred type that stays put while the view moves (#755), Windows starting on Direct3D 12 and loading no Vulkan driver unless asked (#806), the Artboard tool naming its artboard once (#824), Save for Web taking typed pixel sizes (#839), rulers ticked over their whole length in a rotated view (#826), the gradient stop popover opening beside the art (#862) and recent colours recolouring the selected stop (#835), every align button following Align To (#789), the Properties panel's Paragraph section with all seven alignments and Hyphenate (#775), isolation mode dimming and locking the art around the isolated group (#833), color books (`.acb`) opening as swatch libraries (#831), files that can't be opened reported in a dialog (#861), `run` and `command_batch` steps using earlier steps' results (#785), ruler guides pulling drawn points with Smart Guides on and Smart Guides labelling centres and anchors on hover (#812), shadows and glows keeping their blend modes in PDF (#827) and `.ai` import skipping layer checks that can't succeed (#758), the Curvature tool editing any selected path (#798), raster exports composited in floating point so translucent edges over opaque art stay opaque (#787), Calligraphic and Bristle Brush Options with pen-pressure Calligraphic brushes (part of #852), a polygon's Polygon Properties in the Properties and Transform panels, Flare Tool Options that last and more tools opening their options on a double-click (part of #812), MCP file access confined with --automation-read-root / --automation-write-root (#832); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -43,7 +43,7 @@ since 2026-10-01. | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | -| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel | +| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | | **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | ### Where we're lacking (in priority order) @@ -97,7 +97,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Automation:** - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - JSON-lines control channel with real egui pointer and keyboard injection. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. + - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785). - Actions panel that records and plays back commands. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 67941209..1cf07964 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! `vectorcraft-cli`: VectorCraft from the command line. //! //! ```text -//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] +//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR] //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands //! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy] @@ -14,6 +14,7 @@ #![deny(unsafe_code)] use std::io::Write; +use std::path::Path; use std::process::ExitCode; /// `println!` / `print!` that end the program quietly when stdout is closed @@ -50,6 +51,7 @@ mod prefs_dir; mod system_fonts; use serde_json::{Value, json}; +use vectorcraft_engine::file_access::{self, AutomationRoots}; use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; /// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as @@ -66,9 +68,13 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: - vectorcraft-cli mcp [--connect ADDR | --headless] + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. + --automation-read-root / --automation-write-root confine the files the agent's commands read + (open, place, relink, library loads…) and write (save, export, package, library saves…) to + DIR, links followed; a root left out grants none of its access. They imply --headless: a + running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] Headless batch: open FILE (any readable format) or start a new document, run commands in @@ -145,18 +151,42 @@ fn main() -> ExitCode { fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; + let (mut read_root, mut write_root): (Option, Option) = (None, None); let mut it = args.iter(); while let Some(a) = it.next() { - match a.as_str() { + // The roots also come as `--flag=DIR`, as the other Craft apps take them. + let (flag, inline) = match a.split_once('=') { + Some((f, v)) if f.starts_with("--automation-") => (f, Some(v.to_string())), + _ => (a.as_str(), None), + }; + match flag { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?), "--headless" => headless = true, + "--automation-read-root" | "--automation-write-root" => { + let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?; + let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root }; + if slot.replace(dir).is_some() { + return Err(format!("{flag} is given twice: it takes one folder")); + } + } other => return Err(format!("unknown mcp option `{other}`")), } } if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } - let backend: Box = if headless { + let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control --automation-read-root --automation-write-root `" + .into()); + } + let backend: Box = if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + Box::new(Headless::with_document().with_automation_roots(Some(roots))) + } else if headless { Box::new(Headless::with_document()) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 4aac0e29..d5bb2578 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -83,6 +83,58 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } +/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the +/// server starts, and the process then reads and writes only inside its roots. +#[test] +fn mcp_automation_roots() { + let base = tmp("roots"); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let svg = r#""#; + std::fs::write(base.join("in/a.svg"), svg).unwrap(); + std::fs::write(base.join("outside/b.svg"), svg).unwrap(); + let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); + let fails = |args: &[&str], why: &str| { + let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains(why), "{args:?}: {err}"); + }; + fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root"); + fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice"); + fails(&["mcp", "--automation-write-root"], "needs a folder"); + fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots"); + + let mut child = Command::new(BIN) + .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}}); + let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned(); + { + let mut stdin = child.stdin.take().unwrap(); + for m in [ + call(1, "open_file", json!({"path": file("in", "a.svg")})), + call(2, "open_file", json!({"path": file("outside", "b.svg")})), + call(3, "export", json!({"path": file("out", "a.png")})), + call(4, "export", json!({"path": file("in", "a.png")})), + call(5, "save_file", json!({"path": file("outside", "a.vectorcraft")})), + ] { + writeln!(stdin, "{m}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect(); + assert_eq!(errors, [false, true, false, true, true], "{replies:?}"); + assert!(replies[1]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root")); + assert!(base.join("out/a.png").is_file() && !base.join("in/a.png").exists() && !base.join("outside/a.vectorcraft").exists()); +} + /// End-to-end protocol sweep over stdio against the compiled binary. The in-process /// tests drive `Server::handle_line` directly, so the binary's own wiring (argument /// parsing, logger install, stdout pollution, notification order) is untested: this diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 92ab9d88..167827ea 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,13 +1,17 @@ //! VectorCraft desktop app. //! -//! Usage: `vectorcraft [--control ] [--in-window-menus] [files…]` +//! Usage: `vectorcraft [--control [--automation-read-root ] [--automation-write-root ]] +//! [--in-window-menus] [files…]` //! //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! macOS instead of the macOS menu bar (`mac_menu`). //! //! `--control ` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. -//! See `vectorcraft_ui_egui::control` for the methods. +//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and +//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the +//! files its requests read and write (`vectorcraft_engine::file_access`); the person at the +//! keyboard isn't confined. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))] @@ -36,6 +40,7 @@ mod window; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::file_access::{self, AutomationRoots}; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; @@ -51,7 +56,39 @@ struct App { } impl eframe::App for App { - fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the + // control channel asks for, and the frames carrying input it injected, to the automation roots + // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + file_access::unconfined(|| self.logic_frame(ctx, frame)); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(target_os = "macos")] + if self.app.services.native_menu.is_some() { + mac_menu::raw_input_hook(raw); + } + file_access::unconfined(|| self.app.raw_input_hook(raw)); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + file_access::unconfined(|| self.app.ui(ui)); + #[cfg(target_os = "macos")] + if self.app.take_ime_discard() { + discard_marked_text(); + } + } + #[cfg(not(feature = "windows7"))] + fn on_exit(&mut self) { + file_access::unconfined(|| save_prefs(&self.app)); + end_before_teardown(self.frames); + } + #[cfg(feature = "windows7")] + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + file_access::unconfined(|| save_prefs(&self.app)); + } +} + +impl App { + fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.frames = ctx.cumulative_frame_nr(); if let Some(why) = self.graphics_loss.take() { // eframe can't give a window a new device: the user saves and starts again. @@ -76,29 +113,6 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - #[cfg(target_os = "macos")] - if self.app.services.native_menu.is_some() { - mac_menu::raw_input_hook(raw); - } - self.app.raw_input_hook(raw); - } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.app.ui(ui); - #[cfg(target_os = "macos")] - if self.app.take_ime_discard() { - discard_marked_text(); - } - } - #[cfg(not(feature = "windows7"))] - fn on_exit(&mut self) { - save_prefs(&self.app); - end_before_teardown(self.frames); - } - #[cfg(feature = "windows7")] - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - save_prefs(&self.app); - } } /// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, @@ -389,12 +403,19 @@ fn main() -> std::process::ExitCode { // VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into. prefs_dir::install_fonts(); let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); + let mut read_root = root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"); + let mut write_root = root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--automation-read-root" => read_root = args.next().map(std::path::PathBuf::from), + "--automation-write-root" => write_root = args.next().map(std::path::PathBuf::from), + _ if a.starts_with("--automation-read-root=") => read_root = a.split_once('=').map(|(_, d)| d.into()), + _ if a.starts_with("--automation-write-root=") => write_root = a.split_once('=').map(|(_, d)| d.into()), "--in-window-menus" => in_window_menus = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); @@ -417,6 +438,23 @@ fn main() -> std::process::ExitCode { None => logger.no_file(), } } + // The roots confine the control channel: a bad one keeps the app from starting rather than + // leaving agents unconfined. Without a control port there is nothing to confine. + let roots = if control_port.is_none() { + if read_root.is_some() || write_root.is_some() { + log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control"); + } + None + } else { + match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) { + Ok(roots) => roots, + Err(e) => { + log::error!("{e}"); + eprintln!("vectorcraft: {e}"); + return std::process::ExitCode::FAILURE; + } + } + }; let saved = read_prefs(); let saved_window = saved.as_ref().and_then(|ui| ui.window); #[cfg(feature = "wgpu")] @@ -532,7 +570,13 @@ fn main() -> std::process::ExitCode { app.custom_titlebar = CUSTOM_TITLEBAR; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); + app = app.with_control(rx).with_automation_roots(roots.clone()); + if let Some(roots) = &roots { + // Everywhere the app doesn't say whose work it is (worker threads), the + // roots hold: unconfined is only what the person at the keyboard does. + file_access::confine_process(roots.clone()); + log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root()); + } } #[cfg(target_os = "macos")] { @@ -544,7 +588,7 @@ fn main() -> std::process::ExitCode { } #[cfg(not(target_os = "macos"))] let _ = in_window_menus; - open_files(&mut app, files); + file_access::unconfined(|| open_files(&mut app, files)); Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 })) }), ) diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index a5496ce0..462432e2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -181,6 +181,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { fn load_profile(_: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + crate::file_access::check_read(path).map_err(EngineError::Other)?; let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?; Ok(json!({"name": info.name, "kind": info.kind})) } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 4dcdf976..d1cebc15 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -708,20 +708,39 @@ fn formats(_: &mut Session, _: &Value) -> Result { } // ---------- the file system (none on the web, where commands take and return bytes) ---------- +// +// Every path a command reads or writes goes through these, which check it against the automation +// roots in force ([`crate::file_access`]): a path outside them is an error (`file_stamp` and +// `file_created`: no file), as is any path once roots are in force without its kind of access. + +/// `path` checked for reading against the automation roots in force. +#[cfg(not(target_arch = "wasm32"))] +fn may_read(path: &str) -> Result<()> { + crate::file_access::check_read(path).map_err(EngineError::Other) +} + +/// `path` checked for writing against the automation roots in force. +#[cfg(not(target_arch = "wasm32"))] +fn may_write(path: &str) -> Result<()> { + crate::file_access::check_write(path).map_err(EngineError::Other) +} #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &str) -> Result> { + may_read(path)?; std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + may_write(path)?; // Never a half-written file: see [`write_atomic`]. write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { + may_write(path)?; std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -730,6 +749,7 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { use std::io::Write as _; + may_write(path)?; let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?; let written = f.write_all(bytes).and_then(|()| f.sync_all()); @@ -742,17 +762,20 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { } /// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system -/// keeps one); `None` when there is no file at `path`. +/// keeps one); `None` when there is no file at `path` (or automation may not read it). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option)> { + may_read(path).ok()?; let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?; let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); Some((m.len(), modified)) } -/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it. +/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it +/// (and automation may read it). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn file_created(path: &str) -> Option { + may_read(path).ok()?; let t = std::fs::metadata(path).ok()?.created().ok()?; t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64) } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index dc20a0e8..26c453f5 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -257,6 +257,8 @@ pub(crate) fn start_in( if cfg!(target_arch = "wasm32") { return Err("searching a folder isn't available on the web".into()); } + // The walkers follow no links, so a folder automation may read holds all they read. + crate::file_access::check_read(&folder.to_string_lossy())?; let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 }; let n = pool.reserve(threads.clamp(1, MAX_THREADS)); if n == 0 { diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index b499130a..222ee2b9 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -345,7 +345,11 @@ fn add(s: &mut Session, p: &Value) -> Result { if let Some(f) = files.iter().find(|f| !found.contains(f)) { return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display()))); } - super::fileio::create_dir(&dir.to_string_lossy())?; + for f in &files { + crate::file_access::check_read(&f.to_string_lossy()).map_err(EngineError::Other)?; + } + // VectorCraft's own Fonts folder: no automation root applies to it. + crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); let pairs = |v: &[(PathBuf, PathBuf)]| { v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::>() diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f3aca999..9194d20a 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -74,17 +74,18 @@ impl Libraries { self.dir.as_deref() } - /// Set the library folder and read its libraries (unreadable files are skipped). + /// Set the library folder and read its libraries (unreadable files are skipped). The folder + /// is the app's own: automation roots don't apply to it ([`crate::file_access`]). pub fn set_dir(&mut self, dir: Option) { self.dir = dir; self.libs.clear(); if let Some(dir) = self.dir.clone() { for path in library_paths(&dir) { let id = stem_of(&path); - let lib = super::fileio::file_stamp(&path) - .filter(|(len, _)| *len <= MAX_FILE) - .and_then(|_| super::fileio::read_file(&path).ok()) - .and_then(|b| serde_json::from_slice::(&b).ok()); + let lib = crate::file_access::unconfined(|| { + super::fileio::file_stamp(&path).filter(|(len, _)| *len <= MAX_FILE).and_then(|_| super::fileio::read_file(&path).ok()) + }) + .and_then(|b| serde_json::from_slice::(&b).ok()); if let Some(mut lib) = lib { lib.cap(); self.libs.push((id, lib)); @@ -149,14 +150,17 @@ impl Libraries { Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned()) } - /// Write library `id` to its file (nothing to do without a folder). + /// Write library `id` to its file (nothing to do without a folder). The folder is the app's + /// own and ids are safe file names: automation roots don't apply ([`crate::file_access`]). fn save(&self, id: &str) -> Result<()> { let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) }; - if let Some(dir) = self.dir.as_deref() { - super::fileio::create_dir(dir)?; - } let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?; - super::fileio::write_file(&path, &bytes) + crate::file_access::unconfined(|| { + if let Some(dir) = self.dir.as_deref() { + super::fileio::create_dir(dir)?; + } + super::fileio::write_file(&path, &bytes) + }) } /// Change library `id` with `f` and save it; on a failed save the change is undone. diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index e307f9d0..76e38865 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -110,6 +110,7 @@ fn install(_: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn load_path(path: &str) -> Result { + crate::file_access::check_read(path).map_err(EngineError::Other)?; registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err) } @@ -137,7 +138,11 @@ pub(crate) fn sync_prefs(s: &Session) { #[cfg(not(target_arch = "wasm32"))] fn load_folder(folder: &str) -> Value { - match registry::load_folder(std::path::Path::new(folder)) { + if let Err(e) = crate::file_access::check_read(folder) { + return json!({"folder": folder, "error": e}); + } + // A file in it may be a link to one elsewhere. + match registry::load_folder_where(std::path::Path::new(folder), |f| crate::file_access::check_read(&f.to_string_lossy())) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index e132730b..d1562c5e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -449,6 +449,38 @@ impl Prefs { Ok(()) } + /// The folders the app reads (fonts, plug-ins) or writes (Data Recovery copies, the Templates + /// folder it makes for its dialogs) on its own, with the access each needs. + fn folders_mut(&mut self) -> [(&mut String, crate::file_access::Access); 4] { + use crate::file_access::Access; + [ + (&mut self.fonts_folder, Access::Read), + (&mut self.plugins_folder, Access::Read), + (&mut self.recovery_folder, Access::Write), + (&mut self.templates_folder, Access::Write), + ] + } + + /// Put back the folders `next` sets in place of these preferences' that the automation roots + /// in force refuse: automation may only point them inside its roots ([`crate::file_access`]). + /// → why each was refused. Nothing is refused without roots, nor a folder cleared or kept. + fn keep_folders(&self, next: &mut Prefs) -> Vec { + let Some(roots) = crate::file_access::current() else { return vec![] }; + let mut before = self.clone(); + let mut refused = vec![]; + for ((old, _), (new, access)) in before.folders_mut().into_iter().zip(next.folders_mut()) { + let folder = new.trim(); + if folder.is_empty() || folder == old.trim() { + continue; + } + if let Err(e) = roots.check(folder, access) { + refused.push(e); + *new = old.clone(); + } + } + refused + } + /// Values of one category (`None` = all) reset to defaults. pub fn reset(&mut self, category: Option<&str>) { let d = Prefs::default().to_json(); @@ -470,7 +502,12 @@ impl Prefs { impl Session { /// Replace the preferences and push the ones with engine-side consumers into open documents /// and the renderer (grid, history depth, render threads). - pub fn apply_prefs(&mut self, p: Prefs) { + pub fn apply_prefs(&mut self, mut p: Prefs) { + // However they are set (a dialog driven by automation too), the folders stay inside the + // automation roots in force: a refused one keeps its value. + for e in self.prefs.keep_folders(&mut p) { + log::warn!("{e}"); + } let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions; let history_changed = p.history_states != self.prefs.history_states; let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; @@ -554,6 +591,9 @@ fn set(s: &mut Session, p: &Value) -> Result { } let mut next = s.prefs.clone(); next.set_values(&values).map_err(|e| bad("prefs.set", e))?; + if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() { + return Err(EngineError::Other(e)); + } s.apply_prefs(next); // Units ▸ General is also the open document's units (as Document Setup sets them). if values.contains_key("unitsGeneral") && s.active().is_some() { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index dcf9693e..9c1a9298 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -151,13 +151,14 @@ impl Libraries { self.rescan(); } - /// Re-read the library files of the user library folder (unreadable ones are skipped). + /// Re-read the library files of the user library folder (unreadable ones are skipped). The + /// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]). pub fn rescan(&mut self) { self.extra.retain(|e| e.info.category != "user"); let Some(dir) = self.user_dir.clone() else { return }; for path in library_files(&dir, L::EXTS) { let file = file_name(&path); - let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue }; + let Some(lib) = crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue }; let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" }; self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); } @@ -183,9 +184,10 @@ impl Libraries { /// The User Defined library whose file has the extension of file name `file` and holds `bytes`. fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra> { let ext = extension(file); - self.extra - .iter() - .find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes))) + self.extra.iter().find(|e| { + e.info.category == "user" + && e.path.as_deref().is_some_and(|p| extension(p) == ext && crate::file_access::unconfined(|| read_file(p)).is_ok_and(|b| b == bytes)) + }) } /// Write library file `data` as the save commands do: into the user library folder as @@ -203,8 +205,8 @@ impl Libraries { .to_string(); let file = library_file_name(name, ext); let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); - create_dir(&dir)?; - write_file(&path, bytes)?; + // The app's own folder, under a name made safe: no root applies. + crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_file(&path, bytes)))?; self.rescan(); out["path"] = json!(path); out["library"] = json!(format!("user/{file}")); @@ -277,11 +279,11 @@ impl Libraries { let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) { Some(same) => (same, None), None => { - create_dir(&dir)?; let mut taken: HashSet = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect(); let file = free_name(&file, &mut taken); let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); - write_new_file(&path, &bytes)?; + // The app's own folder, under a name made safe: no root applies. + crate::file_access::unconfined(|| create_dir(&dir).and_then(|()| write_new_file(&path, &bytes)))?; self.rescan(); (format!("user/{file}"), Some(path)) } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 00000000..0c3f44d9 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,441 @@ +//! The folders automation may read and write (#832). +//! +//! `vectorcraft-cli mcp --automation-read-root --automation-write-root `, and +//! `vectorcraft --control ` with the same flags, confine the files an agent's commands +//! reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`vectorcraft-cli mcp`, the desktop app with a control port), except inside +//! [`unconfined`]: what the person at the keyboard does, and the app's own folders (preferences, +//! Data Recovery, the library folders, VectorCraft's Fonts folder), which no agent names. Without +//! roots nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: crate::cmd::findfiles::plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// This thread's scope, to carry to work it hands to another thread ([`ScopeHandle::run`]). +#[derive(Clone, Debug)] +pub struct ScopeHandle(Option); + +impl ScopeHandle { + /// The scope of the calling thread. + pub fn capture() -> Self { + Self(SCOPE.with(|s| s.borrow().clone())) + } + + /// Run `f` in the captured scope. + pub fn run(self, f: impl FnOnce() -> T) -> T { + with_scope(self.0, f) + } +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &str) -> Result { + if path.is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {path}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("vc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.svg"), "").unwrap(); + std::fs::write(base.join("outside/secret.svg"), "").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.svg")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.svg")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.svg")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.svg")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.svg"), "").unwrap(); + assert!(roots.check(&s(base.join("in2/b.svg")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.svg", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.svg", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.svg", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.svg", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.svg", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.svg"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.svg")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.svg"), &base.join("in/leak.svg")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.svg")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.svg"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.svg")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.svg")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/A.SVG")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.svg")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_carry_to_other_threads() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.svg")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let handle = ScopeHandle::capture(); + let o = outside.clone(); + let there = std::thread::spawn(move || (check_read(&o).is_ok(), handle.run(|| check_read(&o).is_ok()))).join().unwrap(); + assert_eq!(there, (true, false), "another thread is confined only in the captured scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.svg", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.svg", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 487b26f6..e8d389d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod file_access; pub mod guard; pub mod inspect; pub mod steps; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 71e9ad1f..5370ad63 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,6 +1,9 @@ //! An in-process engine session that answers the control-channel methods itself. +use std::sync::Arc; + use serde_json::{Value, json}; +use vectorcraft_engine::file_access::{self, AutomationRoots}; use vectorcraft_engine::{Session, UiRequest, ViewInfo}; use vectorcraft_geom::Point; use vectorcraft_render::Renderer; @@ -14,6 +17,9 @@ pub struct Headless { pub renderer: Renderer, /// View state handed to tools (zoom 1, smart guides on). pub view: ViewInfo, + /// The folders its methods may read and write (`--automation-read-root`, + /// `--automation-write-root`); `None`: anywhere. + roots: Option>, } impl Default for Headless { @@ -93,7 +99,13 @@ fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool { impl Headless { /// A session with no document (the first `file.new` / `app.open` creates one). pub fn new() -> Self { - Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default() } + Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default(), roots: None } + } + + /// Confine the files every method reads and writes to `roots` ([`file_access`]). + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self } /// A session with a fresh default (Letter) document, ready to draw into. @@ -227,13 +239,20 @@ impl Headless { vectorcraft_render::raster_size(r, scale)?; let img = self.renderer.render_region(&doc, r, scale, true); let png = img.to_png()?; + let mut out = json!({"width": img.width, "height": img.height}); match s(p, "path") { Some(path) => { - std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; - Ok(json!({"path": path, "width": img.width, "height": img.height})) + file_access::check_write(path)?; + vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; + out["path"] = json!(path); + // `data: true` asks for the image as well as the file. + if p.get("data").and_then(Value::as_bool) == Some(true) { + out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)); + } } - None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})), + None => out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)), } + Ok(out) } /// `app.open {path}`: any readable file as a new active document (`document.open`). @@ -260,7 +279,22 @@ impl Headless { impl Backend for Headless { fn call(&mut self, method: &str, params: Value) -> Result { - let p = ¶ms; + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, ¶ms)) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless".into() + } +} + +impl Headless { + /// One control-channel method ([`Backend::call`]). + fn answer(&mut self, method: &str, p: &Value) -> Result { match method { "engine.execute" | "ui.menu.invoke" | "command" => { let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string(); @@ -289,12 +323,4 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } - - fn has_ui(&self) -> bool { - false - } - - fn describe(&self) -> String { - "headless".into() - } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f47a6484..ac0683b0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -649,43 +649,50 @@ fn transform(b: &mut dyn Backend, a: &Args) -> Result { fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); - if a.get("window").and_then(Value::as_bool) == Some(true) { - need_ui(b, "screenshot {window:true}")?; - // The app writes the capture to disk (loopback: same machine), then we read it back. - let tmp; - let target = match path { - Some(p) => p, - None => { - tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); - &tmp - } - }; - let r = b.call("ui.screenshot", json!({"path": target}))?; - let png = std::fs::read(target).map_err(|e| format!("read {target}: {e}"))?; - if path.is_none() { - std::fs::remove_file(target).ok(); - } - return Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path}))); - } - let mut p = json!({}); - for k in ["scale", "artboard"] { - if let Some(v) = a.get(k) { - p[k] = v.clone(); - } - } - let r = b.call("ui.render", p)?; - let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?; + let window = a.get("window").and_then(Value::as_bool) == Some(true); + // The backend writes the file, where its automation roots allow, and sends the image back too + // (`data`): this process writes nothing. + let mut p = json!({"data": true}); if let Some(path) = path { - let png = vectorcraft_format::base64_decode(b64).ok_or("renderer returned bad base64")?; - std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + p["path"] = json!(path); } - Ok(ToolResult { - content: vec![ - json!({"type": "image", "data": b64, "mimeType": "image/png"}), - json!({"type": "text", "text": json!({"width": r.get("width"), "height": r.get("height"), "path": path}).to_string()}), - ], - is_error: false, - }) + let r = if window { + need_ui(b, "screenshot {window:true}")?; + b.call("ui.screenshot", p)? + } else { + for k in ["scale", "artboard"] { + if let Some(v) = a.get(k) { + p[k] = v.clone(); + } + } + b.call("ui.render", p)? + }; + let png = match r.get("pngBase64").and_then(Value::as_str) { + Some(b64) => vectorcraft_format::base64_decode(b64).ok_or("the backend returned bad base64")?, + // An app from before `data` wrote the file without sending the image: read it back + // (loopback: the same machine). + None => match path { + Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?, + None if window => return old_window_capture(b), + None => return Err("the renderer returned no image".into()), + }, + }; + let info = if window { + json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path}) + } else { + json!({"width": r.get("width"), "height": r.get("height"), "path": path}) + }; + Ok(image_result(&png, info)) +} + +/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file. +fn old_window_capture(b: &mut dyn Backend) -> Result { + let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); + let r = b.call("ui.screenshot", json!({"path": tmp}))?; + let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?; + // Best effort: a temporary file left behind is harmless. + std::fs::remove_file(&tmp).ok(); + Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null}))) } fn image_result(png: &[u8], info: Value) -> ToolResult { diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 00000000..fb94bd69 --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,155 @@ +//! `vectorcraft-cli mcp --automation-read-root --automation-write-root ` (#832): through +//! the MCP protocol, files open only from inside the read root and are saved, exported and +//! captured only inside the write root; paths that leave a root (`..`, another folder, a sibling +//! with the same prefix) are refused, and nothing is written outside. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use vectorcraft_engine::file_access::AutomationRoots; +use vectorcraft_mcp::{Headless, Server}; + +const SVG: &str = r#""#; + +/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root), `outside` (with +/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out). +fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { + let base = vectorcraft_testkit::temp_dir(&format!("roots-{tag}")); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.svg"), SVG).unwrap(); + std::fs::write(base.join("outside/b.svg"), SVG).unwrap(); + std::fs::write(base.join("in2/c.svg"), SVG).unwrap(); + let in_dir = base.join("in"); + let out_dir = base.join("out"); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + (Server::new(Box::new(Headless::with_document().with_automation_roots(roots))), base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// `tools/call` → (isError, the first text block). +fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string(); + let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap(); + let result = &reply["result"]; + let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string(); + (result["isError"] == true, text) +} + +fn ok(server: &mut Server, name: &str, args: Value) -> String { + let (error, text) = call(server, name, args.clone()); + assert!(!error, "{name} {args}: {text}"); + text +} + +fn refused(server: &mut Server, name: &str, args: Value, why: &str) { + let (error, text) = call(server, name, args.clone()); + assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); +} + +#[test] +fn files_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))})); + ok(&mut server, "run_command", json!({"command": "file.place", "params": {"path": s(&base.join("in/a.svg"))}})); + refused(&mut server, "open_file", json!({"path": s(&base.join("outside/b.svg"))}), "outside the read root"); + refused(&mut server, "open_file", json!({"path": format!("{}{sep}..{sep}outside{sep}b.svg", s(&base.join("in")))}), "outside the read root"); + refused(&mut server, "open_file", json!({"path": s(&base.join("in2/c.svg"))}), "outside the read root"); + refused( + &mut server, + "run_command", + json!({"command": "file.place", "params": {"path": s(&base.join("outside/b.svg"))}}), + "outside the read root", + ); + // The write root isn't readable: the two kinds of access are separate. + std::fs::write(base.join("out/d.svg"), SVG).unwrap(); + refused(&mut server, "open_file", json!({"path": s(&base.join("out/d.svg"))}), "outside the read root"); + // A relative path is the working directory's (the crate's folder here): outside. + refused(&mut server, "open_file", json!({"path": "Cargo.toml"}), "outside the read root"); +} + +#[test] +fn saves_exports_and_captures_go_only_into_the_write_root() { + let (mut server, base) = confined("write", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#1e88e5"})); + + ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))})); + assert!(base.join("out/art.png").is_file()); + ok(&mut server, "export", json!({"path": s(&base.join("out/art.svg"))})); + ok(&mut server, "save_file", json!({"path": s(&base.join("out/doc.vectorcraft"))})); + ok(&mut server, "screenshot", json!({"path": s(&base.join("out/shot.png"))})); + assert!(base.join("out/shot.png").is_file(), "the backend wrote the capture"); + + refused(&mut server, "export", json!({"path": s(&base.join("in/art.png"))}), "outside the write root"); + refused(&mut server, "export", json!({"path": s(&base.join("outside/art.svg"))}), "outside the write root"); + refused(&mut server, "export", json!({"path": format!("{}{sep}..{sep}outside{sep}up.png", s(&base.join("out")))}), "outside the write root"); + refused(&mut server, "save_file", json!({"path": s(&base.join("outside/doc.vectorcraft"))}), "outside the write root"); + refused(&mut server, "screenshot", json!({"path": s(&base.join("outside/shot.png"))}), "outside the write root"); + refused( + &mut server, + "run_command", + json!({"command": "document.exportForScreens", "params": {"folder": s(&base.join("outside/screens"))}}), + "outside the write root", + ); + refused( + &mut server, + "run_command", + json!({"command": "file.package", "params": {"folder": s(&base.join("outside/pkg"))}}), + "outside the write root", + ); + // Folders the app writes on its own later: only inside the roots. + refused( + &mut server, + "run_command", + json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("outside"))}}), + "outside the write root", + ); + ok(&mut server, "run_command", json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}})); + refused(&mut server, "run_command", json!({"command": "plugin.reload", "params": {"path": s(&base.join("outside"))}}), "outside the read root"); + + // The document saved inside the write root saves again in place. + ok(&mut server, "save_file", json!({})); + let written: Vec = + std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(written, ["b.svg"], "nothing was written outside the roots"); + assert!(!base.join("in/art.png").exists()); +} + +#[test] +fn a_root_left_out_grants_none_of_its_access() { + let (mut server, base) = confined("read-only", true, false); + ok(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))})); + refused(&mut server, "export", json!({"path": s(&base.join("out/art.png"))}), "write authority is absent"); + // Bytes that come back instead of a file are fine. + ok(&mut server, "export", json!({"format": "svg"})); + + let (mut server, base) = confined("write-only", false, true); + refused(&mut server, "open_file", json!({"path": s(&base.join("in/a.svg"))}), "read authority is absent"); + ok(&mut server, "export", json!({"path": s(&base.join("out/art.png"))})); +} + +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok(); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok(); + #[cfg(not(any(unix, windows)))] + let linked = false; + // Windows needs Developer Mode or elevation to make links. + if !linked { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + refused(&mut server, "export", json!({"path": s(&base.join("out/away/art.png"))}), "outside the write root"); + assert!(!base.join("outside/art.png").exists()); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 42e5ff92..13c4f984 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -137,6 +137,55 @@ fn every_tool_survives_empty_and_junk_arguments() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Junk below a root: names, `.`, `..` (at most four, which stay inside the test's folder), a +/// device name, a drive, either separator. +fn junk_tail() -> impl Strategy { + let seg = + prop::sample::select(vec!["..", ".", "a", "b.svg", "x.png", "d.vectorcraft", "CON", "nul.txt", "", "c:", "~", "%2e%2e", "ü", "a b", "x."]); + (prop::collection::vec(seg, 1..=4), prop::sample::select(vec!["/", "\\"])).prop_map(|(segs, sep)| segs.join(sep)) +} + +/// Every file below `dir`. +fn files_below(dir: &std::path::Path) -> Vec { + let mut out = vec![]; + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = e.path(); + if e.file_type().is_ok_and(|t| t.is_dir()) { + out.extend(files_below(&path)); + } else { + out.push(path); + } + } + out +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + /// Confined to one folder (#832), the server takes fuzzed paths, unlike the junk test above: + /// every reply is well formed, and whatever it writes lands inside the root. + #[test] + fn a_confined_server_writes_only_inside_its_root( + tail in junk_tail(), + tool in prop::sample::select(vec!["open_file", "save_file", "export", "screenshot"]), + ) { + let base = vectorcraft_testkit::temp_dir("mcp-confined"); + let root = base.join("a/b/c/root"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(base.join("outside")).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(Some(root.as_path()), Some(root.as_path())).unwrap(); + let mut s = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); + let path = format!("{}{}{tail}", root.display(), std::path::MAIN_SEPARATOR); + let r = catch_quiet(|| call(&mut s, tool, json!({"path": path}))); + prop_assert!(r.is_ok(), "{tool} {path}: panicked"); + let real_root = std::fs::canonicalize(&root).unwrap(); + for f in files_below(&base) { + let inside = std::fs::canonicalize(&f).map_or_else(|_| f.starts_with(&root), |real| real.starts_with(&real_root)); + prop_assert!(inside, "{tool} {path} wrote {}", f.display()); + } + } +} + #[test] fn framing_never_panics_on_malformed_messages() { let mut s = server(); diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index a0f18692..fd165102 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -95,6 +95,13 @@ pub struct FolderReport { /// reported and skipped; the others still load. #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { + load_folder_where(dir, |_| Ok(())) +} + +/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed +/// with its reason. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result { let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); @@ -102,6 +109,10 @@ pub fn load_folder(dir: &std::path::Path) -> Result { files.truncate(1000); let mut report = FolderReport::default(); for f in files { + if let Err(why) = allow(&f) { + report.failed.push((f.display().to_string(), why)); + continue; + } match load_file(&f, Limits::default()) { Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index f5d070d8..a1940a7b 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -9,6 +9,7 @@ use std::sync::mpsc::{Receiver, Sender, channel}; use serde_json::{Value, json}; +use vectorcraft_engine::file_access::ScopeHandle; use crate::VectorcraftApp; @@ -32,6 +33,8 @@ struct Task { id: u64, work: Work, write: SharedWriteFn, + /// The automation roots in force where the work was started, in force where it runs. + scope: ScopeHandle, } /// A job in progress. @@ -65,8 +68,9 @@ impl Background { std::thread::Builder::new() .name("vectorcraft-save".into()) .spawn(move || { - while let Ok(Task { id, work, write }) = tasks.recv() { - let r = vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b))) + while let Ok(Task { id, work, write, scope }) = tasks.recv() { + let r = scope + .run(|| vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b)))) .unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)"))); if done_tx.send((id, r)).is_err() { break; @@ -101,7 +105,7 @@ pub fn run( if let Some(write) = shared { let id = app.background.next_id; if let Some(tx) = app.background.worker() { - match tx.send(Task { id, work, write }) { + match tx.send(Task { id, work, write, scope: ScopeHandle::capture() }) { Ok(()) => { let bg = &mut app.background; bg.next_id += 1; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43d4cf59..bdf2fd94 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -14,8 +14,10 @@ //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` -//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` -//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG) +//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG +//! comes back as `pngBase64` as well) +//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path, +//! or with `data: true`, as `pngBase64`) //! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit` //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) @@ -47,7 +49,12 @@ impl ControlRequest { pub enum Outcome { Done(Value), - Screenshot { path: Option }, + /// A window capture, answered once a frame was presented: written to `path`, the PNG sent + /// back too with `data`. + Screenshot { + path: Option, + data: bool, + }, } fn ok(v: Value) -> Outcome { @@ -301,11 +308,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.screenshot" => { + // Refused now rather than once the frame comes (it is checked again then). + if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) { + return err(e); + } if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } ctx.request_repaint(); - Outcome::Screenshot { path: s("path").map(str::to_string) } + Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) } } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; @@ -320,12 +331,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Ok(png) => png, Err(e) => return err(e), }; + let data = p.get("data").and_then(Value::as_bool) == Some(true); match s("path") { Some(path) => match app.services.write.as_mut() { - Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))), + Some(w) => wrap( + w(path, &png) + .map(|_| with_data(json!({"path": path, "width": img.width, "height": img.height}), data.then_some(png.as_slice()))), + ), None => err("no writer"), }, - None => ok(json!({"width": img.width, "height": img.height, "pngBase64": vectorcraft_format::base64_encode(&png)})), + None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))), } } "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), @@ -342,20 +357,34 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } } -pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value { +/// `result` with the PNG as `pngBase64` when there is one. +fn with_data(mut result: Value, png: Option<&[u8]>) -> Value { + if let Some(png) = png { + result["pngBase64"] = json!(vectorcraft_format::base64_encode(png)); + } + result +} + +/// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as +/// `pngBase64` with `data`. +pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value { let [w, h] = image.size; - let Some(path) = path else { + if path.is_none() && !data { return json!({"ok": true, "result": {"width": w, "height": h}}); - }; + } let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba }; let png = match img.to_png() { Ok(png) => png, Err(e) => return json!({"ok": false, "error": e}), }; + let data = data.then_some(png.as_slice()); + let Some(path) = path else { + return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)}); + }; match app.services.write.as_mut() { Some(wr) => match wr(path, &png) { - Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Ok(()) => json!({"ok": true, "result": with_data(json!({"path": path, "width": w, "height": h}), data)}), Err(e) => json!({"ok": false, "error": e}), }, None => json!({"ok": false, "error": "no writer configured"}), diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 759cad15..9668bbd5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3827,3 +3827,12 @@ axis Both Ambos @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal) @msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v} +@msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path} +@msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path} +@msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía +@msg automation path rejected: a link that leads nowhere: {path} ruta de automatización rechazada: un vínculo que no lleva a ninguna parte: {path} +@msg automation path rejected: cannot resolve {path}: {e} ruta de automatización rechazada: no se puede resolver {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} ruta de automatización rechazada: sube (..) desde una carpeta que no existe: {path} +@msg automation path rejected: reserved device names are not allowed: {path} ruta de automatización rechazada: no se permiten nombres de dispositivo reservados: {path} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2d7241..e4745889 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3838,3 +3838,12 @@ axis Both Les deux @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral) @msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v} +@msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} +@msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path} +@msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path} +@msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path} +@msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide +@msg automation path rejected: a link that leads nowhere: {path} chemin d’automatisation refusé : un lien qui ne mène nulle part : {path} +@msg automation path rejected: cannot resolve {path}: {e} chemin d’automatisation refusé : impossible de résoudre {path} : {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} chemin d’automatisation refusé : remonte (..) depuis un dossier qui n’existe pas : {path} +@msg automation path rejected: reserved device names are not allowed: {path} chemin d’automatisation refusé : les noms de périphériques réservés ne sont pas autorisés : {path} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9659a296..d5cf75e9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3827,3 +3827,12 @@ axis Both Entrambi @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale) @msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v} +@msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path} +@msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path} +@msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto +@msg automation path rejected: a link that leads nowhere: {path} percorso di automazione rifiutato: un collegamento che non porta da nessuna parte: {path} +@msg automation path rejected: cannot resolve {path}: {e} percorso di automazione rifiutato: impossibile risolvere {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} percorso di automazione rifiutato: risale (..) da una cartella che non esiste: {path} +@msg automation path rejected: reserved device names are not allowed: {path} percorso di automazione rifiutato: i nomi di dispositivo riservati non sono consentiti: {path} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6b8aa5ae..2afbb30f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3819,3 +3819,12 @@ theme Appearance 外観 @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください) @msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません) +@msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path} +@msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path} +@msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path} +@msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です +@msg automation path rejected: a link that leads nowhere: {path} 自動化のパスを拒否しました: リンク先が存在しません: {path} +@msg automation path rejected: cannot resolve {path}: {e} 自動化のパスを拒否しました: {path} を解決できません: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 自動化のパスを拒否しました: 存在しないフォルダーから上の階層 (..) へ移動しています: {path} +@msg automation path rejected: reserved device names are not allowed: {path} 自動化のパスを拒否しました: 予約済みのデバイス名は使用できません: {path} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0fa94d41..e607d06a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3825,3 +3825,12 @@ axis Both Оба @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$) @msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v} +@msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path} +@msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path} +@msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст +@msg automation path rejected: a link that leads nowhere: {path} путь автоматизации отклонён: ссылка, которая никуда не ведёт: {path} +@msg automation path rejected: cannot resolve {path}: {e} путь автоматизации отклонён: не удаётся определить {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} путь автоматизации отклонён: переход вверх (..) из несуществующей папки: {path} +@msg automation path rejected: reserved device names are not allowed: {path} путь автоматизации отклонён: зарезервированные имена устройств недопустимы: {path} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c2d43030..060e7359 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3810,3 +3810,12 @@ axis Both Обидві @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$) @msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v} +@msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path} +@msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path} +@msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній +@msg automation path rejected: a link that leads nowhere: {path} шлях автоматизації відхилено: посилання, яке нікуди не веде: {path} +@msg automation path rejected: cannot resolve {path}: {e} шлях автоматизації відхилено: не вдається визначити {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} шлях автоматизації відхилено: перехід угору (..) з папки, якої не існує: {path} +@msg automation path rejected: reserved device names are not allowed: {path} шлях автоматизації відхилено: зарезервовані імена пристроїв не дозволені: {path} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b5a71ba7..5676f82f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -62,6 +62,8 @@ mod tests_adjust; #[cfg(test)] mod tests_aisave; #[cfg(test)] +mod tests_automation; +#[cfg(test)] mod tests_background; #[cfg(test)] mod tests_clipboard; @@ -149,6 +151,7 @@ use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::file_access::{self, AutomationRoots}; use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse}; @@ -319,8 +322,8 @@ pub struct VectorcraftApp { /// moves ([`native_menu::sync`]). run_count: u64, control_rx: Option>, - /// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame. - pending_screenshots: Vec<(u64, Option, Sender, f64)>, + /// Window captures asked for and not answered yet. + pending_screenshots: Vec, queued_screenshots: Vec<(u64, f64, u32)>, screenshot_token: u64, /// Synthetic input events (from the control channel) injected one step per frame. @@ -386,6 +389,25 @@ pub struct VectorcraftApp { /// A numeric field is being scrubbed: the document's edits meanwhile are one undo step /// ([`scrub::begin_frame`]). scrub_group: bool, + /// The folders control requests, and the input they inject, may read and write + /// (`--automation-read-root`, `--automation-write-root`); `None`: anywhere. See + /// [`Self::with_automation_roots`]. + automation_roots: Option>, + /// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs + /// confined to [`Self::automation_roots`]. + synthetic_frame: bool, +} + +/// A window capture asked for through the control channel (`ui.screenshot`), answered once a +/// frame is presented; a window that isn't presented never delivers one, hence the deadline. +struct PendingScreenshot { + token: u64, + path: Option, + /// Send the PNG back as `pngBase64`. + data: bool, + reply: Sender, + /// When to give up (ms, as [`now_ms`]). + deadline: f64, } /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. @@ -454,6 +476,8 @@ impl VectorcraftApp { ime_marked: None, ime_discard: false, scrub_group: false, + automation_roots: None, + synthetic_frame: false, } } @@ -462,6 +486,33 @@ impl VectorcraftApp { self } + /// Confine what the control channel does to `roots` (#832): its requests, the input it + /// injects (in the frames that carry it) and the saves and exports they start read and write + /// only inside them ([`file_access`]). The host's file services check every path they're given + /// against the roots in force, and so does the engine; outside those scopes the person at the + /// keyboard works as usual. + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + let Some(roots) = roots else { return self }; + self.automation_roots = Some(roots); + let s = &mut self.services; + if let Some(read) = s.read.take() { + s.read = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| read(p)))); + } + if let Some(mut write) = s.write.take() { + s.write = Some(Box::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); + } + if let Some(write) = s.write_shared.take() { + s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); + } + // Edit Original and Show in Folder hand a file to another app: only one automation may read. + for service in [&mut s.open_file, &mut s.reveal] { + if let Some(mut open) = service.take() { + *service = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| open(p)))); + } + } + self + } + /// Keep `views` aligned with the session's documents (a new one starts at its saved view). pub fn sync_views(&mut self) { let docs = self.session.documents(); @@ -619,18 +670,19 @@ impl VectorcraftApp { while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). - let outcome = vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)) + let roots = self.automation_roots.clone(); + let outcome = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) .unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)"))); match outcome { control::Outcome::Done(v) => { let _ = reply.send(v); } - control::Outcome::Screenshot { path } => { + control::Outcome::Screenshot { path, data } => { self.screenshot_token += 1; let token = self.screenshot_token; let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; self.queued_screenshots.push((token, now_ms() + settle, 0)); - self.pending_screenshots.push((token, path, reply, now_ms() + settle + 8000.0)); + self.pending_screenshots.push(PendingScreenshot { token, path, data, reply, deadline: now_ms() + settle + 8000.0 }); } } } @@ -671,17 +723,18 @@ impl VectorcraftApp { .collect() }); for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_screenshots.remove(i); - let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); + if let Some(i) = self.pending_screenshots.iter().position(|p| p.token == token) { + let p = self.pending_screenshots.remove(i); + let roots = self.automation_roots.clone(); + let _ = p.reply.send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, p.path.as_deref(), p.data))); } } let now = now_ms(); - self.pending_screenshots.retain(|(_, _, reply, deadline)| { - if now < *deadline { + self.pending_screenshots.retain(|p| { + if now < p.deadline { return true; } - let _ = reply.send(serde_json::json!({ + let _ = p.reply.send(serde_json::json!({ "ok": false, "error": "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard" })); @@ -782,7 +835,8 @@ impl VectorcraftApp { /// Per-frame logic before layout (control channel, shortcuts, inbox). A bug that panics costs /// one frame and shows an error, instead of closing the app with unsaved work. pub fn logic(&mut self, ctx: &egui::Context) { - if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx)) { + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) { self.status(format!("Internal error: {msg} (please report this bug)")); } } @@ -964,6 +1018,7 @@ impl VectorcraftApp { _ => {} } } + self.synthetic_frame = !self.synthetic.is_empty(); let Some(first) = self.synthetic.first() else { if std::mem::take(&mut self.synthetic_modifiers) { raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); @@ -1006,7 +1061,8 @@ impl VectorcraftApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { - if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui)) { + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) { self.status(format!("Internal error: {msg} (please report this bug)")); } } diff --git a/crates/ui-egui/src/tests_automation.rs b/crates/ui-egui/src/tests_automation.rs new file mode 100644 index 00000000..82e30af9 --- /dev/null +++ b/crates/ui-egui/src/tests_automation.rs @@ -0,0 +1,92 @@ +//! `vectorcraft --control --automation-read-root --automation-write-root ` (#832): +//! control requests read and write only inside the roots; the person at the keyboard is not +//! confined. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::file_access::AutomationRoots; + +use crate::control::ControlRequest; +use crate::{Services, VectorcraftApp}; + +/// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside` +/// (with `b.svg`), and an app with the desktop's file services confined to `in` and `out`. +fn confined(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender, PathBuf) { + let base = std::env::temp_dir().join(format!("vc-ui-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let svg = r#""#; + std::fs::write(base.join("in/a.svg"), svg).unwrap(); + std::fs::write(base.join("outside/b.svg"), svg).unwrap(); + let services = Services { + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| vectorcraft_format::write_atomic(Path::new(p), b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let app = VectorcraftApp::new(Session::new(), services).with_control(rx).with_automation_roots(roots); + (app, tx, base) +} + +fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() +} + +/// One request through the control channel, as the app's frame drains it. +fn request(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + reply.try_recv().unwrap() +} + +#[test] +fn control_requests_stay_inside_the_roots() { + let (mut app, tx, base) = confined("control"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("outside/b.svg"))})); + assert!(r["error"].as_str().unwrap().contains("outside the read root"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.place", "params": {"path": s(base.join("outside/b.svg"))}})); + assert!(r["error"].as_str().unwrap().contains("outside the read root"), "{r}"); + + let r = request(&mut app, &tx, "app.export", json!({"path": s(base.join("out/a.png")), "format": "png"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(base.join("out/a.png").is_file()); + for (method, params) in [ + ("app.export", json!({"path": s(base.join("outside/a.png")), "format": "png"})), + ("app.save", json!({"path": s(base.join("outside/a.vectorcraft"))})), + ("ui.render", json!({"path": s(base.join("outside/r.png"))})), + // Refused at once, not when the frame comes. + ("ui.screenshot", json!({"path": s(base.join("outside/w.png"))})), + ] { + let r = request(&mut app, &tx, method, params); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root")), "{method}: {r}"); + } + let r = request(&mut app, &tx, "ui.render", json!({"path": s(base.join("out/r.png")), "data": true})); + assert!(r["result"]["pngBase64"].is_string() && base.join("out/r.png").is_file(), "{r}"); + assert_eq!(std::fs::read_dir(base.join("outside")).unwrap().count(), 1, "nothing written outside"); + + // The person at the keyboard isn't confined. + assert!(app.run("file.open", json!({"path": s(base.join("outside/b.svg"))})).is_ok()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn frames_with_injected_input_are_confined() { + let (mut app, tx, base) = confined("synthetic"); + let r = request(&mut app, &tx, "ui.key", json!({"key": "Escape"})); + assert_eq!(r["ok"], true, "{r}"); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + assert!(app.synthetic_frame, "the frame carrying the key"); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + assert!(!app.synthetic_frame, "the frames after it are the person's"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b2ee755b..46c299e6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -13,6 +13,34 @@ its request line is rejected first. At most 16 connections are served at once; f line and are closed. Clients that get an error reply should reconnect. The port has no authentication, so only enable it while you use it. Transport: `apps/vectorcraft/src/control_server.rs`. +## Confining file access + +```sh +vectorcraft --control 7979 --automation-read-root /work/project --automation-write-root /work/project/out +``` + +`--automation-read-root ` and `--automation-write-root ` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / +`VECTORCRAFT_AUTOMATION_WRITE_ROOT`; `--flag=` works too) confine what the control channel reads and +writes, with the same flags as `vectorcraft-cli mcp` and the other Craft apps. Every path a request reads +(open, place, relink, library and profile loads, plug-ins…) must lie inside the read root, every path it +writes (save, export, package, captures, library saves…) inside the write root; anything else is an +ordinary error reply. Paths are made absolute against the app's working directory, links are followed +(for a file that doesn't exist yet, its folder's), and the comparison is by whole folder names (`/work2` is +not inside `/work`), ignoring case on Windows; `..` below a folder that doesn't exist, a link that leads +nowhere and Windows device names are refused. A root left out grants none of its access. The roots must be +existing folders, or the app doesn't start; without `--control` they are ignored. See `docs/mcp.md` +› Confining file access for the details. + +What is confined: the requests themselves, the frames that carry input they inject (`ui.click`, `ui.key`, +`ui.text`…, so a menu item an agent clicks is confined too), the background saves and exports they start, +and window captures. The person at the keyboard is not: files opened from the command line, Finder or a +file dialog, and everything done with the mouse and keyboard, work as usual. The preferences that move +folders the app reads or writes on its own (Additional Fonts Folder, Additional Plug-ins Folder, Data +Recovery's folder, the Templates folder) can only be pointed inside the roots by automation. The app's own folders +(preferences, Data Recovery, the library folders, VectorCraft's Fonts folder) are not confined, since no +agent names a path there. A path is checked, then opened: another program that swaps in a link between +the two can still win that race. + | Method | Params | | |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | @@ -27,8 +55,8 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | -| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | -| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | +| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | +| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | diff --git a/docs/mcp.md b/docs/mcp.md index 47d5f8b8..7bcf2df7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -49,6 +49,57 @@ claude mcp add vectorcraft -- "C:\Program Files\VectorCraft\vectorcraft-cli.exe" claude mcp add vectorcraft -- vectorcraft-cli mcp ``` +## Confining file access + +```sh +vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"vectorcraft": {"command": "/abs/path/target/release/vectorcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (PhotoCraft and the other Craft apps take the same ones, so one client config works across +the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and + updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins, + `file.newFromTemplate`, preset imports, the folders `text.findFontFiles` searches. +- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and + export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, + library and preset saves to a path. +- Read and write access are separate. Give the same folder twice for both; a root left out grants none + of its access (with only `--automation-read-root`, nothing can be written to disk, though `export` and + the save commands without a path still return the bytes). Without either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a file + (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, its nearest + existing folder's links are followed and the rest must be plain names, so `..` below a folder that + doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). + The result is compared with the root by whole folder names: `/work2/x` is not inside `/work`. On + Windows case is ignored, UNC paths (`\\server\share\…`) are resolved like drive-letter ones, and + device names (`CON`, `NUL`, `COM1`…) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.png` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`. + Links in an opened document that point outside the read root show as missing. +- The preferences that move folders the app reads or writes on its own (`fontsFolder`, + `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders + (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not + confined: no agent names a path there. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). + +The flags confine the headless server and imply `--headless`; `--connect` with them is an error, since +the server would otherwise talk to an app that isn't confined. A running app confines itself: start it +with the same flags, `vectorcraft --control 7979 --automation-read-root --automation-write-root +`, and connect as usual (see [Control protocol](control-protocol.md#confining-file-access)). The +server then writes nothing itself: `screenshot {path}` asks the app to write the file. + +The checks hold for every thread of the server process. A path is checked, then opened: another program +that swaps a link in between can still win that race, so the roots keep an agent to the files it was +given rather than guarding against other software on the computer. + ## Protocol Newline-delimited JSON-RPC 2.0 on stdio. The revision is **`2025-06-18`**; `2025-03-26` and `2024-11-05` are @@ -223,7 +274,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `type_text` | `{text}` | Remote only. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | -| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | +| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |