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_rsaas 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 withdefault-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: trueaddshttps:toconnect-srcandimg-src, and only takes effect for an approved hash whose manifest declares it.storage: truelets 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
plicinemodule). 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'sPrintHTML sits in the app's own document, everydata-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.sourceagainst the iframe's window and hands over aMessagePort. The frame never learns the vault path, the session token or anything about other blocks. - Print output is data.
PrintHTML comes back as a string, goes through DOMPurify (scripts, event handlers, forms, links, iframes and<base>removed) and has its<style>rules wrapped in@scopeso 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@scopeblock early, wherever it's hidden. The static site export does the same throughHTMLRewriter.
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:andconnect-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 withdefault-src 'none'; sandboxandnosniff. 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 ofapplication/octet-stream.
The local server
- Binds to
127.0.0.1on 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 anHttpOnly; SameSite=Strictcookie, named by port, holding a different random value. A browser sends127.0.0.1's cookies to every port on it, so the cookie opens only the page shells, their bundles andassets/, and none of those carry the token. - Every request's
Hostheader must be127.0.0.1:<port>orlocalhost:<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.jsonin 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_modulesof the folder it starts in, or any folder above, before it asks npm, and puts those folders'node_modules/.binfirst on the adapter's PATH; on Windows, a.cmdshim looks fornodein the current folder first. So every agent process starts in an emptyagent-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 asnode_modules/.bincan't reach into a vault the server was started from. - Driving the agent needs the session.
GET /api/agentneeds 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
.blockit packs, or ats-blockit 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.mdgoes 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 leaveplicine trustandvalidate --trustalone. 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-shellruns 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 reacheschrome/<version>/. - Entry names are checked for path traversal as the archive unpacks.
- A browser named by
PLICINE_CHROMEorPUPPETEER_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. Installclaude-agent-acporcodex-acpyourself 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.