Compare commits

...
Author SHA1 Message Date
jason.woltje 74ec320135 reviews: Q195 S0 amendments A1/A2 from Filbert, D8 ruling (#1798)
M1 adds WOFF1 summed-table and WOFF2 declared-size refusals. F2 is
corrected: ctrl+click is already limited to http, https and mailto, and
the exposure is DOCX save writing targets back; M2 now targets
host-saved DOCX output. CSP report nits N1-N3 fixed. D8 recorded as
ruled by Mos (mos-1363): style-src 'self', rfd hash not approved.
2026-10-22 19:06:03 -05:00
jason.woltje 85ae2be773 reviews: Q195 S0 WordCraft security review and editor CSP measurement (#1798)
Conditional pass on WordCraft e05b7fe8: complete inventory, license
notices resolved, findings F1-F15 with mandatory mitigations M1-M14.
CSP: style-src 'self' records one rfd inline-style violation on the
unmodified artifact, the hash variant records none; the hash is held
for Mos under D8.
2026-10-22 18:58:36 -05:00
jason.woltje b7daf5198b reviews: Q195 S0 partial, WordCraft wasm dependency inventory (#1798) 2026-10-22 18:31:47 -05:00
jason.woltje e36bce3526 docs: name save-group settings and enforce adoption gates 2026-10-22 18:13:49 -05:00
jason.woltje 17a48a7782 docs: fix O2 review gates, shipping and save semantics 2026-10-22 18:10:14 -05:00
jason.woltje e012bab0be docs: redesign Q195 for full WordCraft O2 editor 2026-10-22 18:03:56 -05:00
6 changed files with 8901 additions and 652 deletions

No files matched your search

+18
View File
@@ -5344,3 +5344,21 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
2026-10-22T18:11:57Z | Sage | Row 275 (#1780, Morgana, Duckworth) LANDED 3410df7c3. Before: refactor at 48e450ce8; viewers had no verb list of their own and board reads were not refused to users. After: a fast-forward of 3 commits; manifest 39847b15 (4 paths) blobs match; gate 17:52:12Z-18:05:00Z green (whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 10 scripts with 3 named skips), M6 and M10 killed; Duckworth APPROVE rev 3260 (comment 30717); note and done by sage-2897; #1780 comment 30728 and closed. N1 (board refusal test as a loop over BOARD_VERBS) is non-blocking.
2026-10-22 18:11:58 UTC | Gander row276 #1781 prepared, gate pending | Before: row253 left5legacy Case1 AS_USER calls unsupervised across sudo pty sessions. After: candidate uses its unchanged supervisor with per-call inner/outer budgets, separate streams, strict display-only failure trailer, fake-only paired process-model cases and15unique mutants. Duckworth0182 B1 corrected the plan before product code;0183 cleared build. Parse-only Bash checks pass; no Node, behavioral test, host, unit or config action. Runtime gate and publication await Sage.
2026-10-22T23:03:39Z | Gyro Q195 O2 rewrite, #1752 / Vikunja #448, mos-1356 | Before: landed O3 design 95e3f7166/blob 4cb59999. After: Q231/Q233 full WordCraft O2 design on gyro/editor-o2 from refactor c4f77373f; canonical git DOCX, original Markdown/wiki preservation, human cadence default 0 with instant accepted-save commits, full rich-feature first slice. Ten briefs include independent source/dependency security clearance before any build queue entry, exact editor-only CSP approval gate, restricted source/build/bridge, separate reviewed DOCX citation gate and flags-only agent access. Existing Case 2 semantics unchanged; any integration requirement change goes through Mos to Jason. Static checks: ten briefs/seven fields, independent reviewers, unique anchors, current source/base/blob pins, docs scope and append-only logs pass. No builds, browser/worker runs, code, queue or live changes. Fenton review pending; CSP and implementation gates remain closed.
2026-10-22T23:03:53Z | Gyro Q195 O2 push guard | CI queue guard identity gyro / purpose push returned exit 0, documented degraded-pass: CANNOT_ASSERT credential-unresolvable. Audit: /mnt/storage/scratch/gyro-q195-ci-queue-audit.jsonl. No credential fallback or CI-green claim.
2026-10-22T23:09:38Z | Gyro Q195 O2 fenton-0006 fixes | Before: e012bab0b left stale-source ambiguity, incomplete migration/shipping ownership, undefined typing transactions and an unmeasured CSP gate. After: D12 explicitly routes superseded-source/C2 reply notices to Mos/Jason and refuses adoption before W4b/notice path; Q2 remains open. F2-F6 add schema-edge placeholders and restore-accounting files, shipped runtime data/writing closure with no sandbox store mount, per-chapter DOCX and 2s-pause/15s-span save groups, volume/latency/growth gates, split-run clickable wikilinks and historical Obsidian graph warning, and S0 isolated existing-artifact CSP measurement before exact-header approval. Static ten-brief/ownership/settings/checklist checks pass. No build/browser run, product/queue/live change. Independent re-check pending.
2026-10-22T23:09:50Z | Gyro fenton-0006 push guard | identity gyro, purpose push, exit 0 documented degraded-pass CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl. No fallback credentials or CI-green claim.
2026-10-22T23:10:14Z | Gyro mos-1358 ruling recorded | Q36 / Vikunja 562 keeps C2 superseded notices OPEN with Jason. Markdown adoption refusal until W4b PASS is now a separate settled Mos build-order ruling, not a Jason question. D12 and Q2 distinguish them; no notice build authorized.
2026-10-22T23:13:27Z | Gyro Q195 O2 fenton-0007 F7/F8 | Named human-only editor.saveGroupPauseMs=2000 and editor.saveGroupMaxMs=15000 with integer/range/cross-field validation and W3a UI/CLI refusal tests. W3b owns production adoption refusal until W4b; isolated fixture conversion remains testable without request-flag bypass. W4b owns adoption module/API tests and reviewed lifting only with committed-reader/citation readiness and, if Q36 approves, the separate notice row PASS. Q36 unresolved stays closed. Static ten-brief, setting-key/bounds, refusal/lift ownership and two-question checks pass. Docs only; no build, browser/worker run, product/queue/live change. Fenton checks F7/F8 on next push.
2026-10-22T23:13:48Z | Gyro mos-1359 / r3 push guard | D13 records save-group timing as Mos's accepted reading of Q226 F41, not Jason's ruling; both defaults remain human-only settings. Push guard identity gyro/purpose push: exit 0 documented degraded-pass, CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl, no credential fallback or CI-green claim.
2026-10-22T23:58:36Z | Fenton Q195 O2 S0 (#1798, sage-3058 under mos-1360) | Before: partial inventory b7daf5198, no verdict. After: CONDITIONAL PASS on WordCraft e05b7fe8. Inventory 15 local / 199 normal / 10 build-only / 25 build scripts / 10 proc-macros, 3 git deps by commit. Licenses resolved by a notice bundle (L1: epaint fonts missing from WordCraft NOTICE; self_cell and ryu elect Apache-2.0). Findings F1-F15 (F1 wuff WOFF1 orig_length allocation trap reachable from DOCX open, source only), mandatory mitigations M1-M14 with rejection tests. Browser on the copied artifact: style-src self 1 violation (rfd inline style, removed by host-owned Open), style-hash 0 violations, all functions run; Print blob iframe and cross-origin frame navigation blocked. D8 hash fallback returned to Mos, not recommended. Corrections: style-hash attempt 1 had a harness-made connect-src violation (rerun clean); Print evidence copied from the transcript. No compile, no Console/policy/live change, loopback-only fixtures. Filbert reviews.
2026-10-23T00:06:03Z | Fenton Q195 O2 S0 amendments (#1798, sage-3085, mos-1363) | Before: 85ae2be77 CONDITIONAL PASS; Filbert concurred with A1/A2 required (comment 30914). After: A1 M1 refuses WOFF1 per-table and summed origLength and WOFF2 totalSfntSize/brotli uncompressed_size before decode (WOFF2 a regression case, wuff bounds it at 128 MiB). A2 corrected F2: ctrl+click is already limited to http/https/mailto (canvas.rs:2490-2501, link.rs:7); exposure is DOCX save writing targets back (write/story.rs:335-340); F2 now low, M2 retargeted at host-saved DOCX output plus https under M7. N1-N3 fixed in the CSP report (resize receipt, harness/ prefix, loopback claim). D8 recorded as ruled by Mos mos-1363: style-src self, hash not approved. Docs only, no slot.
+5
View File
@@ -1549,3 +1549,8 @@ are never rewritten or removed; corrections are new entries.
2026-10-22 | Della | Row258 E01 3094e3bfa focused manifest review | APPROVED 11-path manifest/static F1/F2/N1 corrections; file and full-blob digest recipes verified. M1-M11 fixed; all execution/landing evidence still required in CLEAR. No shared-log edit, Node, tests, HTTPS Git or live action.
2026-10-22T17:55:40Z | Sage (Opus 5.5, 1ef1e4f8) | Row 253 landing; post-253 doc sweep | 253 LANDED 48e450ce8. Sweep on sage/sweep-253: Gosalyn Case 2 design and rows, 229 design, Gyro 0acbdb0d8, Della 179391055, 268 docs, seat dirs for Della, Scrooge, Huey, Louie and Ludwig, human-users-build:70 under Q173, DEFERRED 253 N1, 198 N2, row 203 text, 224 residual and 206 sharee list, and seat log lines.
2026-10-22 14:48 UTC | Researcher (Sonnet 5.5, 9bac0794) | Rows 220 and 216 resumed (sage-2708, sage-2716, sage-2719); local commits only | 220 e4a204836 on researcher/row220-r2 (on ea6dc9eff): C1 run tests, C2 default-reader M7 test, configDirectory fixture scope, BUILD.md round 2b. 216 4f3fbc25a on researcher/row216-r2 (on aca16404c): index cache, find/get/search/manuscript/universeInfo, PRD 1.14 levels. Nothing pushed, claimed or run; node --check not run. Waits for Sage's CLEAR and go; rebase onto 253 on a new branch before any gate.
2026-10-22T23:03:39Z | Gyro (Codex gpt-6.1-sol, mos-1356) | Q195/Q231/Q233 O2 design rewrite, gyro/editor-o2 from c4f77373f | Canonical queue next gyro: nothing. Ten docs-only briefs; full-feature WordCraft, independent 15-crate/199-package security review, editor-only CSP candidate, instant git DOCX saves and retained Markdown/wiki sources, committed-only DOCX citation gate, no agent story prose. Static structure/source-anchor/scope checks only; no code, build/run, queue or live change. Fenton review requested after push.
2026-10-22T23:09:38Z | Gyro (Codex gpt-6.1-sol, fenton-0006) | Q195 O2 design corrections on gyro/editor-o2 | F1 requirement boundary sent to Mos, adoption gated; F2-F6 source-checked docs fixes prepared. Ten briefs and two open owner items checked statically. No implementation, build/browser/worker run, queue or live action; Fenton re-check after push.
2026-10-22T23:13:27Z | Gyro (Codex gpt-6.1-sol, fenton-0007) | Q195 O2 r3 F7/F8 | Save-group keys/defaults/validation and W3b refusal/W4b lift ownership explicit. Static docs checks only. Q36 remains open; no implementation or gate lift. Direct Fenton/Mos handoff after push.
2026-10-22T23:58:36Z | Fenton (Claude Opus 5.5, sage-3058) | Q195 O2 S0 WordCraft source security and CSP review, fenton/q195-s0 | CONDITIONAL PASS, M1-M14 mandatory, D8 style-src hash held for Mos. Reviews docs only; Filbert review requested.
2026-10-23T00:06:03Z | Fenton (Claude Opus 5.5, sage-3085) | Q195 S0 amendments A1/A2 and N1-N3 on fenton/q195-s0, D8 ruling recorded | Docs only; Filbert re-check requested.
File diff suppressed because it is too large. Load diff
+184
View File
@@ -0,0 +1,184 @@
# Q195 O2 S0: editor frame CSP measurement
Status: COMPLETE. Feeds the S0 verdict in `2026-10-22_q195-wordcraft-security.md`.
- Review id `s0-20261022`, Gitea #1798. Owner: Fenton. Reviewer: Filbert.
- Spec: `docs/plans/2026-10-21_q195-writing-editor.md` blob `322ec50f` at e36bce352, D7 and D8.
- Scratch: `/mnt/storage/scratch/q195-csp-review/s0-20261022/` (called R below). Receipts are in
`R/receipts/`.
- Nothing was compiled. No Console code, policy or live stack was touched. The harness is a
loopback-only stand-in server with fixture data, and the browser had no external network use:
every request in every run went to 127.0.0.1.
## What was measured
The unmodified WordCraft 0.9.0 web build, framed same-origin under a stand-in shell that sends
the Console shell CSP exactly. Three candidate frame policies differ only in `style-src`.
### Artifact
Copied from Louie's build (origin/louie/wordcraft-eval 880ef19e0); each hash matches Louie's receipt.
| File | SHA-256 |
|---|---|
| index.html (not served; reference only) | `361c9ec8eda95afa7483fef1f6e95270a199cfe59591285096bf22cb06bfef43` |
| wordcraft-fa59e09a3f921e03.ico | `53a23dcf66260f44d87d4d143d633395f3e967f391eda47e6e9cc3a52f820225` |
| wordcraft-web-88f7089f25a3b338.js | `866a1de6a33eff2ded8ae9588fd049aeacfc42557a3863ccd2107b3faf4d691e` |
| wordcraft-web-9bda74d1c7bc6623_bg.wasm | `90dec375924b47b40054eb4ecafb4235b6ed0bd63880b91ec138fdbb6f939e75` |
### Wrapper and harness (mine, scratch only)
| File | SHA-256 |
|---|---|
| harness/www/frame.html | `6a8d141fa8b073cff73fa666fdf030a9f91030924e74a6aa0a441d76a5583f82` |
| harness/www/frame-bootstrap.js | `32c8f71acfe14f3bdc4d31534ed3a84609fc4fb1fb8a1a4a96f56eb28f39d3d4` |
| harness/www/frame.css | `629eb32e9cdf6b97f0ce79f841419ab4a80d42f322c10948ec5d23198799111e` |
| harness/www/shell.html | `cf94f1ce359e8b90f86d39cdef98894d7874e97da9cf7eb0808b0f6e038b3bac` |
| harness/www/shell.css | `372716168beb22dd9fbc7e3d45a76bc06c1b3b1d8f59807a187700689d9ff38c` |
| harness/serve.py | `ad34aed5cdf49a77128778f5f6523698d35f5a6eca771e1dbe53941f09fabe80` |
| harness/instrument.js | `028e914a994a518043dab0b834a9917fc67bc35730f9e3725b59ebd8fa8fb0fa` |
| harness/fixtures/s0-full-feature.docx (9985 B) | `b40eb5875518701d10de1a5419931f02e10c378560fbd5ab6a2ed68d19be53f4` |
The frame wrapper loads the wasm-bindgen module from `frame-bootstrap.js`; it has no inline
script or inline style. The fixture is generated by `harness/make_fixture.py`. It carries
two sections (portrait, then landscape), a header and footer, headings, a table, a small PNG, an
external hyperlink to `https://example.invalid/` and wikilinks. It has no embedded font and no
real writing.
Browser: Chrome 150.0.0.0, Linux x86_64, driven through DevTools MCP isolated contexts. WebGPU
reported "No available adapters", so wgpu ran on its GL backend.
## Headers
Shell (stand-in for `serve.mjs:605`, unchanged, no SSO), sent on `/shell.html`, `/shell.css`,
assets, fixtures and every 404/405:
```
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
```
Frame, sent only on `/editor/wordcraft/frame`:
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src <V>; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; frame-src 'none'; form-action 'none'
```
| Variant | `<V>` |
|---|---|
| style-self | `'self'` |
| style-hash | `'self' 'sha256-9tSqG2Th9gNn6sbn1ZYMSqakJLznxnsamRDeigez2Mo='` |
| style-inline | `'self' 'unsafe-inline'` (defined, not run: the first two answered the question) |
Every response also carries `x-content-type-options: nosniff`, `referrer-policy: no-referrer`
and `cache-control: no-store`. Routes are fixed (`/shell.html`, `/shell.css`,
`/editor/wordcraft/frame`, five allowlisted asset names, `/fixtures/<name>`). Anything else is a
404, and every non-GET method is a 405. Each request and its exact headers are logged to
`requests-<variant>.jsonl`.
Note on the shell header: the stand-in shell sends `frame-ancestors 'none'` as Console does.
The frame is framed by the shell, not the other way round, so this does not affect the
measurement. The real adapter's frame route sends the frame header above.
## Results
| Run | Port | CSP violations | Functions | Receipts |
|---|---|---|---|---|
| style-self full pass | 7391 | **1**: `style-src-elem`, blocked `inline`, js line 93 | all | `full-style-self-events.json`, `requests-style-self.jsonl` |
| style-hash clean pass | 7392 | **0** | all | `clean-style-hash-events.json`, `requests-style-hash.jsonl` |
| frame navigation test | 7392 | 1 in the shell, intended: `frame-src`, enforce | n/a | `browser-console-network.txt` |
| Print probe | 7392 | 1, intended: `frame-src`, blocked `blob` | Print blocked | `print-probe.json` |
Each full pass made exactly 8 requests: the 8 routes, all 200, and the fixture fetch is one of
them. That the client was 127.0.0.1 rests on the server binding 127.0.0.1 only (`serve.py`); the
request log does not record the peer address. The browser's network list showed no request to
any other origin.
The one style-self violation fired between the file-picker click and the fixture feed. Source:
rfd 0.17.2 `src/backend/wasm.rs:127-129` appends its `wasm/style.css` as a `<style>` element. Its
SHA-256 is the hash in the style-hash variant, and the console message names the same hash.
Nothing else in the app adds inline style. eframe and egui write styles through CSSOM, which
`style-src` does not govern. Blocking the `<style>` did not stop any function: the picker still
opened and the fixture loaded.
### Function coverage
Every function in both full passes ran on the same fixture. Event logs record each one.
| Function | Evidence (both passes) |
|---|---|
| Load and start | wgpu GL backend, event handlers installed |
| Open (Browse) | `input[type=file]` click, accept `.docx,.docm,.dotx,.dotm,.doc,.dot,.odt,.rtf,.txt,.md,.html,.htm,.tex,.json,.xml`; fixture fed, title changes to `s0-full-feature` |
| Edit | text inserted and the title gains the dirty mark (screenshots `clean-02`, `selffull-03`) |
| Resize | 1024x700 (style-self also 1280x800 then back). style-self has a `step` event; the style-hash resize has no event record, only screenshot `clean-03-resized.png` and the session transcript |
| Hyperlink ctrl+click | `window.open("https://example.invalid/", "_blank")` |
| Discord title button | `window.open("https://discord.gg/artcraft", "_blank")` |
| Save As (.docx) | `showSaveFilePicker`, then `createObjectURL` + anchor download, 10382 / 10386 B |
| Export PDF | same path, 29014 / 29085 B |
| Export HTML | same path, 1678 / 1689 B |
| Export PNG | same path, 126467 / 128310 B |
| About links | three `window.open(..., "_self")`: getartcraft.com, discord.gg, github.com |
| Sidebar community | `window.open("https://discord.gg/artcraft", "_blank")` |
Byte counts differ between runs because the typed edit text differs.
Print is not counted in the zero-violation result. D7 removes WordCraft's hidden blob PDF
iframe (`web.rs:548-604`) with no blob-frame exception. The probe confirms that path creates
`iframe#wordcraft-print-frame` with a `blob:` PDF and that `frame-src 'none'` blocks it. Print
under the adapter is host-owned and must be measured in W2.
Storage after the style-hash pass: `localStorage["wordcraft.ui"]` (1958 chars, including the
recent file list and author), sessionStorage empty, no cookies. An earlier run also showed the
IndexedDB `wordcraft` font store. See finding F9 in the security review.
### Frame navigation
After the style-hash pass the frame ran `location.href = "http://127.0.0.1:7391/shell.html"`,
another loopback origin. The shell CSP blocked it: `frame-src` falling back to `default-src
'self'`, disposition enforce. No request reached port 7391. The only new requests were three
`data:image/png` loads from Chrome's own blocked-frame error page. Same-origin navigation is not
blocked by CSP. That case needs the sandbox and host link gate (mitigation M7).
## Harness changes, disclosed
The harness changes behaviour in these places. Each one stands in for a host or OS
surface the scratch harness cannot provide.
1. `instrument.js` is injected into the frame by DevTools after load. It is never served. It
replaces `window.open` (records, returns null), `showSaveFilePicker` (records, rejects with
SecurityError so WordCraft's anchor fallback runs), file-input `click` (feeds the fixture from
`/fixtures/`), anchor `click` (records, drops the download) and wraps `URL.createObjectURL`.
A byte-identical copy is `receipts/instrument-as-injected.js`.
2. The `showSaveFilePicker` stub is an async function from the shell realm. WordCraft's
same-realm Promise check rejects it, which logs `web.rs:405: showSaveFilePicker failed: not a
promise` and an unhandled `SecurityError: S0 harness stub`. Both are harness artefacts. The
fallback path that ran is the one a picker failure or refusal takes.
3. DevTools keyboard typing does not reach egui. The edit was a synthetic
`InputEvent('input', {inputType: 'insertText'})` on eframe's hidden text-agent `<input>`. It
exercises the same egui text path. It is not real keyboard input.
4. Clicks are synthetic pointer and mouse events on the canvas (`__tap`).
5. style-hash attempt 1 recorded one `connect-src` violation from my own instrumentation: its
anchor stub fetched the `blob:` URL to measure it. The violation's source was DevTools
(`pptr`), not WordCraft. I removed the fetch and reran clean. Attempt 1's log is kept as
`requests-style-hash-attempt1.jsonl`.
6. style-self attempt 1 ran on an earlier serve.py that predates the current hash and covered
load and open only. Its log is kept as `requests-style-self-attempt1.jsonl` and
`server-style-self-attempt1.log`. The style-self result above is the full pass on
serve.py `ad34aed5`.
7. The Print probe's event excerpt was copied from the session transcript because it was not
saved to a file at the time (`print-probe.json` says so).
## Recommendation for D8
1. The final frame header uses `style-src 'self'`, with no hash, nonce or `'unsafe-inline'`.
The rest is as measured above.
2. D7 already requires host-owned Open/import, which replaces `Services.open_async`
(`web.rs:216-224`). That is the only web caller of rfd, so the measured violation goes away
with it. Mitigation M5 makes this a gate.
3. W2 re-measures the adapter build under `style-src 'self'` and must record 0 violations with
full function coverage, including host-owned Print.
**Ruled by Mos (mos-1363, 2026-10-23T00:05Z).** On the unmodified artifact, `style-src 'self'`
is not violation-free, and the measured fallback was the rfd hash above. Mos ruled that
`style-src` stays `'self'`; the hash is not approved and not kept open. W2 must re-measure 0
violations with full function coverage, including host-owned Print. If W2 cannot remove rfd
from the web path, the change goes back to Mos.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,253 @@
# Q195 O2 S0: WordCraft source security review
Status: COMPLETE. Verdict: **CONDITIONAL PASS**. The baseline source is acceptable for the
O2 adapter only with mandatory mitigations M1-M14 adopted as gates. Amended after Filbert's
review (#1798 comment 30914, A1 and A2). Mos ruled the D8 question in mos-1363 (see "Verdict").
- Review id: `s0-20261022`. Record: Gitea #1798. Owner: Fenton. Reviewer: Filbert.
- Spec: `docs/plans/2026-10-21_q195-writing-editor.md` blob `322ec50f` at e36bce352: D6-D9 and
the "S0 independent WordCraft source security review" section.
- Source: WordCraft `e05b7fe86f3b85454000dffc321de638d7009817` (0.9.0), exported with
`git archive` into the review dir. `Cargo.lock` SHA-256
`06b745e43c13df3df7f048fa6a1981625ce7a5fe4cd484bc6254d0913d865b0b`, byte-identical to the pin.
- Artifact: Louie's web build from origin/louie/wordcraft-eval 880ef19e0, copied into the review
dir, with hashes checked against Louie's receipt. The hashes are listed in the CSP report.
- Companion files: `2026-10-22_q195-wordcraft-security.json` (inventory, findings,
mitigations, per-package notes) and `2026-10-22_q195-editor-csp.md` (browser measurement).
- Independence: I did not author any WordCraft import, patch or browser adapter. I reviewed the
O2 design (fenton-0004 to fenton-0008). That review shaped the wording of D6-D9, not the code.
## Method
Evidence labels:
- **[source]**: I read it in the pinned source.
- **[browser]**: measured in the scratch harness (Chrome 150, Linux).
- **[agent-reported]**: found by a read-only subagent; I did not re-read the cited lines unless
the text says so.
Nothing was compiled, and no code from the tree was run. Browser runs used the copied
prebuilt artifact.
1. Ran `cargo fetch --locked --target wasm32-unknown-unknown` into an isolated `CARGO_HOME`.
Cargo checks every crates.io archive against its `Cargo.lock` checksum.
2. Ran `cargo tree -p wordcraft-web --target wasm32-unknown-unknown --locked` with
`--edges normal` and with `--edges normal,build`, then `cargo metadata --filter-platform`.
3. `harness/inventory.py` joined these into one record per package: lock checksum, declared
license, license and notice files, build script, proc-macro flag, and counts of capability
markers. A marker count is a lead for reading, not a finding.
4. Read the D7 surfaces in all 15 local crates myself, plus the parts of `wuff`, `rfd`,
`eframe` and `epaint_default_fonts` named below.
5. Four read-only subagents traced the same ground in parallel, split as ui-egui and web,
document parsers, fonts/zotero/rest, and third-party hotspots. I re-read every finding that
sets a mitigation's scope (F1, F2, F3, the spin cap in F4, F5's package parts, F6-F14). The
rest stay labelled [agent-reported].
6. Did the license pass from crate files, the craft-libs git checkouts, and a string scan of the
built wasm for embedded fonts and data.
7. Browser measurement: see the CSP report.
## Inventory (complete)
| Set | Count |
|---|---|
| Local WordCraft crates in the web closure | 15 |
| Third-party packages, normal edges (D6's 199) | 199 |
| Third-party build-only packages (build-dependencies) | 10 |
| Third-party packages with a build script | 25 |
| Third-party proc-macro crates | 10 |
The local crates are wordcraft-doc, -docbin, -docx, -engine, -fonts, -formats, -geom, -layout,
-metafile, -pdf, -proof, -render, -ui-egui, -web and -zotero. This matches D6. `control-key` and
`mcp` exist in the source tree but are not in the web closure.
Three packages come from git rather than crates.io and have no `Cargo.lock` checksum:
`craft-pdf` and `krilla` at craft-libs `c3487320` (krilla lives at `vendor/krilla`), and
`craft-ui` at craft-libs `d27646a5`. Their pin is the git commit id.
## Licenses and notices
Declared licenses for the 209 third-party packages (199 normal plus 10 build-only):
- 116 `MIT OR Apache-2.0` and 25 `Apache-2.0 OR MIT`.
- 23 `MIT`.
- The remainder are other permissive forms: Zlib, BSD-2/3, 0BSD and Unlicense options.
- Unicode-3.0, alone or combined: unicode-ident, yoke, yoke-derive, zerofrom, zerofrom-derive.
- Apache-2.0 only: codespan-reporting, unicode-general-category, unicode-linebreak, zopfli.
- Two dual licenses need an election (L4):
- `ryu`: Apache-2.0 OR BSL-1.0.
- `self_cell`: Apache-2.0 OR GPL-2.0-only.
- `encoding_rs`: (Apache-2.0 OR MIT) AND BSD-3-Clause, with a WHATWG license file.
- `epaint_default_fonts`: (MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0.
No copyleft license is forced. The only GPL option is avoided by electing Apache-2.0.
Notice obligations:
- **L1** [source + artifact]: `epaint_default_fonts` is enabled, and the wasm contains its
emoji-icon-font.ttf (MIT), Hack-Regular.ttf (MIT plus the Bitstream Vera license),
NotoEmoji-Regular.ttf (OFL-1.1) and Ubuntu-Light.ttf (UFL-1.0). WordCraft's NOTICE and
ATTRIBUTION do not mention these fonts. The Mosaic notice bundle must ship `OFL.txt`,
`UFL.txt`, `Hack-Regular.txt` and `emoji-icon-font-mit-license.txt` from the crate's `fonts/`.
- **L2** [source + artifact]: WordCraft's own bundled fonts are Inter, JetBrains Mono, Source
Sans 3 and Source Serif 4 (all OFL). It also bundles en-us, it, pl and pt hyphenation data and
the en-extra and ru-extra spelling lists. WordCraft's NOTICE covers all of these, so it must
ship. Its line "Rust dependencies… (MIT/Apache-2.0/BSD/ISC/Zlib)" leaves out Unicode-3.0, BSL
and the font licenses, so it is not a complete notice for the build on its own.
- **L3** [source]: also ship:
- subsetter's NOTICE (ttf-parser, MIT).
- The craft-libs `LICENSE-APACHE`, `LICENSE-MIT` and `NOTICE` at both revs, plus
`licenses/pdf-writer` at `c3487320`.
- encoding_rs's WHATWG text.
- The Unicode-3.0 text.
Thirteen packages carry no license file in the crate. The bundle supplies the standard text
of the declared license for each: accesskit, craft-pdf, craft-ui, ecolor, eframe, egui,
egui-wgpu, emath, epaint, epaint_default_fonts (font licenses are in `fonts/`), krilla,
profiling and wuff. `cfg_aliases` has a NOTICES.md but is build-only, so nothing ships from it.
- **L4**: Record the elections in the bundle: `self_cell` takes Apache-2.0, and `ryu` takes
Apache-2.0.
- **L5** [artifact]: The craft-fonts Japanese fonts are not in this artifact. A scan for "BIZ
UDPGothic" and "Shippori" found 0 matches. If a later build enables them, their licenses join
the bundle.
The license findings are resolved by M13, the notice bundle. None blocks.
## Build scripts and proc-macros
[agent-reported, file list source-verified from metadata]
- Build scripts (25): ahash, crc32fast, encoding_rs, getrandom, libm, naga, num-traits,
parking_lot_core, proc-macro2, quote, rfd, rustversion, serde, serde_core, serde_json, slotmap,
thiserror, unicode-general-category, wasm-bindgen, wasm-bindgen-shared, wgpu, wgpu-core,
wgpu-hal, zerocopy, zmij.
- Agent report: they probe the compiler or target and emit cfgs or generated tables. None
uses the network.
- `wasm-bindgen-shared` runs `git rev-parse HEAD`, which is non-hermetic (F15).
- Proc-macros (10): bytemuck_derive, document-features, multiversion_no_op, rustversion,
serde_derive, thiserror-impl, wasm-bindgen-macro, yoke-derive, zerocopy-derive,
zerofrom-derive.
- `document-features` reads its crate's `Cargo.toml`.
- `rustversion` reads the compiler version and date.
- None writes files or uses the network.
- Build-only packages (10): autocfg, cfg_aliases, rustversion, serde_spanned, toml,
toml_datetime, toml_parser, toml_writer, version_check, winnow.
## D7 trace
| D7 surface | What WordCraft does on web | Evidence | Disposition |
|---|---|---|---|
| Open/import | `Services.open_async` (web.rs:216-224) opens rfd's `<input type=file>` for documents, pictures, fonts and recipients. Accept list includes `.json`, which loads a native `Document` through serde (io.rs:202). | [source][browser] | F3, F8; M3, M5 |
| Save/export | `showSaveFilePicker` (web.rs:398-451), then a `createObjectURL` plus `a.download` fallback. Every save and export took this path. | [source][browser] | F8; M5 |
| Fonts | Embedded DOCX fonts and Add Font decode through `wuff`. User fonts persist in IndexedDB (font_store.rs). `queryLocalFonts` is called (web.rs:274-301). | [source][browser] | F1, F9, F10; M1, M9, M10 |
| Storage | localStorage `wordcraft.ui` (prefs.rs) holds the recent list, author, dictionary, autocorrect and UI keys. No cookies, and sessionStorage is empty. | [source][browser] | F9; M9 |
| Clipboard | Document-level paste listener, added once and never removed (web.rs ~203). | [source] | F11; M11 |
| Objects | vbaProject, attachedTemplate and OLE external relationships survive a round trip. Nothing executes them. | [source][agent-reported] | F5; M6 |
| Hyperlinks | Targets are imported unfiltered (docx read/story.rs:485-496) and DOCX save writes them back unfiltered (docx write/story.rs:335-340). Ctrl+click is already gated: canvas.rs:2490-2501 follows a link only if `is_web_or_mail_link` (doc link.rs:7) allows it (http, https, mailto). The About links and Discord buttons call `window.open` with hard-coded targets. `show_discord` defaults to true (ui-egui lib.rs:376). | [source][browser] | F2, F6; M2, M7 |
| Print/PDF | Hidden `iframe#wordcraft-print-frame` with a `blob:` PDF (web.rs:541-604). `frame-src 'none'` blocks it. | [source][browser] | F7; M8 |
| MCP | The `mcp` crate is not in the web closure. | [source] | none |
| Zotero | A protocol driver that takes a caller-supplied answer callback and has no transport of its own. Its UI actions are disabled on wasm32 (widgets.rs:183). | [source] | none |
| Script and eval | No `eval`, `Function`, `Worker` or dynamic script anywhere in the closure. eframe and egui set styles through CSSOM. The only inline `<style>` comes from rfd. | [agent-reported][browser] | F13 |
| Start error | `set_inner_html` with a formatted eframe error (web.rs:89). It is not derived from the document. | [source] | F12 |
| URL flags | `?webgl` and `?sample` (apps/wordcraft-web/src/main.rs:10). | [source] | F14; M12 |
## Findings
Severity is for the O2 deployment: same-origin frame, single trusted user, documents they
choose. The full text of each finding is in the companion `.json`.
| Id | Severity | Evidence | Finding |
|---|---|---|---|
| F1 | medium | source | `wuff` 0.2.9 trusts the WOFF1 `orig_length`: `decompress_woff1.rs:96` passes it to `decompress_z`, which runs `Vec::with_capacity` at `:15`. The only check is `woff_length < orig_length`. A DOCX with an embedded WOFF reaches this on open: io.rs:164-182 → docx fonts.rs:30 → fonts user.rs:143-163 → wuff. The input cap is 50 MiB; the 128 MiB decoded cap is checked only after decoding. `catch_unwind` (user.rs:166) does nothing on wasm32-unknown-unknown (panic=abort), and allocation failure aborts anyway. Result: the wasm instance traps and unsaved edits are lost. Not reproduced in the browser. |
| F2 | low | source | DOCX hyperlink targets (`javascript:`, `data:`, `file:`, custom schemes) are kept on import and written back unfiltered on DOCX save (write/story.rs:335-340), so a saved file carries them to a desktop Office user. Activation inside WordCraft is already limited to http, https and mailto (canvas.rs:2490-2501, link.rs:7). `is_safe_href` filters the markdown, latex, html, ODT (odt.rs:399) and RTF (rtf.rs:261) exporters, not DOCX. Corrected after Filbert's A2: this row first said activation was ungated and rated it medium. |
| F3 | medium | source + browser | `.json` opens as a native serde `Document` and skips the package caps. The web picker accepts it. |
| F4 | medium | agent-reported, spin cap source-verified | Import exhausts resources on the main thread: encrypted DOCX spin count up to 10,000,000 (crypt.rs:31); no ODT package total; CFB up to 1 GiB; image decode limits 512 MiB; zip has no ratio limit of its own. The frame shares the shell's renderer process. |
| F5 | low | source + agent-reported | vbaProject (package.rs:40/83/87), attachedTemplate (:27) and OLE external relationships are passed through on save. |
| F6 | medium | browser + source | Outbound `window.open` from the hyperlink, Discord, the three About links and the sidebar community link. A cross-origin self-navigation of the frame was blocked by the shell CSP. |
| F7 | low | source + browser | The Print blob iframe is blocked by `frame-src 'none'` and must be removed. |
| F8 | medium | source + browser | Open and Save are browser-owned: rfd's input and the FS Access picker with an anchor-download fallback. |
| F9 | low | source + browser | localStorage holds document-derived data (the recent list, the author); fonts persist in IndexedDB. |
| F10 | low | source | `queryLocalFonts` is called. |
| F11 | low | source | An unconditional paste listener feeds clipboard content to the importer. |
| F12 | low | source | `innerHTML` is used for the start error; not derived from the document. |
| F13 | info | source + browser | rfd injects one inline `<style>`, the only `style-src 'self'` violation. |
| F14 | low | source | The `?sample` and `?webgl` URL flags. |
| F15 | low | agent-reported | Non-hermetic build inputs; no build script or proc-macro uses the network. |
| L1-L5 | license | source + artifact | See "Licenses and notices". |
Filbert's A1 and A2 (#1798 comment 30914) amended F2, M1 and M2; the rows above are the amended text.
No finding blocks on its own. Each medium is closed by a mandatory mitigation with a rejection
test. Two things would turn F1 into a blocker: the adapter failing to gate font decode size, or
the corpus in M14 finding a trap that M1 does not cover.
## Mandatory mitigations
Each mitigation is a gate on the named W brief. Its rejection test must exist and fail on the
unmodified baseline before the mitigation lands.
| Id | Closes | Requirement | Rejection test |
|---|---|---|---|
| M1 | F1 | Before any decode, refuse a font whose declared sizes exceed `MAX_DECODED_BYTES`: each WOFF1 table `origLength`, the WOFF1 sum of `origLength`, and the WOFF2 `totalSfntSize` and brotli `uncompressed_size`. Applies to embedded fonts and Add Font alike. | Each case, embedded in a DOCX and through Add Font, is refused before decode with the editor responsive: (a) WOFF1 table `origLength` 0xFFFFFFFF; (b) WOFF1 tables each under the cap whose `origLength` sum exceeds it; (c) WOFF2 with a huge `totalSfntSize` and brotli `uncompressed_size`. (a) and (b) fail on the baseline. (c) is a regression case: wuff already bails above 128 MiB and a ratio of 100 (decompress_woff2.rs:23,27,85-92), though brotli.rs:60 still allocates up to that bound. |
| M2 | F2 | Host-saved DOCX output carries only http, https and mailto external hyperlink targets; any other target is dropped or kept as inert text. Activation keeps WordCraft's existing `is_web_or_mail_link` gate, and an https activation stays inside the M7 sandbox. | A DOCX with `javascript:`, `data:`, `file:` and `vbscript:` hyperlinks, saved through host Save, has none of those targets in its relationships; this fails on the baseline. Regression: ctrl+click on each makes no `window.open` call, and ctrl+click on an https link opens no browsing context under the M7 sandbox. |
| M3 | F3 | The host import allowlist excludes `.json` and any other native format. | A `.json` file sent through host Open is refused before WordCraft sees it. |
| M4 | F4 | Host caps before handoff: file size, zip entry count and uncompressed total. Encrypted documents are refused, or their spin count is capped low. An import watchdog tears down the frame on a stall. | A zip bomb, an oversize ODT, an encrypted DOCX with spinCount 10,000,000 and an oversize PNG are each refused or aborted, and the shell stays responsive. |
| M5 | F8, F13 | Host-owned Open, Save and Export replace `Services.open_async`, `showSaveFilePicker` and the anchor download. rfd is never called on web. | A full pass under `style-src 'self'` records 0 violations, no file-input click, no `showSaveFilePicker` call and no `a.download`. |
| M6 | F5 | Strip vbaProject, attachedTemplate and OLE external relationships on save, or refuse `.docm` and `.dotm`. | A `.docm` round-trips with neither part present. |
| M7 | F6, F2 | Frame sandbox without `allow-popups` or `allow-top-navigation`. `show_discord` forced false. The About and community links are removed or go through the host link gate. | None of the six measured `window.open` triggers creates a browsing context or sends an off-origin request. |
| M8 | F7 | Remove the blob print iframe. Print goes through the host, and `frame-src 'none'` stays. | Print creates no iframe and records no `frame-src` violation. |
| M9 | F9 | No document-derived data goes into origin storage. The dictionary, autocorrect list and fonts are host-owned, or kept in a partition the host clears. | After open, edit and close, no fixture file name, author or user word remains in localStorage or IndexedDB. |
| M10 | F10 | Disable Local Font Access: `Permissions-Policy: local-fonts=()` on the frame response, or remove the call. | `queryLocalFonts` is missing or rejects, and no prompt appears. |
| M11 | F11 | Pasted content goes through M4's caps. | Pasting an oversize image or deeply nested HTML is refused, and the editor stays responsive. |
| M12 | F14 | The host serves a fixed frame URL and ignores or refuses query strings. | `?sample` and `?webgl` change nothing. |
| M13 | L1-L3 | Mosaic notice bundle as listed under "Licenses and notices", with the L4 elections. | A bundle check maps every inventory package and every font embedded in the wasm to a shipped license text. |
| M14 | residuals | The W6 malformed-input corpus covers deep nesting, panic sites and the PDF writer. | Every corpus input opens or is refused, with no trap and no hang past the watchdog. |
## Residual risks (not cleared by S0)
1. **Recursion depth against the wasm stack.** The DOCX element-depth cap is 256
[agent-reported]. I did not show that every recursive walk (layout, render, the serde
`Document` path) fits in the default wasm stack at that depth. M14 covers this.
2. **Panics.** On wasm32-unknown-unknown every panic traps the instance. S0 did not audit
every `unwrap`, index and arithmetic overflow in the importers. M14 is the empirical
backstop, and M4's watchdog limits the damage to the frame.
3. **krilla and craft-pdf** were reviewed by marker scan only: their filesystem markers are in
docs and tests (krilla lib.rs:42 and :135, util.rs:278). The PDF writer works on WordCraft's
own model, not on raw input, which lowers the exposure.
4. **Decoders** (`image`, `zune-jpeg`, `gif`, `png`) were trusted to their upstream limits
beyond the 512 MiB cap noted in F4.
5. **[agent-reported] items** that were not re-read: the DOCX cap values, ODT and CFB totals,
the OLE paths, the image limits and the build-script behaviour. Filbert may spot-check any
of them. Each cites file and line in the `.json`.
## Verdict
**CONDITIONAL PASS**, against the S0 conditions:
- Complete inventory: yes. 15 local crates, 199 normal, 10 build-only, 25 build scripts,
10 proc-macros, and 3 git dependencies pinned by commit.
- License findings resolved: yes, by M13 and the L4 elections. Nothing copyleft is forced.
- No unresolved blocking security finding: yes, provided M1-M12 are accepted. With any of M1,
M2, M3, M5 or M7 refused, S0 fails.
- An accepted list of mandatory adapter mitigations and rejection tests: M1-M14 are proposed
here. They need acceptance by Filbert's review and Mos.
- Baseline meets the restrictions: yes. The measured frame header holds with every function
working, and the only `style-src 'self'` violation sits on a path that D7 already removes.
**D8, ruled by Mos (mos-1363, 2026-10-23T00:05Z).** On the unmodified artifact, `style-src
'self'` records one violation: rfd's inline `<style>`. I recommended the final header with
`style-src 'self'` and no extra source, and returned rfd's hash
(`'sha256-9tSqG2Th9gNn6sbn1ZYMSqakJLznxnsamRDeigez2Mo='`) as the D8 delta. Mos ruled:
`style-src` stays `'self'`; the hash is not approved and not kept open. M5 gates removing rfd
from the web path. W2 must re-measure 0 violations with full function coverage, including
host-owned Print. If W2 cannot remove rfd, the change goes back to Mos.
## Process disclosures
- Four general-purpose subagents ran read-only traces of the scratch source copy. They were
told not to compile, run code from the tree, use the network or write files. The
document-parser agent reported one slip: it sent a loop's output to `/tmp/none`. The file was
empty and the agent deleted it at once. Nothing else was written.
- The harness changes made for the browser runs are disclosed in the CSP report, "Harness
changes, disclosed".
- I did not read any other seat's scratch apart from copying Louie's artifact, which Sage
authorized (sage-3058). No credential was used or read.