All writing

Policy, not sandbox

A container answers what code can do to my machine. It can't answer what a model may do to my notes. Two of my Obsidian tools, two different controls, and the bugs in both.

Essay6 min readFacts checked

Contents

I build two tools at CronAI Labs that put a model next to an Obsidian vault, and they disagree about containers. VaultOre (working v0.1, pre-release) runs its code cells in one. OKF Vault Kit (alpha, v0.1.0) ships without any, on purpose.

I don't think either is wrong. They answer different questions, and keeping those questions apart is most of the work.

Two questions

A container answers: what can this code do to my machine? Which files it sees, whether it reaches the network, how much CPU and memory it gets.

An agent tool has a second question: what may this model do to my notes? Which notes it reads, which it writes, whether it may overwrite, and who signs off.

The kit's security doc puts the gap in one sentence: "a container with a read-write mount of the vault will happily let a prompt-injected model rewrite every file inside it." Writing the vault is the model's job. The useful question is what it may write, and a container has no opinion on that.

What VaultOre's containers do

VaultOre is a Markdown-native workflow engine for Obsidian. There is no published release yet; you build it from source. Workflows are notes with ore: true. ore:ts and ore:shell cells run in Docker, Podman, Colima or Apple's container. Permissions sit in the frontmatter:

---
ore: true
name: Weekly digest
permissions:
  network: deny      # the default
  vaultRead: allow   # the default
  vaultWrite: ask    # the default is deny
---

network: deny becomes --network=none on the container. The vault is mounted at /workspace, read-only unless the workflow holds vaultWrite. That is the right tool for a cell I copied from somewhere and didn't read closely.

Where it can't deliver, it fails closed. On Apple's container, VaultOre refuses to run a network: deny workflow rather than run it un-isolated. The README blames Apple's CLI for having no way to switch the network off. That stopped being true when container 0.6.0 added --network none in October 2025, so the refusal is now VaultOre's limitation, and mine to fix. Refusing is still the right default. A permission that quietly means nothing on one engine would be worse than no permission.

Now the part the container never sees. ore:ai cells don't run in a container at all. They call OpenAI or Anthropic with your own key, and {{note:Path.md}} pastes a note into the prompt. In v0.1, network is one permission per workflow: an AI cell refuses to run without it, and the same flag lifts --network=none for every code cell in that workflow. The cell where untrusted text comes in is the one that opens the network for the others. And a TS cell can read the AI cell's answer with cell("summary"), so the model's output, which a note may have steered, lands in code that can reach the network. That coupling isn't fixed yet.

Open VaultOre issue #45 is titled, in part, "prompt injection via note content". The defect itself is small and very JavaScript: the interpolation calls String.prototype.replace with a string as the replacement, and that string still honours $& and $`.

const tpl = "Summarise this note: {{note:Inbox/clip.md}}\nAnswer in one line.";
const note = "Meeting moved. $` $&";

tpl.replace("{{note:Inbox/clip.md}}", note);
// "Summarise this note: Meeting moved. Summarise this note:  {{note:Inbox/clip.md}}\nAnswer in one line."

tpl.replace("{{note:Inbox/clip.md}}", () => note); // the proposed fix: the note arrives verbatim

$` pasted in the prompt text that came before the placeholder, and $& put the placeholder itself back. A note can splice the prompt's own words into itself.

The proposed fix, still open in that issue, is one arrow function. The same line fails open a second way: if the note has moved, the literal {{note:…}} placeholder goes to a paid API and the run reports success.

Fixing both still leaves the real problem. The note reaches the model word for word, and a model can follow instructions written in it. No container sits on that path. It is an open problem, in VaultOre and in everything else that feeds documents to a model.

The container side has its own gaps, tracked in VaultOre issue #28. The permissions spec lists hardening such as --cap-drop=ALL and --pids-limit=256 that the runtime doesn't pass yet. And vaultRead: deny isn't enforced, because the vault is mounted either way. The AI cell has the same gap without a container: {{note:}} reads the note on the host and never checks vaultRead.

What the kit does instead

OKF Vault Kit is an Obsidian vault template wired to a local 2B-class model you run yourself, which searches and edits the vault over MCP, the Model Context Protocol. Its ALPHA.md states the choice plainly: "The protection that matters — what the model may do to your notes — is in the bridge's policy layer, not in a sandbox." Scheduling is out too: "A knowledge vault that edits itself on a timer needs a permission model we have not built yet."

Every tool call to the kit's own MCP server, the bridge, passes through mcp/bridge_policy.py. On by default:

  • a write deny-list covering people notes and system folders;
  • notes marked sensitivity: confidential are hidden from reads, search and the graph tools;
  • 20,000 bytes per write, 20 writes a minute;
  • an audit line written before each write: if it can't be written, the write doesn't happen;
  • overwriting an existing note is refused.

Opt-in:

  • read-only mode, in which the write tools are never registered;
  • an allow-list, so only the folders on it are writable;
  • propose mode, in which writes become proposals a human applies.

The defaults are flags, so they loosen too: the limits take other numbers, --no-audit drops the audit line, and --show-confidential turns hiding off. What no flag adds is a delete tool. There isn't one.

For machine-level isolation the docs point at what the OS already offers: a separate user, WSL2, bubblewrap, Seatbelt. They also say what the policy layer can't do: "it cannot judge content. A model may still append something wrong to the right note."

Policy layers have bugs too

OKF Vault Kit issue #9, closed by PR #26 before v0.1.0, lists three defaults that contradicted the kit's own security docs:

  1. mcp-config --write also registered the MCP server of qmd, the on-device search engine the kit indexes the vault with. As the kit set it up, qmd indexed the whole vault, confidential notes included, and qmd has no policy layer. That made it a second, unguarded door. It is now opt-in, behind --with-qmd-mcp, and the flag's help text says it exposes confidential notes.
  2. overwrite was a model-supplied argument that no check inspected, so a prompt-injected model could blank any writable note, 20 a minute. It is now denied unless the bridge runs with --allow-overwrite.
  3. llm ask failed open on a confidential note with broken YAML, while the bridge failed closed. There were two implementations of one rule. There still are two, but both now fail closed, they share one helper, and a test pins them to the same answer on the input that split them.

OKF Vault Kit had an earlier hole of the same kind: a blanket except: pass that silently switched off confidential hiding whenever the vault path wasn't canonical.

None of these was a wrong rule. Each was a path the rule never saw: a second server, an unchecked argument, a duplicate implementation, a swallowed exception. VaultOre's vaultRead gap has the same shape: the vault.read() helper checks the permission, while the mount hands over the files anyway and {{note:}} never asks.

A rule of thumb

When I start an agent tool now, I write down two answers before any code:

  • What can this code do to the machine? Containers or OS isolation, for code I didn't write. Keep them.
  • What may this model do to the data? A policy at one choke point, deny by default, failing closed whenever it can't tell.

Then I count the doors: every tool registered, every argument the model controls, every second copy of a rule, every except that turns "I can't tell" into "allowed". Each one is a place the policy has to see, or the model walks around it.

And I size the model's permissions for the day a note lies to it. Neither control stops that day from coming. They only decide what it costs.