This document describes the security boundary of Drawing Analyzer's outputs and
how it handles your Anthropic API key and project data. The boundary was
established in Phase 17A of the remediation plan and is enforced per release by
the Phase 27 gates: the headless-Chromium exploit suite, a repo secret scan
(scripts/scan_secrets.py), a dependency vulnerability + license audit in CI,
and the recorded acceptance checklist (docs/RELEASE_ACCEPTANCE_TEMPLATE.md).
Drawing content is untrusted input. It is rendered into the images and text layers that are sent to the model as prompts, so the model's output can be attacker-influenced — a malicious or malformed drawing can steer what the model writes back. Every string that reaches a generated artifact is therefore treated as hostile: model findings and prose, the Ask-AI assistant's answers, source filenames, sheet IDs, titles, quotes, citation notes/URLs, evidence paths, parser errors, run errors, and serialized configuration.
On the way in, the same text is fenced. Each request that carries a sheet's
text delimits it and tells the model it is drawing content to read, never
instructions to follow: the per-sheet digest wraps the text layer in
<sheet_text_layer> tags, and every text-only stage (cross-sheet QC,
synthesis, focus, set identity, the review planner's digest fallback, the
absence cross-check, the prose harvest) sends one <sheet id="…"> block per
sheet (sheet_blocks.py). Inside those blocks a < that would begin one of
their tags is replaced by a look-alike before sending, so a page cannot close
its own block or pose as another sheet. This lowers the odds that a crafted
note steers the model; it does not make the output trustworthy, which is why
the output-side boundary below still applies to everything the model returns.
The report is a single self-contained file. Its security rests on four layers:
-
Escaping on the Python side. Every untrusted value is HTML-escaped into element content, or attribute-escaped into attributes. Dynamic values never form tag or attribute syntax. The one machine-readable data island (the Ask-AI config) is emitted as an inert
type="application/json"script, serialized so that every<(and the U+2028/U+2029 line separators) becomes a JSON string escape — no value can close the script element or inject markup, andJSON.parsestill round-trips it byte-for-byte. -
Safe DOM construction on the browser side. The Ask-AI assistant renders the model's streamed Markdown by building DOM nodes with
createElementand filling them viatextContent— neverinnerHTML,outerHTML,insertAdjacentHTML, ordocument.writewith model data. Attack strings in answers, code spans, tables, or thinking summaries appear as inert text. -
One URL policy for every link. Markdown links in answers and the citation chips both go through a single validator that accepts only absolute
https:URLs. It rejectsjavascript:,data:,file:,blob:, protocol-relative, malformed, credential-bearing (user:pass@), and control-character URLs (raw or percent-encoded). A rejected URL degrades to inert visible text — never a live link. Every emitted link carriesrel="noopener noreferrer".The one other kind of link is the Files panel's list of the export folder (
report.htmlsitting besiderun.log, the marked-up PDFs, …). Those targets never come from model text: the export builds the list from the folder it just wrote, the report drops any path that is absolute, drive- or host-qualified, contains./../empty segments or control characters, and the browser percent-encodes every path segment, so a filename such asjavascript:x.pdfbecomes the relative linkjavascript%3Ax.pdf.base-uri 'none'keeps them relative to the report. The assistant'sshare_filetool can only name an entry on that list (or an artifact of the conversation). -
Content-Security-Policy (defense in depth). The report ships a CSP
<meta>tag that:- allows only the exact inline scripts this build emits, pinned by SHA-256
hash (
script-src 'sha256-…') — there is no'unsafe-inline'for scripts and there are no inline event-handler attributes; - restricts
connect-srctohttps://api.anthropic.com(the Ask-AI target) when the assistant is present, and'none'when it is omitted; - forbids objects (
object-src 'none'), base-URI rewriting (base-uri 'none'), and form submission (form-action 'none'); - restricts images to the report's own relative evidence crops.
The exact policy is exercised against
file://in Chromium in the Phase 17B headless-browser test suite; the safe DOM boundary above is mandatory regardless of CSP support. - allows only the exact inline scripts this build emits, pinned by SHA-256
hash (
- The assistant is present by default, whether or not the report was built with a key. It calls the Anthropic Messages API directly from your browser (no server).
- By default the key is never written into the file. The assistant prompts
for a key on first use and keeps it only in the browser tab's
sessionStorage. A Forget key control clears both the in-memory copy andsessionStorage. A401clears the stored prompted key before retry. - On a
file://report,sessionStorageis not isolated per file. Local files do not get distinct origins the wayhttps://pages do, so another local HTML file opened later in the same tab can read the stored key. Measured, not assumed: in headless Chromium a second local page navigated to in the same tab read the key back verbatim; a page in a new tab readnull. The scope is therefore one tab, and the remedies are the ones the widget names — Forget key, or close the tab — plus the ordinary one of not opening untrusted HTML in a tab that has held a key. Serving the report overhttp(s)://gives it a real origin and removes the exposure. - Embedded-key mode is an explicit opt-in (GUI checkbox /
embed_api_key= True). The key is then baked into the HTML; the report shows a red warning, and the file must be treated as a credential. A runtime "forget" cannot remove an embedded key — only regenerating or deleting the file can, and the widget says exactly that. - Pass
include_chat=Falseto omit the assistant (and every network reference) entirely. - The key literal never appears in the default HTML, in rendered chat text, or in logs. Request headers are never logged.
- The conversation auto-saves to your browser's local storage, under a key scoped to that report, so a refresh or reopen no longer loses it. New chat erases the stored copy. Nothing is uploaded — this is the same machine, in the browser's own profile directory.
- A transcript never contains an API key. The serializer scrubs
sk-ant-…from the entire document before it is written, and the schema has no key field at any depth — so a key you paste into the chat box, or one the model echoes back, is redacted in both the stored copy and any saved file. - Know this about
file://: a double-clicked report shares one local-storage origin with every other local HTML file you open in that browser. Scoping the key per report stops reports from overwriting each other, but it is not an isolation boundary — another local page you open could read a stored transcript. Use New chat when that matters, or serve the report overhttps://where each origin is genuinely separate. - Save writes the conversation to a JSON file you choose (the file picker
lets you place it next to
report.html); Load reads one back. A saved transcript holds the full conversation — your questions, the model's answers, the report excerpts quoted into them, and any web-search results — so treat it like the report itself. - A loaded transcript is treated as hostile input, exactly like streamed
model output: it is rendered through the same text-node-only path with the
same https-only link policy, and any tool call recorded in it is drawn as a
label, never executed. The two exceptions only draw: a recorded chart, table
or memo (
create_artifact) is re-validated and re-rendered from its recorded input, and a recordedshare_fileis re-drawn as a link only if its target is on this report's own export list. Neither touches the report page or the network.
- The assistant's
create_artifactinput is validated into a fresh object before anything is drawn, and drawn with the same safe-DOM rules: SVG built withcreateElementNSand numeric attributes, text throughtextContent, colours from a fixed palette (no model string reaches a style or an attribute that could carry markup or a URL). - Downloads are made in the browser from the conversation (
blob:downloads, no request): CSV files neutralize formula sigils the wayfindings.csvdoes (a leading'before=,+,-,@; a plain number is left alone); a memo's.htmlis the serialized safe-DOM render with its owndefault-src 'none'CSP and no script; the SVG carries no script orforeignObject.
Saving a key for future sessions uses an OS-secured credential store —
Windows Credential Manager, macOS Keychain, or Secret Service / kwallet on Linux
— via the optional keyring package. A backend is trusted only after a verified
round-trip (store, then read the value back).
If no secure backend is available, the key is not silently written to disk.
The GUI asks for explicit consent before writing a plaintext fallback file;
declining keeps the key for the current session only. Any consented plaintext
file is created owner-only (0600) on POSIX. Legacy plaintext key files are
migrated into the credential store (and deleted) the next time the key is loaded
or saved on a machine with a working backend.
The optional diagnostics file passes every line through a shared redaction
filter before writing: sk-ant-… key material, Authorization / Bearer
values, and named secret fields (x-api-key, api_key, token, secret,
password, …) are replaced with [REDACTED]. This applies to our own log
lines, the optional SDK wire-capture (DRAWING_ANALYZER_DEBUG), and formatted
tracebacks. Token counts are preserved.
The generated artifacts contain information about your drawings. Treat them accordingly:
report.html— the full digest, findings, and (when embedded) your API key.sheet_text/— each sheet's extracted text layer.evidence/— the image crops the verifier saw for each finding.findings.json/findings.csv— the structured findings.*_reviewed.pdf/Drawing_Set_Review_Notes.pdf— the marked-up drawings.run.log/run_manifest.json/markup_manifest.json— the run record: display filenames, configuration, stage statuses, usage, and artifact hashes. Sanitized at emit time (keys redacted, absolute paths reduced to basenames) but still descriptive of the project — treat like the rest of the export.chat_history.json,chat_files.zipand theA1-….svg/.png/.csv/.md/.htmlartifact files — only if you save them from the report's assistant. They are written by your browser, not by the export, so they are not listed or hashed inrun_manifest.json: the manifest inventories what the export itself wrote and is sealed before any conversation happens. A transcript or artifact sitting in an export folder is expected to exceed the manifest, not evidence of tampering.
The Ask-AI assistant sends the report context and your question to Anthropic. The verification, citation, and QC stages send their described crops and claims to Anthropic. An embedded-key report is a credential and must not be shared.
Please report suspected security issues privately to the repository owner rather than opening a public issue.