Skip to the document

Security

Blocks arrive through a shared folder, which makes them closer to macros in a spreadsheet than to plugins someone chose to install. This page sets out what Plicine protects, who it protects it from, and the controls in place. Why a block asks before it runs is the plain version.

What's being protected

  • Files on the user's machine, including the rest of the vault and anything the user can read.
  • Credentials: sync-client tokens, browser cookies for other sites, the Plicine session token.
  • Other documents and other blocks' data in the vault.
  • The integrity of what the user sees and prints. A quote that shows one total on screen and another in the PDF is a real harm.

Who it's protected from

Actor How they get in
A malicious or careless block author A .block file dropped into a shared vault, a company library or the community folder
A collaborator with write access to the vault Editing markdown, vault.json, themes or blocks.lock
A website open in the same browser Requests to 127.0.0.1 on the Plicine port, DNS rebinding
A malicious theme A .theme file with hostile CSS or templates

Out of scope: malware already running as the user (it can edit trust.json or anything else), and bugs in the browser's own sandbox.

Trust boundaries

  untrusted input                 boundary                         protected
  ---------------                 --------                         ---------
  .block code        -> approval by content hash -> compile -> sandboxed frame (CSP)
  block Print HTML   -> DOMPurify + @scope          -> print page
  markdown           -> markdown-it html:false + DOMPurify -> app page
  theme templates    -> fill (escaped) + DOMPurify   -> print page
  theme CSS          -> host CSP (no external loads) -> app and print pages
  other websites     -> Host check + token + SameSite cookie -> local API
  agent commands     -> built-in list + agents.json in app data -> agent process, running as you

Controls

Block code

  • Approval per content hash, per user. A block does nothing until someone on this machine approves that exact hash. Updating a block changes its hash and asks again; the prompt says when it's an update of something approved before. Denied blocks render as placeholders, on screen and in print.
  • No compilation before approval. An unapproved hash is never compiled, and the route that serves a block's code to its frame refuses one too.
  • Bundle-time containment. Bun macros execute at bundle time, so any source mentioning type: "macro" is refused. A resolver plugin realpaths every import and refuses anything outside the package and its installed dependencies, so a block can't bundle ~/.ssh/id_rsa as text. Dependencies install with --frozen-lockfile --production --ignore-scripts. Archives are checked for path traversal and capped at 64 MB unpacked.
  • Sandboxed execution. Each block runs in <iframe sandbox="allow-scripts"> with an opaque origin: no cookies, no storage, no access to the parent DOM. The frame is served with default-src 'none', scripts only from /_sandbox/, styles and fonts only from /_sandbox/ and /_theme/, connect-src 'none', form-action 'none', frame-src 'none', worker-src 'none'.
  • Capabilities. network: true adds https: to connect-src and img-src, and only takes effect for an approved hash whose manifest declares it. storage: true lets the frame ask the host to read and write a small JSON store in app data. The approval prompt lists both.
  • One thing a block can write. A block can change its own props, by a markdown field written in or a toolbar button of its own (the plicine module). The frame sends the props and the app does the writing: into that block's fence only, through the route the block's form saves by, refused when the fence changed meanwhile, and ignored while the form is open. A value can't break out of its fence: when a prop holds a line of backticks, the app makes the fence longer than it, so nothing a block writes can become text or another block in the document. The buttons' labels are drawn as text, six at most. On the pages, where a block's Print HTML sits in the app's own document, every data-fx-* attribute is taken off it except a field's path, which is checked before it's used: those attributes are how the app finds the page's elements, and a block that could write them could point a click at lines that aren't its own.
  • A private channel. After the frame's hello, the app checks event.source against the iframe's window and hands over a MessagePort. The frame never learns the vault path, the session token or anything about other blocks.
  • Print output is data. Print HTML comes back as a string, goes through DOMPurify (scripts, event handlers, forms, links, iframes and <base> removed) and has its <style> rules wrapped in @scope so they can't restyle the rest of the document. The CSS is tokenised and written back out before it's wrapped, so a } in it can't close the @scope block early, wherever it's hidden. The static site export does the same through HTMLRewriter.

Documents and themes

  • markdown-it runs with html: false, so raw HTML in a doc is escaped. Rendered HTML is sanitised again in the browser.
  • Theme templates are filled with HTML-escaped values and sanitised.
  • The app and print pages are served with script-src 'self', img-src 'self' data: blob:, font-src 'self' data: and connect-src 'self'. Theme CSS can't load anything from outside the local server.
  • Assets are served only from assets/; doc and asset paths are resolved and checked against their folders.
  • Symbolic links below the vault root aren't followed. A document, folder or asset path that passes through one is refused for reading, writing, moving, deleting and serving, even when the link points somewhere else in the vault, since a link in a shared or synced folder can point anywhere on the machine. Listing documents never followed them, so nothing behind a link was ever in the sidebar. A vault opened through a link works as usual.
  • Nothing served from assets/ can run on the app's origin, where a script could read the session token. Every file goes out with default-src 'none'; sandbox and nosniff. An image keeps its type so documents and PDFs can embed it; opening an SVG as a page downloads it, and every other file, HTML included, is a download of application/octet-stream.

The local server

  • Binds to 127.0.0.1 on a random port.
  • A random 48-hex-character token per run, which the sign-in link hands to the tab's sessionStorage. The link also sets an HttpOnly; SameSite=Strict cookie, named by port, holding a different random value. A browser sends 127.0.0.1's cookies to every port on it, so the cookie opens only the page shells, their bundles and assets/, and none of those carry the token.
  • Every request's Host header must be 127.0.0.1:<port> or localhost:<port>, which defeats DNS rebinding.
  • API routes need the token header. The event socket takes the token in its URL instead, and a PDF opened in a new tab takes a key that works for one document for five minutes. Anything that changes state needs the header, so a form post that carries the cookie fails.
  • The API sends no CORS headers. Only hash-addressed sandbox and theme files do.

The agent palette

  • Only the app decides what runs. An agent's command comes from the built-in list (Claude Code, Kiro, Gemini CLI, Codex) or from agents.json in app data. Nothing in a vault can name a command, add an agent or change an agent's arguments.
  • Nothing in a vault picks the program a command ends up running either. npx runs a package from the node_modules of the folder it starts in, or any folder above, before it asks npm, and puts those folders' node_modules/.bin first on the adapter's PATH; on Windows, a .cmd shim looks for node in the current folder first. So every agent process starts in an empty agent-cwd/ folder in app data, and the vault reaches the agent as its session's folder over ACP. Commands are looked up only in PATH's absolute folders, so a relative entry such as node_modules/.bin can't reach into a vault the server was started from.
  • Driving the agent needs the session. GET /api/agent needs the token header like any other API route, and every action is a POST that needs the header, so a website open in the same browser can't send prompts or answer permission requests.
  • The agent never gets the session token. The MCP server entry it's handed carries a per-launch secret that opens Plicine's three tools and nothing else, so an adapter that logs or keeps its configuration gives away no more than those tools.
  • The agent keeps its own tools and its own rules about what needs asking. Plicine advertises no file system or terminal capability over ACP, so it never reads or writes a file on an agent's behalf. When the agent does ask, the palette shows the request with its files and diff, and only a click on one of the offered options answers it. Ending the turn, stopping the agent or starting a new conversation answers anything still open as cancelled.
  • What an agent writes gets no special treatment. A .block it packs, or a ts-block it adds to a document, waits for approval by content hash like anything else that lands in the vault.
  • The primer mixes the app's own words with text from the vault. Each package's BLOCK.md goes in whole, inside a <block-doc> wrapper it can't close early, under a line telling the agent it's documentation from the block's author and not instructions from you. The primer also asks the agent to leave plicine trust and validate --trust alone. That's a request, not a control: the agent runs as you and could run either.

The desktop app

  • The app's native commands are declared in an app manifest and granted only to the bundled launcher page. Vault pages from the local server, and block frames inside them, can only start a window drag or zoom the window (the page stands in for the hidden macOS title bar); every other native call is refused.
  • The window only navigates to the launcher and the running vault's server origin. Links elsewhere open in the default browser.
  • The app's local server is stopped when the app quits, and stops by itself if the app crashes, so nothing is left running.

The PDF renderer

  • A downloaded chrome-headless-shell runs as you, so its archive is checked before any of it is written to disk. Each release of Plicine pins the SHA-256 of each platform's archive next to the pinned version, and a download that hashes to anything else is refused, whether it came from the archive URL or from one the Chrome for Testing feed named. The half-finished .download-* folder is deleted and nothing reaches chrome/<version>/.
  • Entry names are checked for path traversal as the archive unpacks.
  • A browser named by PLICINE_CHROME or PUPPETEER_EXECUTABLE_PATH, or an installed Chrome, Chromium, Edge or Brave, isn't checked. You chose it.

Working with an agent

  • An agent runs with your permissions. It's a program on your machine working in the vault folder, and what it may do without asking is decided by the agent and the mode you pick, not by Plicine. Documents in a shared vault are text the agent reads, and so is every block's BLOCK.md, which the primer includes, so a collaborator or a block's author could write instructions that try to steer it. Keep an agent's ask-before-edits mode on in vaults other people write to.
  • The Claude Code and Codex entries fall back to npx -y, which downloads the ACP adapter from npm the first time and runs whatever version npm serves. Install claude-agent-acp or codex-acp yourself to choose the version.

Reporting a vulnerability

Write to hello@plicine.com with what you found and how to reproduce it. Please don't post it publicly first.