# Safety model Diagram source is written by a language model and often quotes repository text, so it is treated as untrusted input. Two things protect the machine it runs on: a check of the source before D2 starts, and the way D2 is started. ## Why the source is checked at all D2 is a full language with file and asset access. This is a real diagram that reads a file: ```d2 a -> b ...@secret/private ``` Given that source, D2 reads `secret/private.d2` from disk and draws its contents as part of the diagram. Absolute paths work too, so running D2 in a private directory does not stop it. `icon` accepts local paths and remote URLs, and `shape: image` loads whatever it is pointed at. A diagram tool without a source check is a file-read and network-fetch tool. ## What the source check allows | Feature | Policy | | --- | --- | | Nodes, edges, labels, containers | Allowed | | Sequence diagrams, SQL tables, class shapes | Allowed | | Built-in shapes | Allowed from a fixed list | | `@` imports, including `...@` spreads | Refused | | `icon:` | Refused | | `link:` | Refused | | `shape: image` | Refused | | `\|...\|` block strings: Markdown, LaTeX, code | Refused | | `d2-config`, `layout-engine` | Refused | Refusals report a code, a line, a column, and what to do instead, so the model can correct the source in one more call rather than guessing. The check lexes the source with comment and string tokens before looking for anything else. That matters in both directions: - `a: "user@example.com"` and `# icon: https://example.com/x.svg` are ordinary text and are allowed. So is `a: user@example.com`, because D2 only treats `@` as an import at the start of a token. - An unterminated quote, or a block string, means the lexer can no longer tell code from content. Both are refused rather than guessed at. D2 rejects unterminated quotes as well, so nothing valid is lost. ## How D2 is started | Control | What happens | | --- | --- | | No shell | `execFile` with an argument array. No command string is ever built. | | No model input in the argument list | The arguments are fixed literals. Source travels only as a file. | | Private working directory | A fresh temporary directory holding one file, `input.d2`, removed when the call ends. | | Minimal environment | Only `PATH`, so a bare command name can be found. Nothing else is passed on. | | Time limit | `--timeout 10` for D2, and a process limit above it in case D2 itself hangs. | | Output limit | The render is stopped past 1 MB, and a drawing too large for a transcript is refused. | | Cancellation | The host's abort signal terminates the subprocess and stops file commits. | | Version check | Below the supported version, the call reports how to install a supported one. No renderer is ever downloaded during a call. | | Fixed layout | ELK, chosen here rather than in the source. | | No path leak | D2 puts absolute paths in its render errors. They are stripped before anything is returned. | ## What is checked after D2 runs D2 exiting zero is not taken as proof the output is usable. Before anything is displayed: - The drawing is not blank. D2's text renderer can return an empty box instead of failing. - Unicode output contains box-drawing characters, so a text-only answer is not mistaken for a diagram. - Terminal text rejects every control character except diagram line feeds. - SVG is parsed as XML. Only static D2 elements, local references, and embedded WOFF font URLs are accepted. Event handlers, active elements, external URLs, unsafe CSS rules, and prototype-key lookalikes are refused. ## Writing files Nothing is written unless a call asks for it, and the repository is never the default. `formats` produces files in a private per-process temporary directory created with `mkdtemp`, so diagrams that quote repository content are not readable by other users of a shared machine. That store is capped, so a long session cannot fill the temp directory. Only `save` reaches the repository, and `save.dir` is required: no directory convention holds across repositories, so the destination has to be named rather than assumed. The directory and file name come from the model, so both are parsed before anything touches the disk. | Control | What happens | | --- | --- | | Relative paths only | An absolute `save.dir` is refused. Control characters and `..` segments are refused. | | No symlink redirect | The deepest existing ancestor is resolved with `realpath` and checked, *before* any directory is created, so a `docs` symlink pointing elsewhere cannot have `mkdir` build the rest of the path on the other side of it. The check runs again after `mkdir`. | | Bounded file names | The name is slugified from `title` or `basename`: lowercase, letters and digits only, 60 characters, no separators, and no Windows device names. Temporary names require a parsed SHA-256 source hash. | | Plain files only | An existing path that is not a regular file, including a symlink, is never overwritten. | | Bundle commit | Every destination is checked and every file is staged before a replacement. Rendering and transcript limits finish before the commit starts. A failed replacement restores prior artifacts. If restoration fails, the backup stays beside the affected artifact and the error names that recovery state. | | Visible before approval | The approval prompt lists the exact paths, and any `save` field uses the `write` tier. | Saved SVG is parsed before it is written. D2's own documentation calls exported SVG web content, so active elements, event attributes, `href`, external CSS URLs, and unsafe CSS rules are refused. Real output contains only static SVG, embedded WOFF fonts, injected CSS, and namespace URIs. ## Drawing the image D2 exports PNG by driving a headless browser that Playwright downloads on first use, which ADR-011 rules out. The image is drawn locally instead, from the SVG this tool already checked. | Control | What happens | | --- | --- | | No browser, no network | resvg draws the SVG in this process. Nothing is fetched or started. | | Checked input | Only a structurally parsed SVG reaches the rasterizer. | | Fonts from the diagram | The faces D2 embedded in the SVG are rebuilt as font files in a fresh temporary directory. Every offset and decompressed length is bounded before it is used. | | Bounded canvas | Both dimensions and the total area are bounded to 1600 by 2400 pixels. An impossible aspect ratio is refused before drawing. | | Checked output | The bytes have to be a PNG with a valid signature, chunk structure and checksums, IHDR, IDAT, final IEND, safe dimensions, and the size that was asked for. The temp-store bytes are parsed again before display. A silently ignored option cannot reach the terminal as a broken image. | | Out of the model's context | The PNG goes to the temp store and is read back when the row is displayed. Its bytes never enter the tool result the model reads. | | Kept between sessions | The image is also held in the render cache, in a private directory outside the repository, and dropped after a week. | | Out of the repository | Only `formats: ["png"]` with `save.dir` writes one into the workspace, through the same path checks as any other artifact. | | Never sent blind | A terminal with no image protocol is not sent an image, and none is drawn for it. | resvg reads the fonts it is given and nothing else, so a label with no glyph would come out as an empty box. Rather than let that happen quietly, the characters in the SVG's text are compared against what the embedded faces can draw. When something is missing, the machine's own fonts are added and the result says so. That is the case for CJK and emoji: D2 has no glyphs for them either, and sizes the boxes as if it had. ## Known limits of D2's text renderer D2's Unicode export is beta. Block string labels (`|md ... |`) can draw an empty box and lose their text, which is one reason the source check refuses more than security alone would require. When Unicode cannot represent a diagram, the call fails with a concise explanation. It never substitutes ASCII or a different diagram.