Private, on-device Terraform security review for Chrome
A note on two paths these reports mention. The audits were written while the agent task queue lived at
docs/overnight-queue.mdand the agent definitions at.claude/agents/. Both are local working machinery and are no longer tracked in this repository. The reports below are left exactly as written — correcting paths inside a verbatim record would make it something other than a record — so those references describe the tree as it stood on the date of each run.
The privacy-auditor agent (.claude/agents/privacy-auditor.md) was invoked against the
whole tracked tree — not a diff — as queue task T23.
cdd857fbea69478c5a480736115dcbf7faf2b5c3Nothing in this report was fixed. Both violations are egress to a host outside the
api.github.com / huggingface.co allowlist, which is precisely the class T23 and STOP RULE 5
forbid an unattended agent from resolving. They are recorded under “Blocked on human” in
docs/overnight-queue.md. The advisory items A1 and A2 concern public/manifest.json and are
likewise a human’s call.
The report below is the agent’s output verbatim — findings unedited, not summarised and not
softened. The only alteration: the harness that returned the agent’s output HTML-escaped its angle
brackets (< → <), so <script src>, ReadableStream<Uint8Array> and similar appeared
mangled; those have been restored to the characters the auditor actually wrote. No word, claim,
severity or line reference was changed, and one unbalanced backtick — in the False positives
section, in the item beginning **src/lib/model/runtime.ts:586**` — is the auditor’s own and has
been left as-is, which is why that line renders oddly.
Two inaccuracies in the report were found while checking it and are recorded here rather than
edited out of it: it says 66 tracked files where git ls-files reports 67 (a miscount, not a
coverage gap — its sweep was tree-wide greps), and its summary claim that “dist/ built from this
tree contains zero http(s):// strings” is false, because dist/manifest.json carries the two
allowlisted host_permissions URLs. The precise version of that claim later in the same report —
zero such strings in dist/sidepanel.js and dist/background.js — is true and was verified.
Neither affects any finding.
Commit audited: cdd857fbea69478c5a480736115dcbf7faf2b5c3 (branch main, working tree clean for tracked files)
Date: 2026-07-30
Auditor brief: manifest surface, code egress, dependency egress, data handling (PAT + repo contents)
Method: whole tree, not a diff. First audit of this project; nothing assumed pre-checked.
Neither violation is reachable from the built extension at this commit. dist/ built from this tree contains zero http(s):// strings; the shipped code path today is src/background.ts (opens the panel) plus a WebGPU/quota capability check. Both violations are nonetheless real: one is live and reproducible today via an allowlisted npm run script, the other is a latent hole in src/lib/ that becomes live the first day the side panel is wired.
Enumerated with git ls-files: 66 tracked files. Every one was covered — either read in full or swept by the egress greps below (the only exclusion from the greps was package-lock.json, checked separately for cdn/jsdelivr strings: none; its only URLs are registry.npmjs.org resolution entries, which are install-time, not runtime).
The repo root contains untracked entries that are a WSL filesystem overlay, not project files: bin, boot, etc, lib, mnt, sbin, usr, var, plus stray dotfiles (.bashrc, .profile, .zshrc, .gitconfig, .mcp.json, .idea, .vscode, .ripgreprc, and .claude/ local files). I ignored them, and I confirmed nothing project-related hides in them: find on /home/tomsa/git/ferrule/{bin,boot,etc,lib,sbin,usr,var} at depth 1 and 2 returns the directories themselves and nothing else — they are empty stubs, with no read errors suppressed. A depth-1 search for *.ts, *.json, *.tf, manifest* across them returns nothing, and find -maxdepth 1 -type d on the repo root lists only node_modules, spike, public, samples, .git, src, dist, docs, .claude. The one I could not enumerate is /home/tomsa/git/ferrule/mnt, which my own sandbox denies reading; it is a mount stub of the same family and contains no tracked file.
dist/ is gitignored but present; I inspected it as build evidence (see below).
File:line: /home/tomsa/git/ferrule/spike/model-eval/main.js:3
import { Engine } from 'https://cdn.jsdelivr.net/npm/@litert-lm/core/+esm';
Exact egress path: npm run spike (package.json:12 → vite spike/model-eval --open) serves the page from the local dev server. The browser resolves the module specifier at line 3 and issues a cross-origin GET to cdn.jsdelivr.net (Fastly), disclosing the request and its referrer, and then executes third-party JavaScript with full access to that page — including the Terraform in the #code textarea, every fixture, and the window.__engine handle.
There is a second hop in the same file: spike/model-eval/main.js:31 calls Engine.create({ model: url, ... }) without pinning a WASM path first. Engine.create calls getOrLoadGlobalLiteRtLm() with no argument (node_modules/@litert-lm/core/dist/engine.js:54), which falls back to LiteRtLm.DEFAULT_WASM_PATH = https://cdn.jsdelivr.net/npm/@litert-lm/core@0.14.0/wasm (dist/litertlm_web.js:29, applied at dist/load_litertlm.js:84). The loader then injects it as a DOM <script src> with crossOrigin = 'anonymous' (node_modules/@litertjs/wasm-utils/dist/bundle.js:3-8). So running this spike fetches loader JS and multi-megabyte WASM binaries from jsDelivr. Line 34 of spike/model-eval/index.html additionally accepts an arbitrary user-typed model URL with no origin validation, so the model fetch inside Engine.create can go to any host the user pastes.
Mitigating facts, stated so the severity is not overread: this file is not in the extension bundle (vite.config.ts inputs are src/background.ts and src/sidepanel/index.html only; dist/sidepanel.js and dist/background.js contain no URLs at all), the fixtures in spike/model-eval/fixtures.js are synthetic, and docs/m0-results.md records that M0 is already complete. Aggravating fact: .claude/settings.json:51 allows Bash(npm run *), so this egress is reachable by an agent without a prompt. CLAUDE.md constraint 1 makes no exception for spikes.
Minimal fix (either one):
spike/model-eval/ — M0 is done and its results are recorded; orimport { Engine, getOrLoadGlobalLiteRtLm } from '/node_modules/@litert-lm/core/dist/index.js';
// ... and before line 31:
await getOrLoadGlobalLiteRtLm('/node_modules/@litert-lm/core/wasm/');
(Vite’s dev server resolves a bare @litert-lm/core locally too; either form keeps every byte on the machine.)
liteRtEngineFactory validates the WASM path but not the model URLFile:line: /home/tomsa/git/ferrule/src/lib/model/runtime.ts:578-594, with the permissive type at :493-494
export type ModelSource = string | Blob | ReadableStream<Uint8Array>; // :494
Exact egress path: checkedWasmPath (runtime.ts:549-561) closes the jsDelivr hole for the WASM, and it does so correctly. The model source gets no such check. A string source is passed through buildEngineSettings (:513-521) into Engine.create, which does:
const response = await fetch(modelUrl, { credentials: 'same-origin' }); // engine.js:166
node_modules/@litert-lm/core performs no origin validation anywhere on that URL. So liteRtEngineFactory(anyUrlString, localWasmPath) egresses to any host, and the destination is not statically verifiable — it is whatever string reaches the exported factory. src/lib/model/cache.ts has exactly the right guard (checkedModelUrl, :546-573, plus a post-redirect origin re-check at :495-501) and runtime.ts does not reuse it. Nothing calls liteRtEngineFactory in this tree yet, which is the only reason this is not live: the hole is in the exported API of src/lib/, one wiring commit from being reachable, and it is the same class of hole T15 closed for the WASM path and left open beside it.
Minimal fix (either one):
liteRtEngineFactory, validate a string source before the dynamic import: const model = typeof source === 'string' ? checkedModelUrl(source) : source; (import checkedModelUrl from ./cache), orBlob | ReadableStream<Uint8Array> so every caller must come through ModelCache, which already validates the URL, refuses redirects off https://huggingface.co, and caps the byte count. This is the stronger fix: it removes the unverifiable path from the type system rather than guarding it.A1 — No content_security_policy in the manifest; host_permissions is not an egress control. This is the highest-value item in the report. public/manifest.json declares no CSP, so extension pages get the MV3 default script-src 'self'; object-src 'self' — which has no connect-src. Host permissions do not restrict outbound requests; they only exempt listed hosts from CORS. A fetch() from the side panel to any CORS-permissive origin (cdn.jsdelivr.net sends Access-Control-Allow-Origin: *, as would any attacker-controlled endpoint) succeeds and is readable today, with or without a host permission. The only in-browser mechanism that actually enforces the allowlist is connect-src. Recommended (needs human sign-off, per docs/overnight-queue.md:172):
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; default-src 'self'; connect-src 'self' https://api.github.com https://huggingface.co; img-src 'self' data:"
}
'wasm-unsafe-eval' will be needed for the LiteRT WASM regardless, so this CSP has to be written eventually; writing it now buys the connect-src enforcement for free, and script-src 'self' is what makes jsDelivr’s WASM loader script (V1’s second hop) structurally unloadable from an extension page. Note the interaction: a connect-src limited to huggingface.co also blocks the LFS CDN redirect, which is consistent with cache.ts’s existing redirect-refused, and is the same open decision already logged at docs/overnight-queue.md:192-201.
A2 — runtime.ts:571-572 prescribes a web_accessible_resources entry that is not needed and would widen the surface. The comment says bundling wasm/ needs “a Vite asset copy and a web_accessible_resources entry.” The WASM loader creates its <script src> inside the side-panel page, which is same-origin with chrome-extension://<id>/. web_accessible_resources exists to expose resources to web pages and content scripts; it is not required here, and adding it would make the WASM fetchable by listed pages and make the extension fingerprintable by its ID. When the WASM is bundled, do it as a plain Vite asset copy with no WAR entry and no new host permission, and correct that comment so the next reader does not widen the manifest on its advice.
A3 — The jsDelivr guard in runtime.ts does close the hole, but the guarantee is ordering, not structure. Verified in the installed dependency: liteRtEngineFactory calls checkedWasmPath before the dynamic import (runtime.ts:585), then getOrLoadGlobalLiteRtLm(path) (:589) which pins module-global globalLiteRtLmPath (load_litertlm.js:84); the getOrLoadGlobalLiteRtLm() inside Engine.create (engine.js:54) then reuses the already-created promise and never reaches DEFAULT_WASM_PATH. Requesting a different path afterwards throws rather than silently reloading (load_litertlm.js:76-81) — the safe direction. Residual risks: (a) if any other code in the same page reaches Engine.create / loadLiteRtLm() / getOrLoadGlobalLiteRtLm() first, the default CDN path wins and Ferrule’s guard never runs — nothing in src/ does this today, and no test pins that a second code path cannot; (b) globalLiteRtLmPath is not reset when a load fails (load_litertlm.js:44-48 clears the promise, not the path) — harmless, but worth knowing. The CSP in A1 is the structural belt to this guard’s braces. Also note the intended path is not yet bundled, so the real factory currently has no valid argument and inference cannot run in the extension at all — that is fail-closed, and the pressure it creates (“just drop the wasmPath and it works”) is exactly how this guard gets deleted. Whoever bundles the WASM should keep checkedWasmPath and add no default.
A4 — spike/m2-verify handles a real PAT on a dev-server page. spike/m2-verify/index.html:61 is type="password", autocomplete="off"; main.ts:30,46-48 reads it at click time and hands it straight to GitHubClient, which puts it only in an Authorization header. It is never stored, never logged, never in a URL — the file’s header comment states the rule and the code keeps it. Two notes: the catch block at main.ts:93 prints err.url (an api.github.com path containing the private repo’s owner/repo) into the page DOM — local only, no egress, but do not screenshot it into a bug report; and the Vite dev server is a plain HTTP listener on localhost for as long as it runs. Use it, close the tab, stop the server.
A5 — Report persistence must land in chrome.storage.local, never sync. src/lib/report/markdown.ts:204,215 and src/lib/report/narrate.ts:82,564 anticipate round-tripping reports (repo file paths, code snippets, model prose) through chrome.storage.local. That is local and acceptable. chrome.storage.sync replicates through the user’s Google account and would be a straight exfiltration of repo content. src/lib/github/token.ts:6-11 already states this rule for the PAT and is the only place in src/lib/ that names a storage area; when report persistence lands, put the same one-line rule beside it. No code today.
A6 — When the report is rendered in the panel, render it as text or with HTML disabled. src/lib/report/markdown.ts:161-168 escapes \ ` * _ [ ] < & | ~ in every flattened field, explicitly so that a repo-controlled file path cannot become raw HTML (:148-152), and markdown.test.ts:343-358 pins it. That is correct and complete for markdown. The residual risk is the renderer that does not exist yet: a repo-controlled <img src="https://attacker/..."> in a panel that allows raw HTML is a beacon that carries repo-derived strings off the machine. default-src 'self' / img-src 'self' data: from A1 is the structural stop.
src/lib/scanner/types.ts:77,86,92 build https://registry.terraform.io/... and https://developer.hashicorp.com/... URLs. No fetch: these are RuleReference.url strings, and all ~45 call sites in src/lib/scanner/rules.ts pass hardcoded resource-type literals (resourceDocs('aws_s3_bucket_acl') etc.) — never scan-time input. report/markdown.ts never renders them, and there is no href, innerHTML, insertAdjacentHTML, location.* or window.open anywhere in src/. Not egress even if a future UI links them; they carry no repo data.src/lib/model/runtime.ts:586** — await import(‘@litert-lm/core’) is a bare specifier bundled by Vite, not a remote import. Confirmed by inspecting dist/ built at this commit: zero http(s):// strings in dist/sidepanel.js and dist/background.js`.npm run dev, spike, verify-m2): localhost only. Listed because a WebSocket grep hits it in spirit; it is not egress.console.log at src/lib/report/prompt.test.ts:646 and src/lib/scanner/spotcheck.test.ts:32 — the only two console.* calls in the entire tree, both test-only, both printing synthetic samples/webapp fixtures to the vitest terminal. Zero console.* in shipped src/.src/lib/errors.test.ts:253, src/lib/github/client.test.ts:769,955, src/lib/github/token.test.ts:20, src/lib/pipeline.e2e.test.ts:246, and AKIAIOSFODNN7EXAMPLE in spike/model-eval/fixtures.js:57 (AWS’s own documented example key). All synthetic; errors.test.ts:191 exists precisely to assert a PAT inside an error message never reaches user-facing text. No real credential is committed anywhere in the tree..claude/settings.json permits Bash(npm run *) and npm install. Dev-environment configuration, not extension surface — noted only because it is what makes V1 reachable without a human in the loop. Its deny list already blocks WebFetch, WebSearch, curl, wget, nc, ssh, scp.1. Manifest surface — /home/tomsa/git/ferrule/public/manifest.json, 21 lines, read in full.
permissions is exactly ["sidePanel", "storage"] (:6) — nothing beyond the two allowed.host_permissions is exactly ["https://api.github.com/*", "https://huggingface.co/*"] (:7-10). Both scheme-pinned to https, both host-exact, no *. prefix, no <all_urls>, no *://, no broad match pattern of any kind.externally_connectable, web_accessible_resources, content_scripts, optional_permissions, optional_host_permissions, declarativeNetRequest, webRequest, cookies, identity, nativeMessaging, debugger, tabs, unlimitedStorage, content_security_policy (see A1 — the last one is the only absence I would change).dist/manifest.json matches public/manifest.json field-for-field; vite.config.ts copies public/ verbatim and adds no plugin, proxy, external, or CDN base.2. Code egress — complete inventory. Tree-wide greps for fetch(, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, import(, <script, eval(, new Function, new Image, <iframe>, <link>, <form>, location.*, window.open, document.write, @import, src=, action=, wasm, cdn., jsdelivr, unpkg, https?:// across src/, public/, spike/, samples/, docs/, and build config. Non-test result: exactly two fetch call sites in the whole codebase, both through injected interfaces, both statically verifiable.
src/lib/github/client.ts:414 — this.#fetch(target, { headers }). target comes only from url(path) (:533-535), which is the module constant GITHUB_API_ORIGIN = 'https://api.github.com' (:18) concatenated with a locally built path. The origin is not configurable — deliberately, per the file header (:8-11). Every caller-supplied segment goes through segment() (:545-552), which encodeURIComponents it and rejects '', '.' and '..' outright so path traversal cannot retarget the request; refs keep their / but each part is escaped (refPath, :558-563). Blobs are addressed by sha, not path (:236, with the reason at :229-235). Statically verifiable as allowlisted: yes.src/lib/model/cache.ts:485 — this.#fetch(this.#url). #url is set in the constructor from checkedModelUrl (:234, :546-573), which requires parsed.origin === 'https://huggingface.co' — a full-origin comparison, so huggingface.co.example.com, cdn-lfs.huggingface.co and http://huggingface.co are all refused — and requires a .litertlm pathname. The response’s post-redirect origin is then re-checked before the status and before any byte is read (:493-501), so an HF redirect to a CDN is refused rather than followed. cache.test.ts:675-677,996-1003 pins the hostile cases including https://cdn.jsdelivr.net/npm/model.litertlm. Statically verifiable as allowlisted: yes.XMLHttpRequest, no WebSocket, no EventSource, no navigator.sendBeacon, no chrome.runtime.connectNative, no chrome.runtime.connect/sendMessage, no importScripts, no new Worker, no remote import(), and no off-origin <script src> in src/ or public/ — src/sidepanel/index.html:13 is the relative ./main.ts. No dynamic URL construction feeds any network call other than the two above.src/lib/ is chrome.*-free except token.ts:176-182, which keeps the surface auditable in one place.3. Dependency egress. One runtime dependency: @litert-lm/core@0.14.0 (package.json:16), pulling @litertjs/wasm-utils@2.5.3 transitively (package-lock.json:62-76). I grepped both installed dist/ trees exhaustively. The only URLs in either package are Apache licence headers plus DEFAULT_WASM_PATH (litertlm_web.js:29). The only network primitives are fetch(modelUrl) (engine.js:166) and runScript’s <script src> / importScripts (@litertjs/wasm-utils/dist/bundle.js:3-8). No telemetry, no analytics, no update check, no crash reporter, no phone-home of any kind. The sanctioned case — the model coming from huggingface.co — is the engine.js:166 fetch, and see V2 for why its origin is not yet enforced on the runtime.ts side. Dev dependencies (vite, vitest, typescript, @types/chrome, @types/node) do not ship; no postinstall script exists in either runtime package (@litert-lm/core’s prebuild/download-wasm.js is a publisher script and is not in the published files list, so it never runs on install).
4. Data handling — GitHub PAT. Traced end to end and clean.
chrome.storage.local via src/lib/github/token.ts:176-182. sync appears nowhere in the tree except the comment at token.ts:6-11 explaining why it is forbidden.client.ts:411-413 puts it in an Authorization: Bearer header only; client.test.ts:803-810 asserts every request origin is https://api.github.com and :769-782 asserts the token never appears in a request URL.tokenProblem (token.ts:155-166) reports the reason and refuses even to quote the offending character.src/lib/errors.ts reads only .kind — never .message, .url, .stack — with .cause read in exactly one place (:251-257) and only for its class and kind. errors.test.ts pins this with hostile inputs including a token-in-message.src/lib/pipeline/state.ts:82-111 stores { source, kind } pairs, discarding the error object; the header at :26 states the rule.pipeline.ts:311-315 resolves the token from a provider at scan time (() => tokenStore.get()), and it lives only in the GitHubClient instance.console.* in src/, so there is no log destination for it to reach.5. Data handling — repo contents. GitHubClient.fetchTerraformSources → scan() → report → buildNarratePrompt → on-device engine. pipeline.ts:246 builds an in-memory Map<path, content> handed to narration as a source callback (:253) — deliberately, so narration cannot do I/O to get code (:250-252). Terraform text enters exactly one prompt string, which goes only to the local LiteRT engine. Nothing serializes source content into a URL, a query parameter, a header, or a remote destination. The one error message in the prompt builder that could have quoted code (report/prompt.ts:327) quotes only the token-budget number. Model-cache error messages quote byte counts, HTTP statuses and Ferrule’s own model URL, with the rule stated at cache.ts:67-75; that module has never seen a repository, a prompt, or the PAT.
privacy-auditor review plus user sign-off (docs/overnight-queue.md:172).node_modules/@litert-lm/core/wasm into the build — do it as a plain asset copy, with no web_accessible_resources entry (A2), and keep checkedWasmPath with no default.docs/overnight-queue.md:192-201) — cache.ts refuses the LFS CDN redirect today, which is fail-closed and correct. Whoever resolves this must not widen host_permissions to *.hf.co or cdn-lfs.huggingface.co without a fresh audit; a same-origin resolve endpoint or a documented redirect target is the better answer.None of V1, V2, A1 or A2 was fixed by this audit. No file in the tree was modified.
The privacy-auditor agent was invoked a second time against the whole tracked tree —
not a diff — as queue task T30, because the tree had changed materially since the first
audit: the manifest CSP landed, V1 and V2 were fixed, the options page and side panel were
built, an inline Vite plugin bundles the LiteRT WASM into dist/wasm/, and narration now
runs from a user-supplied model file.
89354b8595d5f0f9a5091f0210814bcd3350f50fThe report below is the agent’s output verbatim — findings unedited, not summarised and not softened. Nothing needed restoring this time; the output arrived unmangled.
The report’s load-bearing claims were verified by hand before recording it, per the same
protocol as the first run: the V1 fix (spike/model-eval/main.js:10 imports the bare
specifier, :20 pins WASM_PATH, :48 calls getOrLoadGlobalLiteRtLm before
Engine.create at :49), the V2 fix (src/lib/model/runtime.ts:598 routes a string
source through checkedModelUrl), the manifest CSP and the absence of
web_accessible_resources (full read of public/manifest.json, 25 lines), the tracked
file count (git ls-files reports 85, matching the report — the first run’s miscount is
not repeated), A8’s premise (zero style=/<style in either extension page), and that
storage.sync appears in src/ only inside comments, test string literals and test
titles that state or enforce its prohibition — no code path touches the sync area. All
hold as described.
One inaccuracy in the report was found while checking it (by the reviewer agent) and is
recorded here rather than edited out of it, per the first run’s precedent: sweep section 2
cites “options/index.html:75,121” for the options page’s relative asset references, but
src/options/index.html is 55 lines long — the stylesheet and script references are at
:7 and :53. The substance of the claim (both extension pages reference only relative
assets) is true and was verified independently; only the line numbers are wrong. It does
not affect any finding or the verdict.
Commit audited: 89354b8595d5f0f9a5091f0210814bcd3350f50f (branch main, working tree clean)
Date: 2026-08-05
Previous audit: 2026-07-30 against cdd857fbea69478c5a480736115dcbf7faf2b5c3 (verdict: VIOLATIONS — V1, V2, advisories A1–A6), recorded verbatim in /home/tomsa/git/ferrule/docs/privacy-audit.md
Auditor brief: same protocol as the first audit — manifest surface, code egress, dependency egress, telemetry, PAT handling, repo-content leakage, plus a status ruling on every previously reported finding.
Method: whole tracked tree (85 files per git ls-files), not a diff. Every file was covered by full reads (manifest, vite config, package.json, both spikes, all new/modified modules: src/lib/model/runtime.ts, src/lib/model/handle.ts, src/lib/report/history.ts, src/lib/report/html.ts, src/lib/report/text.ts, src/sidepanel/main.ts, src/options/main.ts, src/background.ts, scripts/*) or by tree-wide greps for https?://, fetch(, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, eval(, new Function, postMessage, BroadcastChannel, chrome.runtime.sendMessage/connect, chrome.tabs, chrome.downloads, chrome.identity, storage.sync, console., innerHTML, createObjectURL, src=, href=, action=, @import, url(, style=, window.open, location.*. package-lock.json was checked separately (install scripts, non-registry URLs). dist/ (gitignored, present) was inspected as build evidence.
Both violations from the first audit are fixed, all six advisories were either implemented or remain acceptable, and no new egress path was found anywhere in the tree. Three new advisories (A7–A9) are recorded below; none is a violation of a stated constraint.
spike/model-eval/main.js:10 now imports from the bare specifier '@litert-lm/core', which Vite’s dev server resolves out of local node_modules — no CDN.main.js:20 pins WASM_PATH = '/node_modules/@litert-lm/core/wasm/' and main.js:48 calls await getOrLoadGlobalLiteRtLm(WASM_PATH) before Engine.create at main.js:49, so the library’s DEFAULT_WASM_PATH (jsDelivr) fallback is never reached — exactly the fix the first audit prescribed.liteRtEngineFactory did not validate the model URL: FIXEDsrc/lib/model/runtime.ts:598: const model = typeof source === 'string' ? checkedModelUrl(source) : source; — a string source now goes through cache.ts’s origin check (origin === 'https://huggingface.co', .litertlm path) before the library is even imported (import at :599). The WASM guard is unchanged and still runs first (checkedWasmPath at :591, pin at :602).src/lib/model/runtime.test.ts:775-779 asserts https://cdn.jsdelivr.net/npm/model.litertlm, https://huggingface.co.example.com/..., https://cdn-lfs.huggingface.co/..., http://huggingface.co/... and https://attacker.example/exfil.litertlm are all refused; :752-753 pins the hostile WASM paths.runtime.ts:592-597 is accurate).public/manifest.json:11-13 now carries "content_security_policy": { "extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://api.github.com https://huggingface.co; img-src 'self' data:; style-src 'self' 'unsafe-inline'" }. This is the recommended policy: connect-src is now the browser-enforced form of the allowlist (a fetch() from an extension page to any other origin is blocked regardless of CORS), and script-src 'self' makes the jsDelivr WASM-loader <script src> structurally unloadable from extension pages. One deviation from the recommendation — style-src 'unsafe-inline' — is new advisory A8 (not an egress channel).
web_accessible_resources when bundling the WASM: FIXED (followed)The WASM is bundled by an inline Vite plugin (vite.config.ts:13-20, plain cp of node_modules/@litert-lm/core/wasm to dist/wasm at :17, no dependency added). public/manifest.json contains no web_accessible_resources entry — verified by full read of the 25-line file. The misleading comment the advisory flagged is corrected: runtime.ts:571-578 now states the WAR entry must NOT exist and why. dist/wasm/ exists in the build (8 files) and the side panel loads it same-origin via chrome.runtime.getURL('wasm/') (src/sidepanel/main.ts:197).
The mechanism is unchanged (runtime.ts:591 check → :599 import → :602 pin, all before Engine.create at :606), and no second code path in src/ reaches Engine.create first — git grep finds Engine.create only in runtime.ts:606 and the spike. What changed: A1’s manifest CSP is now the structural belt in the shipped extension — script-src 'self' blocks the loader script and connect-src blocks the WASM fetch even if the ordering guard were ever bypassed. The residual “first caller wins” risk now exists only on dev-server pages (the spike), where main.js:48 pins before create.
spike/m2-verify handles a real PAT on a dev page: STILL PRESENT (unchanged, acceptable)spike/m2-verify/ was not modified since the first audit (confirmed via git diff --name-status cdd857f..HEAD). Same posture as before: password-type input, token only ever in an Authorization header, never stored or logged; the err.url printed into the local page DOM at main.ts:93 remains a local-only note.
chrome.storage.local, never sync: FIXED (implemented as prescribed)src/lib/report/history.ts landed and follows the rule exactly. The only place a storage area is named is the adapter at history.ts:403-409, which is chrome.storage.local for all three operations; the header (:8-14) and adapter comment (:394-398) state the A5 rationale. history.test.ts:523-567 builds a chrome.storage fake in which any property access on sync throws (:527-537) and asserts at :567 that reads and writes touch only local. Same pattern already pinned for the PAT at token.test.ts:312-356. Stored entries (StoredScan, history.ts:66-77) contain scope, ref, findings and narrations — repo-derived, local-only — and never the token; revival (reviveEntry, :229-280) rebuilds derived state and drops anything malformed without quoting it.
src/sidepanel/main.ts builds every node with createElement/textContent (:66-172); zero innerHTML/outerHTML/insertAdjacentHTML anywhere in src/ (tree-wide grep). The header comment at :1-4 states the rule.src/lib/report/html.ts escapes every string (escapeHtml, :68-75), lets no untrusted text reach an attribute (closed whitelist severityAttr, :109-111), renders model prose as escaped plain text in a pre-wrap block (:238-254), and embeds <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"> (:121, emitted at :325) so even a hypothetical escaping bug cannot beacon — a repo-controlled <img src="https://attacker/..."> is refused by the document’s own policy. html.test.ts:86 sweeps the rendered document for <script, <link, <img, <iframe, src=, href=, url(, @import, http://, https://; :326-357 pins hostile paths and narrations.No new violations. Three new advisories:
A7 — The M0 spike still fetches a user-typed model URL with no origin validation. spike/model-eval/index.html:39 is an <input type="url"> and spike/model-eval/main.js:43,49 passes its value straight to Engine.create({ model: url }), which fetches whatever string it is given. The page copy now instructs a localhost URL and the placeholder is http://localhost:5173/..., but nothing enforces it: a pasted https://anything.example/x.litertlm egresses there from the dev-server page. This is user-directed, dev-only (npm run spike), not reachable from the extension, and carries no repo data at fetch time — which is why it is an advisory, not a violation — but it is the one fetch destination in the tree that is not statically verifiable. Minimal fix: mirror runtime.ts and refuse a URL whose origin is not the page’s own (new URL(url, location.href).origin === location.origin), or delete the spike (M0 is complete per docs/m0-results.md).
A8 — style-src 'unsafe-inline' in the manifest CSP is unused surface. public/manifest.json:12 allows inline styles, but neither extension page has any: zero style=/<style in src/sidepanel/index.html and src/options/index.html (both use external stylesheets, panel.css/options.css, loaded relative). Not an egress channel — any url() load inside an inline style would still be governed by default-src 'self'/img-src — but it is a wider policy than the pages need. Minimal fix: drop 'unsafe-inline' from style-src and confirm both pages still render.
A9 — connect-src https://huggingface.co is retained although the shipped flow no longer downloads the model. The side panel takes the model as a user-picked file (src/sidepanel/main.ts:193-198 — a Blob, bytes-in-hand, no network), so at this commit nothing in the shipped extension fetches from huggingface.co; the allowlist was deliberately not widened toward the HF LFS CDN, which remains the fail-closed answer to the first audit’s open item 3. Keeping the origin in host_permissions and connect-src is consistent with ModelCache (the sanctioned future path) and with CLAUDE.md constraint 1. Nothing to fix; recorded so a future reader knows the retained entry is intentional, and that removing it is also a defensible tightening if ModelCache never ships.
1. Manifest surface — public/manifest.json, 25 lines, read in full. permissions is exactly ["sidePanel", "storage"] (:6). host_permissions is exactly ["https://api.github.com/*", "https://huggingface.co/*"] (:7-10) — scheme-pinned, host-exact, no wildcards. CSP as ruled under A1. New since last audit: options_page (:21) and action.default_title (:22-24) — neither is a permission. Absent, and correctly so: web_accessible_resources, externally_connectable, content_scripts, optional_permissions, optional_host_permissions, webRequest, declarativeNetRequest, cookies, identity, nativeMessaging, debugger, tabs, downloads, unlimitedStorage. dist/manifest.json is byte-identical to public/manifest.json (verified with diff).
2. Code egress — complete inventory. Still exactly two fetch call sites in the whole tree outside tests: src/lib/github/client.ts:414 (origin locked to the module constant GITHUB_API_ORIGIN = 'https://api.github.com' at :18, path segments encoded and traversal-refused) and src/lib/model/cache.ts:485 (constructor-validated checkedModelUrl, post-redirect origin re-check before any byte is read). The side panel hands globalThis.fetch to the pipeline (src/sidepanel/main.ts:205), which routes it only into GitHubClient. The one library fetch (@litert-lm/core engine.js:166) is now guarded at both call sites that can reach it: runtime.ts:598 (V2 fix) and the spike’s user URL (A7). No XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, eval, new Function, chrome.runtime.sendMessage/connect, chrome.tabs, chrome.downloads, or off-origin <script src>/<link href> anywhere in src/ or public/ — both extension pages reference only relative assets (sidepanel/index.html:7,66; options/index.html:75,121), and neither contains an <a href>, a form action, or an inline style. The export buttons use URL.createObjectURL + <a download> click (sidepanel/main.ts:242-248,257-263) — a local file save, revoked immediately, no navigation. src/background.ts is 5 lines and only opens the side panel.
3. Dependency egress. package-lock.json is unchanged since the first audit (git diff --stat cdd857f..HEAD shows only package.json, which added the docs:rules script and adjusted the two spike scripts — no dependency added or updated). Still one runtime dependency, @litert-lm/core@0.14.0, with @litertjs/wasm-utils transitive; findings from the first audit stand (no telemetry, no update check, only DEFAULT_WASM_PATH — now doubly neutralized by the ordering guard and the manifest CSP). All 104 "resolved" URLs in the lockfile are registry.npmjs.org. The only hasInstallScript is fsevents@2.3.3 (package-lock.json:998) — dev-only, optional, os: ["darwin"], a standard Vite transitive that does not run on this Linux machine and never ships. The new doc generator (scripts/run-generate-rules-doc.mjs:13) uses Vite’s runnerImport on a local file with configFile: false — local module execution, no network, and its output (docs/rules.md) is pinned to the registry byte-for-byte by src/lib/scanner/rules-doc.test.ts.
4. Data handling — GitHub PAT. Unchanged core (token.ts, client.ts, errors.ts not modified since cdd857f) plus two new consumers, both clean: src/options/main.ts moves the input value only into TokenStore.set (:90), never echoes it (refresh at :46-64 renders only “a token is saved”-class text), and src/sidepanel/main.ts:206 passes a provider closure (() => store.get()) so the token exists only inside GitHubClient at scan time. Single storage site remains chrome.storage.local (token.ts:178-180); the sync-never-touched property is test-enforced (token.test.ts:316-326,356). Zero console.* in shipped src/ (tree-wide grep: only two test files and the node-only doc script, which prints a rule count).
5. Data handling — repo contents. Terraform text reaches: (a) the on-device engine via one prompt string; (b) the side-panel DOM via textContent only; (c) the two export files the user explicitly saves locally; (d) chrome.storage.local history entries (A5, local-only, test-enforced). Nothing serializes source content or repo paths into a URL, header, query parameter, remote destination, or synced storage. The exported filename embeds the sanitized scope (sidepanel/main.ts:246,261) — a local filename, no egress. ModelError messages never quote prompts or chunks (runtime.ts:47-51,404-408); handle.ts never quotes the file name in an error (:20-22).
6. New model-file plumbing. src/lib/model/handle.ts persists a FileSystemFileHandle in the extension origin’s IndexedDB (ferrule-model-handle, :215) — local profile data, does not sync — with permission mode pinned to 'read' in the type itself (:41-42), so Ferrule can never hold a write grant to the user’s disk. It is not yet wired into any UI surface (no import of RememberedModelFile/indexedDbHandleStore outside its own test), which is fail-closed. The shipped narration path uses a plain <input type="file"> Blob (sidepanel/main.ts:193-198): no network to load the model, WASM same-origin from dist/wasm/.
7. Build evidence. dist/ built from this tree: dist/manifest.json identical to public/manifest.json; dist/wasm/ present with the eight LiteRT files and no WAR entry exposing them. The only http(s):// strings in dist/sidepanel.js are the three registry.terraform.io/developer.hashicorp.com template literals from src/lib/scanner/types.ts:77,86,92 — the same RuleReference.url builders the first audit ruled a false positive, now bundled because the scanner ships in the panel. Still not egress: all call sites pass hardcoded resource-type literals, Finding carries no reference field, and neither the panel (textContent only, no anchors) nor either export (html.test.ts:86 forbids href= outright) ever renders them. dist/background.js and dist/options.js contain zero URL strings.
8. False positives, surfaced deliberately (delta from the first report). The terraform.io templates in dist/sidepanel.js (above); docs/rules.md rendering those same reference URLs as markdown links (a committed doc, generated from hardcoded literals, not runtime surface); hostile URLs in cache.test.ts, runtime.test.ts, html.test.ts, markdown.test.ts (attack fixtures that pin the refusals); the Vite dev-server HMR WebSocket (localhost); blob: object URLs in the export buttons (local, revoked); spike/model-eval/index.html:34’s <a href="https://huggingface.co/litert-community"> (a doc link on a dev page, user-clicked, allowlisted origin). The first report’s false-positive list otherwise stands unchanged.
spike/model-eval/main.js:10,20,48) — residual noted as A7.src/lib/model/runtime.ts:598, pinned by runtime.test.ts:775-779).style-src 'unsafe-inline' unused — drop it), A9 (retained huggingface.co allowlist entry is intentional and unwidened — no action).No file in the tree was modified by this audit.
The privacy-auditor agent was invoked a third time against the whole tracked tree —
not a diff — as queue task T37, because the tree had changed materially since the second
audit: a whole new local-filesystem data source landed (T31’s directory walker and T32’s
panel wiring — repo content now enters Ferrule from the user’s disk as well as from
GitHub), plus suppressions (T33), the scan-vs-last-scan diff and the first history writes
from the panel (T34), the expression resolver (T35), seven new rules (T36), and a
.github/FUNDING.yml created directly on GitHub.
9b24777fd7a9136cb9ca7e402c5591a70ec93459The report below is the agent’s output verbatim — findings unedited, not summarised and not softened. Nothing needed restoring; the output arrived unmangled.
The report’s load-bearing claims were verified by hand before recording it, per the same
protocol as the first two runs: the two-and-only-two fetch call sites at the cited lines
(src/lib/github/client.ts:414, src/lib/model/cache.ts:485 — a tree-wide grep outside
tests finds exactly those); src/lib/local/ containing no fetch/XHR/WebSocket/beacon, no
console.*, and no URL string, with chrome, showDirectoryPicker appearing only in
comments; zero File System Access write APIs (createWritable, createSyncAccessHandle,
removeEntry, getFileHandle, getDirectoryHandle) anywhere in shipped code;
public/manifest.json byte-unchanged since the second audit (git diff 89354b8..HEAD on
it is empty); the history adapter naming chrome.storage.local for all three operations
(history.ts:453-455); RunLocalScanOptions having no fetch and no token member
(pipeline.ts:238-253); the permission mode pinned to 'read' in the handle interface
types (handle.ts:44-45) and at the picker (main.ts:355); diffReports’ one throw
naming neither scope (diff.ts:80-82); .github/FUNDING.yml:6 carrying the Ko-fi value
at commit 64fe034; zero console.* in shipped src/; and storage.sync appearing in
non-test src/ only inside comments stating its prohibition. All hold as described.
One inaccuracy in the report was found while checking it and is recorded here rather than
edited out of it, per the precedent of both prior runs: sweep section 3 says “package.json
and package-lock.json are byte-unchanged since the first audit”, but package.json
changed between the first and second audits (it gained the docs:rules script and adjusted
the two spike scripts — the second audit’s own sweep section 3 records exactly this). The
claim is true of package-lock.json, and both files are byte-unchanged since the second
audit, which is what the finding rests on: no dependency was added, updated or removed.
It does not affect any finding or the verdict.
Commit audited: 9b24777fd7a9136cb9ca7e402c5591a70ec93459 (branch main, working tree clean for tracked files)
Date: 2026-08-06
Previous audit: 2026-08-05 against 89354b8595d5f0f9a5091f0210814bcd3350f50f (verdict: CLEAN — V1, V2 fixed; advisories A1–A9), recorded verbatim in /home/tomsa/git/ferrule/docs/privacy-audit.md
Auditor brief: same protocol as the first two audits — manifest surface, code egress, dependency egress, telemetry, PAT handling, repo-content leakage — plus a status ruling on every previously reported finding (V1, V2, A1–A9), with the new local-filesystem data source traced in depth.
Method: whole tracked tree (97 files per git ls-files), not a diff. Every file in the delta since 89354b8 was read in full or diffed in full (src/lib/local/source.ts, src/lib/local/handle.ts, src/lib/pipeline.ts, src/lib/scanner/{suppress,resolve,hcl,index,rules}.ts, src/lib/report.ts, src/lib/report/{diff,history,html,markdown}.ts, src/sidepanel/{main.ts,index.html}, public/manifest.json, .github/FUNDING.yml, samples/webapp/data.tf); the unchanged remainder was re-swept by tree-wide greps for fetch(, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, navigator.*, https?://, chrome.*, storage.sync, console.*, innerHTML/outerHTML/insertAdjacentHTML, document.write, window.open, eval(, new Function, style=/<style, and — new for this audit, because a filesystem source landed — createWritable, createSyncAccessHandle, removeEntry, getFileHandle, getDirectoryHandle, move(, write(. package.json, package-lock.json, vite.config.ts, tsconfig*.json, src/background.ts, src/options/, src/lib/model/, src/lib/github/ and both spikes are byte-unchanged since the second audit (git diff --name-only 89354b8..HEAD on each: empty), so the second audit’s file-level findings for them carry forward and were spot-verified by the greps above. dist/ (gitignored, present, built 2026-08-06 from this tree — it contains the local-scan and suppression code, 5 showDirectoryPicker/ferrule:ignore hits in dist/sidepanel.js) was inspected as build evidence.
No new egress path exists anywhere in the tree. The new local-filesystem source is read-only, in-memory, and network-free end to end. Every previously reported finding keeps or improves its status. Two new advisories (A10, A11) are recorded; neither is a violation of a stated constraint.
spike/ is byte-unchanged since 89354b8, where the fix was verified line by line (spike/model-eval/main.js:10 bare specifier, :20 local WASM_PATH, :48 pin before Engine.create at :49). Nothing reintroduced the CDN; tree-wide grep for jsdelivr/cdn. in tracked non-lockfile sources: no hits.
liteRtEngineFactory model-URL validation: FIXED (holding)src/lib/model/runtime.ts is byte-unchanged since 89354b8 (checkedModelUrl routing at :598, pinned by runtime.test.ts:775-779). Its only shipped caller is unchanged in behaviour: /home/tomsa/git/ferrule/src/sidepanel/main.ts:89 passes a user-picked File (a Blob — bytes in hand, no fetch) and the same-origin chrome.runtime.getURL('wasm/') path.
/home/tomsa/git/ferrule/public/manifest.json:11-13 still carries the full policy with connect-src 'self' https://api.github.com https://huggingface.co. Full read of the 25-line manifest: unchanged since the second audit. dist/manifest.json is byte-identical (diff: no output).
web_accessible_resources for the WASM: FIXED (holding)No WAR entry in the manifest; dist/wasm/ present with the same eight LiteRT files, loaded same-origin.
runtime.ts and vite.config.ts untouched; the CSP belt from A1 still stands.
spike/m2-verify handles a real PAT on a dev page: STILL PRESENT (unchanged, acceptable)spike/m2-verify/ byte-unchanged since the first audit. Same posture: header-only token, never stored or logged.
chrome.storage.local, never sync: FIXED (holding, surface grew)The adapter at /home/tomsa/git/ferrule/src/lib/report/history.ts:453-455 remains the only storage site and is chrome.storage.local for all three operations. What grew: StoredScan now also persists suppressed entries (finding + user-written reason + comment line, history.ts:77-86,193-199) and the resolver’s note field (:218-221), and the panel now actually writes history (src/sidepanel/main.ts:229 inside showDiffAndRecord). All of it is repo-derived, local-only data going to the same local-only area; sameFinding (history.ts:378-382) and reviveSuppressed (:339-348) extend the revival validation without quoting rejected data. Tree-wide grep: storage.sync still appears only in comments and tests that state or enforce its prohibition.
Zero innerHTML/outerHTML/insertAdjacentHTML/document.write anywhere in src/ (tree-wide grep). Every new DOM write is textContent: the diff status line (src/sidepanel/main.ts:226), the rescan button label carrying the folder name (:301), and all finding rendering (:104-210). The HTML export’s new suppressed section escapes every field (src/lib/report/html.ts — suppressedHtml, all strings through textField/escapeHtml, the badge attribute through the closed severityAttr whitelist) and the document-level default-src 'none' meta CSP is unchanged; the markdown export’s suppressedBlocks runs every repo-controlled string (including the suppression reason, which is literally a comment an author of the scanned code wrote) through escapeInline/codeSpan.
spike/model-eval/ untouched. Still the one fetch destination in the tree that is not statically verifiable, still reachable only from npm run spike on a dev page, still carrying no repo data. Prior minimal fix (same-origin check, or delete the completed spike) still applies.
style-src 'unsafe-inline' unused: STILL PRESENT (premise re-verified)public/manifest.json:12 still allows inline styles; both extension pages still contain zero style= and zero <style (grep of src/sidepanel/index.html and src/options/index.html, including the six lines added to the side panel this cycle — class attributes only). Still not an egress channel; still droppable.
huggingface.co allowlist entry: STILL PRESENT (intentional; case strengthened)Nothing in the shipped extension fetches from huggingface.co at this commit, and the new local-folder flow makes the whole scan runnable with zero network: RunLocalScanOptions (/home/tomsa/git/ferrule/src/lib/pipeline.ts:238-253) has no fetch and no token field at all — the GitHub half is structurally absent, not merely unused. The retained entry remains the fail-closed placeholder for ModelCache; removing it stays a defensible tightening.
No new violations. Two new advisories:
A10 — .github/FUNDING.yml introduces a Ko-fi pointer into the tree. /home/tomsa/git/ferrule/.github/FUNDING.yml:6 (ko_fi: Ko-fi.com/d4ydr34m, commit 64fe034, created directly on GitHub 2026-08-06) is the first tracked file naming a third-party destination outside the allowlist by design. It is not egress and not a violation: the file is GitHub repository metadata rendered as a Sponsor button by github.com’s own UI, it is not bundled (vite.config.ts inputs unchanged; not in dist/), no code path reads it, and CLAUDE.md constraint 1 governs host permissions, fetches and dependencies — this is none of the three. Two notes for the human: (a) the value is malformed — the ko_fi field expects a bare username (d4ydr34m), not a domain-qualified path, so the button may not render as intended; (b) if the project’s public claim material ever says “the repository references no third-party services”, this file is now the exception. No action required for privacy.
A11 — A persistent, re-grantable read capability to a folder on the user’s disk now lives in IndexedDB. RememberedScanDirectory (/home/tomsa/git/ferrule/src/lib/local/handle.ts) stores the picked FileSystemDirectoryHandle in the extension origin’s IndexedDB (ferrule-scan-directory, :225) so the panel can offer one-click rescan. This is the model-file pattern from the second audit’s sweep item 6, now applied to a directory — a strictly wider object (a whole subtree, not one file). It is local-only and correctly minimal: local profile IndexedDB does not sync (:12-13); the permission mode is pinned to 'read' in the type itself (:44-45) and requested as { mode: 'read' } at both call sites (:147,189), matching the picker’s { mode: 'read' } at src/sidepanel/main.ts:355; the tree contains zero File System Access write APIs (createWritable, createSyncAccessHandle, removeEntry, getFileHandle, getDirectoryHandle, move( — grep: no hits in shipped code); Chrome re-gates every session’s reuse behind queryPermission/requestPermission (:147-161), so the stored handle is a bookmark, not a standing grant; and no error thrown in the module quotes the folder name or any data (:114-116,272,291,299-301 — static messages or the platform’s own DOMException). The two as unknown as casts that span the lib.dom gap (src/sidepanel/main.ts:372; src/lib/local/source.ts:157,167) each stand behind a runtime shape check (isScanDirectoryHandle, handle.ts:205-215; the TypeError gate in fromFileSystemDirectoryHandle, source.ts:158-166). Advisory rather than clean-item because it is a genuine widening of what the extension’s local storage contains — a capability, not just data — and a future reader should re-audit any change that touches the pinned 'read' mode or adds a write API.
Local repo content enters at exactly one point and touches exactly four sinks, all local:
showDirectoryPicker({ mode: 'read' }) (src/sidepanel/main.ts:355) → fromFileSystemDirectoryHandle (src/lib/local/source.ts:156-168) → readLocalTerraformSources (:128-146), a bounded, deterministic walk (2,000 files / 50 MiB / 5,000 directories, :21-37) collecting only .tf files (:199) into in-memory SourceFile[]. .git/HEAD is read as plain text with a 64 KiB guard (:56,243), never by invoking git; only a refs/heads/ prefix yields a branch label (:257-265). src/lib/local/ contains no fetch, XMLHttpRequest, WebSocket, console.*, chrome.*, or URL string of any kind (grep: zero hits), and its stated rule — no path, name or content may reach a log, an error message, or the network (source.ts:12-13) — is upheld: the only thrown messages in both files are static (source.ts:165, handle.ts:114-115,272,291,299-301).runLocalScan (src/lib/pipeline.ts:269-293) → applySuppressions + scan (:278) → buildReport (:280). The option bag has no network member (:238-253); scanner/suppress.ts, scanner/resolve.ts and report/diff.ts are pure (headers state it, greps confirm it: no I/O primitive in any of the three).textContent only; (b) the on-device engine via one prompt string, same path as GitHub scans; (c) chrome.storage.local history (showDiffAndRecord, main.ts:219-233); (d) the two export files the user explicitly saves, filenames sanitized to [A-Za-z0-9_-] (main.ts:418,433).describeError (src/lib/errors.ts), which reads only .kind and answers unknown errors — including any DOMException from the walk — with the static fallback at :61; diffReports’ one throw deliberately names neither scope (src/lib/report/diff.ts:80-82, comment states the reason: a scope carries the private repository’s name); showDiffAndRecord’s catch swallows storage refusals without quoting (main.ts:230-232).Repo-controlled strings that newly flow into report surfaces — the branch name from .git/HEAD, the folder name, ferrule:ignore reason text, the clamped unknown-rule-id quoted into an FR-META-001 title (src/lib/scanner/suppress.ts:218, bounded at :239-242), and the resolver’s notes built from clamped identifier names (src/lib/scanner/resolve.ts:146-148,169,173) — were each chased to their sinks: panel textContent, escaped markdown/HTML exports, chrome.storage.local. None reaches a URL, header, log, synced storage, or remote destination.
1. Manifest surface. public/manifest.json, 25 lines, read in full, byte-unchanged since the second audit: permissions exactly ["sidePanel", "storage"] (:6); host_permissions exactly the two allowlisted origins (:7-10), scheme-pinned, host-exact, no wildcards; CSP intact (:11-13); all dangerous keys still absent (web_accessible_resources, externally_connectable, content_scripts, optional_*, webRequest, declarativeNetRequest, cookies, identity, nativeMessaging, debugger, tabs, downloads, unlimitedStorage). dist/manifest.json byte-identical.
2. Code egress. Still exactly two fetch call sites in the whole tree outside tests — src/lib/github/client.ts:414 (origin locked to GITHUB_API_ORIGIN at :18) and src/lib/model/cache.ts:485 (constructor-validated, redirect-refusing) — both statically verifiable as allowlisted, both unchanged. The cycle’s ~6,900 added lines contribute zero network primitives: no fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, eval, new Function, or off-origin asset reference in any new or modified file (per-file reads plus tree-wide greps). The side panel’s six new HTML lines (src/sidepanel/index.html:49-54,67) add two buttons and two text paragraphs — no src, href, or action. chrome.* remains confined to token.ts:178-180, history.ts:453-455, background.ts, and the two UI entry points.
3. Dependency egress. package.json and package-lock.json are byte-unchanged since the first audit — no dependency added, updated or removed in this cycle. The first audit’s exhaustive findings for @litert-lm/core@0.14.0 + @litertjs/wasm-utils stand: no telemetry, no update check, no phone-home; the one sanctioned fetch (engine.js:166) guarded at every reachable call site.
4. Data handling — GitHub PAT. token.ts, client.ts, options/main.ts unchanged. The one behavioural note: the PAT is structurally absent from the entire local-scan flow (RunLocalScanOptions has no token member), so the new feature cannot touch it. Zero console.* in shipped src/ (grep: only the node-only doc script, scripts/generate-rules-doc.ts:94, printing a rule count).
5. Data handling — repo contents. GitHub path unchanged; local path traced above. New this cycle and verified local-only: suppressed findings + reasons and resolver notes in reportId (src/lib/report.ts — hashing input only, a hex id out), in history entries, and in both exports; the diff status line renders counts only (src/lib/report/diff.ts:108-113).
6. Build evidence. dist/ built from this tree (fresh — contains the T31–T36 features): the only http(s):// strings in dist/sidepanel.js are the two registry.terraform.io/developer.hashicorp.com doc-URL template prefixes from src/lib/scanner/types.ts:77,86,92 — same false positive as both prior audits, still rendered by no surface. dist/background.js and dist/options.js: zero URL strings. dist/manifest.json identical; dist/wasm/ the same eight files.
src/lib/scanner/rules.ts:1107 — the word “WebSocket” in a comment about API Gateway v2 stage blocks, and the new lambdaUrlNoAuth rule’s title/rationale containing “URL” (:126-137): grep bait; the seven new rules are pure data + pure functions over ScanContext, and the only URL-shaped things they touch are the hardcoded-literal resourceDocs(...) references ruled non-egress in both prior audits.docs/rules.md — regenerated this cycle, now rendering 52 rules’ reference links to terraform.io/hashicorp.com: a committed document generated from hardcoded literals (scripts/run-generate-rules-doc.mjs, local runnerImport, no network), not runtime surface.src/lib/local/handle.ts / model/handle.ts remove() and cache.ts write() — grep hits for “write/remove” that operate on the extension’s own IndexedDB/Cache storage, not the user’s filesystem. No File System Access write API exists in the tree.local/source.test.ts, local/handle.test.ts, pipeline.local.test.ts, suppress.test.ts, resolve.test.ts, diff.test.ts, rules.test.ts additions) — contain zero URL strings (grep: no hits) and drive fakes only; the hostile-fixture URLs in the pre-existing test files stand as previously ruled.samples/webapp/data.tf additions — synthetic fixture Terraform for the resolver’s golden-equivalence test; no secret-shaped or URL-shaped content..github/FUNDING.yml — see A10: a github.com-rendered pointer, not extension or dev-runtime surface.local only) · A6: FIXED (holding, surface grew — still text-only) · A7: STILL PRESENT (dev-only) · A8: STILL PRESENT (premise re-verified — drop it) · A9: STILL PRESENT (intentional; strengthened by the zero-network local flow).'read', re-audit any change to that pin).chrome.storage.local with no network member even present in its option types, the PAT remains header-only in chrome.storage.local, and the tree still contains exactly two statically-verifiable fetch sites, both allowlisted.No file in the tree was modified by this audit.
The privacy-auditor agent was invoked a fourth time against the whole tracked tree,
before the 0.2.0 release, because the tree’s network surface changed for the first time
since the first audit: the in-app model download shipped (src/lib/model/download.ts,
catalogue.ts, the options page), the Hugging Face host moved from a required to an
optional host permission, and https://*.cdn.hf.co was added to both
optional_host_permissions and the CSP connect-src — exactly the widening the first
audit’s open item 3 said must not happen without a fresh audit. CLAUDE.md constraint 1
and the auditor’s own rule 1 were updated to the new sanctioned surface before the run,
so the audit tests the surface that shipped rather than the wording that predated it.
5a59816d9fcebc0ac8f51eeeb90d0c18e78e84d2The report below is the agent’s output verbatim — findings unedited, not summarised and not softened. Nothing needed restoring; the output arrived unmangled.
The report’s load-bearing claims were verified by hand before recording it, per the same
protocol as the three prior runs: the post-redirect origin check in cache.ts sits
before the response.ok check (cache.ts:528-538, the comment there citing
constraint 1); the side panel’s only ModelCache is built with NO_FETCH
(src/sidepanel/main.ts:28,156); catalogueModel(id) — the one function on the download
path that accepts a string from outside — has no caller outside its own test (tree-wide
grep); src/lib/github/client.ts contains no response.url inspection, which is A13 as
described; and public/manifest.json contains no web_accessible_resources,
externally_connectable, content_scripts or unlimitedStorage. All hold as described.
No inaccuracy in the report was found while checking it. Of the items it leaves for a
human, the following were acted on in the release commit that follows this record:
the A12 wording, the A13 one-line check with a test, the A15 manifest-absence pins, the
CONTRIBUTING.md and docs/PRIVACY.md corrections, and the version bump. A7 and A8
remain open, unchanged, and are the user’s call as manifest/spike decisions.
Commit audited: 5a59816d9fcebc0ac8f51eeeb90d0c18e78e84d2 (branch main; working tree clean for tracked files except CLAUDE.md, whose uncommitted edit is the constraint-1 rewording this audit was told to audit against)
Date: 2026-08-23
Previous audit: 2026-08-06 against 9b24777fd7a9136cb9ca7e402c5591a70ec93459 (verdict: CLEAN — V1, V2 fixed; advisories A1–A11), recorded verbatim in /home/tomsa/git/ferrule/docs/privacy-audit.md
Auditor brief: pre-release 0.2.0 full-tree audit against the updated sanctioned network surface (required api.github.com; optional huggingface.co + *.cdn.hf.co for the in-app model download only; connect-src as the enforcement; no unlimitedStorage), with the new in-app download path traced end to end, plus a status ruling on every prior finding.
Method: whole tracked tree (109 files per git ls-files), not a diff. Every file in the delta since 9b24777 (git diff --name-status: 50 entries) was read in full or diffed in full — public/manifest.json, src/manifest.test.ts, src/lib/model/{cache,catalogue,download,capability}.ts, src/options/{main.ts,index.html,options.css}, src/sidepanel/{main.ts,index.html,panel.css}, src/lib/pipeline.ts, src/lib/pipeline/state.ts, src/lib/report/{rank,collect,html,markdown,prompt,narrate}.ts, scripts/generate-icons.mjs, .github/workflows/ci.yml, .github/FUNDING.yml, .gitignore, package.json, README.md, docs/PRIVACY.md, docs/ROADMAP.md, CONTRIBUTING.md, docs/screenshot.png. Files byte-unchanged since the second audit (git diff --stat 89354b8..HEAD empty): src/lib/model/runtime.ts, src/lib/github/{client,token}.ts, src/lib/errors.ts, src/background.ts, vite.config.ts, both spikes, scripts/{generate-rules-doc.ts,run-generate-rules-doc.mjs}, package-lock.json; unchanged since the third: src/lib/local/*, src/lib/report/history.ts, src/lib/model/handle.ts. The unchanged remainder was re-swept by tree-wide greps for fetch(, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, SharedWorker, postMessage, BroadcastChannel, eval(, new Function, import(, new URL(, https?://, chrome.*, navigator.*, storage.sync, console.*, innerHTML/outerHTML/insertAdjacentHTML/document.write, window.open, location.*, createObjectURL, new Image, <iframe, <script, <link, <a , src=, href=, style=/<style, action=, @import, url(, @font-face, unlimitedStorage, web_accessible_resources, externally_connectable, content_scripts. dist/ (gitignored, built 2026-08-23 21:29 from this tree, after the HEAD commit of 2026-08-19 — dist/manifest.json byte-identical to public/manifest.json by diff) was inspected as build evidence, including the bundled @litert-lm/core chunk and the eight files under dist/wasm/. The installed node_modules/@litert-lm/core and @litertjs/wasm-utils were re-grepped. npm test was run read-only: 32 files, 1814 passed, 2 skipped (the env-gated live catalogue check). Nothing was fetched from the network; no file was modified.
No egress path to a non-allowlisted origin exists anywhere in the tree, shipped or dev. The in-app model download — the cycle’s material change — can only begin at a hard-coded https://huggingface.co/…/*.litertlm URL, re-checks the post-redirect origin against an anchored *.cdn.hf.co pattern before status, headers or body are touched, bounds the bytes twice, and cannot be started from the side panel. The manifest matches the sanctioned surface field for field and is pinned by a closed-list test. Every previously reported finding is fixed, holding, or acceptably unchanged; one (A9) is superseded by the sanctioned design. Four new advisories (A12–A15) and six documentation inaccuracies are recorded; none is a violation of a stated constraint.
spike/ is byte-unchanged since 89354b8 (git diff --stat 9b24777..HEAD -- spike/: empty), where the fix was verified line by line (spike/model-eval/main.js:10 bare specifier, :20 local WASM_PATH, :48 pin before Engine.create at :49). Tree-wide grep for jsdelivr/cdn./unpkg in tracked first-party sources: no hits; the CI job at .github/workflows/ci.yml:73-79 now fails the build on any such reference in src/ or public/.
liteRtEngineFactory model-URL validation: FIXED (holding)src/lib/model/runtime.ts is byte-unchanged since 89354b8; :598 still routes a string source through checkedModelUrl before the library import at :599, with the WASM guard at :591. Both shipped callers now pass bytes, not strings: src/sidepanel/main.ts:136 (a user-picked File) and :157 (handle.body, the ReadableStream out of ModelCache). No string source reaches the factory anywhere in shipped code.
public/manifest.json:23: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://api.github.com https://huggingface.co https://*.cdn.hf.co; img-src 'self' data:; style-src 'self' 'unsafe-inline'. The one change since the third audit is the added https://*.cdn.hf.co source, which is exactly what the updated CLAUDE.md constraint 1 prescribes. src/manifest.test.ts:44-49 pins connect-src as a closed toEqual list, :86-94 pins no 'unsafe-eval' and no https: in script-src, and :52-61 pins that the CSP wildcard and the cache.ts regex mean the same thing. dist/manifest.json byte-identical.
web_accessible_resources for the WASM: FIXED (holding)Full read of the 41-line manifest: no WAR entry. dist/wasm/ holds the same eight LiteRT files; the glue derives its .wasm URL from its own document.currentScript.src (dist/wasm/litertlm_wasm_internal.js:10,105,63-67,299), i.e. chrome-extension://<id>/wasm/, same-origin.
runtime.ts and vite.config.ts untouched. Engine.create still appears only at runtime.ts:606 and in the spike (grep). The DEFAULT_WASM_PATH jsDelivr constant is now visible in the build as dist/assets/dist-YRBJJ1X_.js (the dynamically imported @litert-lm/core chunk) — see false positives; the CI job at ci.yml:59-69 documents and permits exactly that one string.
spike/m2-verify handles a real PAT on a dev page: STILL PRESENT (unchanged, acceptable)Byte-unchanged since the first audit. Same posture.
chrome.storage.local, never sync: FIXED (holding)src/lib/report/history.ts byte-unchanged since 9b24777; adapter :453-455 is chrome.storage.local for all three operations. The panel writes at src/sidepanel/main.ts:345. The new ranking field on ScanReport (src/lib/pipeline.ts:154-164) is not persisted — toStored (history.ts:189-203) copies scope, ref, findings, suppressed and narrations only. Tree-wide: storage.sync appears in non-test src/ only in the three comments stating its prohibition (token.ts:6, history.ts:10,443). The only storage keys in the tree are github.pat (token.ts:25) and report.history (history.ts:39).
Zero innerHTML/outerHTML/insertAdjacentHTML/document.write anywhere in src/ (grep). Every new DOM write this cycle is textContent: the fix-order list (src/sidepanel/main.ts:232-254), the options page’s model rows (src/options/main.ts:174-217, comment at :122-123 states the rule), and all status/feedback lines. The HTML export’s new fix-order section (src/lib/report/html.ts:289-312) runs every field through codeField/textField/escapeHtml and the closed severityAttr whitelist; the document-level default-src 'none'; style-src 'unsafe-inline' meta CSP is unchanged (html.ts:132,440); html.test.ts:95 still sweeps the whole rendered document for <script, <link, <img, <iframe, src=, href=, url(, @import, http://, https://, and :345-370,549-570,776-789 pin hostile titles, files, reasons and narrations. The markdown export’s fixOrderBlocks (markdown.ts:288-306) uses codeSpan/escapeInline; markdown.test.ts:348-363 pins that [link](http://evil.example) in a title is emitted with escaped brackets. The four committed snapshots contain no URL, script, link, src=, href= or url( (grep).
spike/model-eval/ untouched. Still the only fetch destination in the tree that is not statically verifiable; still reachable only from npm run spike.
style-src 'unsafe-inline' unused: STILL PRESENT (premise re-verified)public/manifest.json:23 still allows inline styles; both extension pages still contain zero style= and zero <style (grep of src/sidepanel/index.html, src/options/index.html); both stylesheets contain zero url(, @import, @font-face. Still not an egress channel; still droppable.
huggingface.co allowlist entry: SUPERSEDEDThe premise (“nothing in the shipped extension fetches from huggingface.co; the entry is a placeholder for ModelCache”) no longer holds: ModelCache shipped. The entry moved from host_permissions to optional_host_permissions (manifest.json:18-21) and the allowlist was widened to https://*.cdn.hf.co — the exact widening the first audit’s open item 3 said must not happen “without a fresh audit”. This is that audit, and the widening is acceptable on four grounds verified in code: (1) the CDN is never a start origin — checkedModelUrl (cache.ts:583-610) requires parsed.origin === 'https://huggingface.co', and cache.test.ts:752 pins that a direct https://us.aws.cdn.hf.co/model.litertlm is refused; (2) the post-redirect check is an anchored full-origin regex, /^https:\/\/(?:[a-z0-9-]+\.)+cdn\.hf\.co$/ (cache.ts:198), so cdn.hf.co, us.aws.cdn.hf.co.example.com, http://us.aws.cdn.hf.co and https://evil.com/?x=https://us.aws.cdn.hf.co are refused (cache.test.ts:736-744), and a non-default port would not match either (URL.origin includes it); (3) the pattern is the narrowest that works (*.cdn.hf.co, not *.hf.co), requires at least one label, and hf.co is Hugging Face’s own domain; (4) the three spellings — manifest optional_host_permissions, CSP connect-src, MODEL_CDN_ORIGIN — are cross-checked by manifest.test.ts:52-61 and download.test.ts:55-85.
.github/FUNDING.yml Ko-fi pointer: STILL PRESENT (acceptable; note (a) resolved)Commit d217ed0 corrected the value to a bare username (ko_fi: d4ydr34m, FUNDING.yml:6), resolving the malformed-value note. Still GitHub repository metadata rendered by github.com, not bundled (vite.config.ts unchanged; not in dist/), read by no code path. Not egress.
src/lib/local/handle.ts byte-unchanged; the picker still passes { mode: 'read' } (src/sidepanel/main.ts:477); restore() is called inside a click (:510) and with { prompt: false } at init (:580). Zero File System Access write APIs in shipped code (grep: createWritable, createSyncAccessHandle, removeEntry, getFileHandle, getDirectoryHandle, move( — none). RememberedModelFile (src/lib/model/handle.ts) remains unwired outside its own test (grep: no importer).
No new violations. Four new advisories:
A12 — Revoking the optional permission is enforced by Ferrule’s code path (and by CORS), not by the CSP; three places say otherwise. MV3’s connect-src is static: manifest.json:23 lists https://huggingface.co and https://*.cdn.hf.co unconditionally, so after “Revoke access” (src/options/main.ts:319-329 → chrome.permissions.remove, download.ts:104-106) the browser’s policy still permits a connection to those hosts. What actually stops one: (a) no shipped code constructs a fetching ModelCache except startDownload, which returns before any network call unless requestModelPermission resolved 'granted' (options/main.ts:260-274), and the panel’s only ModelCache carries NO_FETCH (sidepanel/main.ts:156); (b) without the host permission a cross-origin fetch falls back to ordinary CORS — which only blocks if Hugging Face’s servers are not CORS-permissive, and a host that serves model files to browser clients generally is (I did not verify this on the network, per the brief). So the grant is a genuine consent gate and the revocation is real for Ferrule’s code, but it is not “the browser enforcing it”. Overstated at src/lib/model/download.ts:10-11 (“it is the browser enforcing it rather than this code promising it”), docs/PRIVACY.md:43-46 (“Ferrule cannot reach them until you allow it”), and — mildly — README.md:148-149. Not a violation: the constraint asks for optional permissions, revocability and the connect-src list, all of which hold. Minimal fix: reword the three passages to “Ferrule’s code will not start a download without the grant, and the browser will not let it bypass CORS without one” — no code change is available, since a conditional connect-src does not exist in MV3.
A13 — The GitHub client does not re-check the response origin after a redirect, unlike the model cache; docs/PRIVACY.md:79-82 claims both do. src/lib/github/client.ts:414-415 reads response.ok and .json() without inspecting response.url; cache.ts:532-538 does inspect it. Residual exposure is narrow: connect-src blocks any hop to an origin outside the three, Chrome strips Authorization on cross-origin redirects, and the only CSP-permitted foreign hop (api.github.com → huggingface.co or *.cdn.hf.co) would carry the request path — which names the private owner/repo and a blob sha — but not the token, and requires api.github.com itself to misbehave. Advisory because it is the same class of check the first audit asked for on the model path and it is cheap. Minimal fix: after the fetch, if (new URL(response.url).origin !== GITHUB_API_ORIGIN) throw new GitHubError(...) (treating an empty url as unreported, as cache.ts:620-627 does) — or correct the PRIVACY.md sentence to say the post-redirect check exists on the model path only.
A14 — The options page now contains the first outbound hyperlink in a shipped extension page. src/options/index.html:59-61: <a href="https://huggingface.co/litert-community" target="_blank" rel="noreferrer">, carried into dist/src/options/index.html:61. A navigation, not a fetch: connect-src does not govern it, no host permission is needed for it, it fires only on a user click, it carries nothing repo-derived, and rel="noreferrer" keeps the chrome-extension://<id> referrer off the wire. Not a violation (allowlisted origin, user-initiated). Recorded because the second and third audits’ sweep statement “neither page contains an <a href>” is no longer true, and a future reader diffing against them should know the invariant changed deliberately. Minimal fix: none required; keep rel="noreferrer" and do not let the pattern spread to repo-derived text.
A15 — src/manifest.test.ts pins every positive list but no absence. It asserts permissions, host_permissions, optional_host_permissions and connect-src as closed lists (:44-49,73-78,83) and the script-src shape (:86-94), but nothing fails if a future edit adds web_accessible_resources, externally_connectable, content_scripts, optional_permissions (the non-host kind), or drops default-src 'self'/object-src 'self'. Those absences have been verified by hand in four audits; they should be verified by the test. Minimal fix: five expect(manifest).not.toHaveProperty(...) lines and one expect(csp).toContain("default-src 'self'").
Documentation inaccuracies relative to the code (not findings, recorded per the brief):
CONTRIBUTING.md:9-10 — “The extension talks to api.github.com and (reserved) huggingface.co”: huggingface.co is no longer reserved, and *.cdn.hf.co is missing.docs/PRIVACY.md:9 — “Last updated: 2026-08-06”, but the file was materially rewritten in commit 426af51 (2026-08-19) to describe the optional permissions.docs/PRIVACY.md:67 — storage is said to save “your preferences”; no preference is stored. The only keys are github.pat and report.history.docs/PRIVACY.md:16 — Terraform is “never stored on disk by Ferrule”: narrowly overstated. Recent reports (:24-25) persist findings, ferrule:ignore reason text (verbatim comment text from the scanned .tf), and model narrations (prose generated from a prompt that embeds code context and can quote it) in chrome.storage.local; the remembered folder handle sits in IndexedDB. All local, all described elsewhere in the same document, but the sentence should say “except as part of the recent reports below”.docs/PRIVACY.md:79-82 — “checks the origin against a closed list before and after any redirect” is true of cache.ts only (A13). “Reached from exactly two places” is true of the two injected consumers (client.ts:414, cache.ts:522); the global fetch is referenced at two UI sites (options/main.ts:159, sidepanel/main.ts:373), which the sentence could name.README.md:41 and public/manifest.json:4 / package.json:3 all say 0.1.0; this audit is for release 0.2.0 (see “Items a human must decide”).Also stale, not inaccurate: docs/screenshot.png shows the pre-download panel copy (“Ferrule itself never downloads it”), which the current src/sidepanel/index.html:38-42 no longer says.
1. Where a URL can come from — only the catalogue. src/lib/model/catalogue.ts:42-57 is a readonly table of two entries whose url fields are string literals on https://huggingface.co/litert-community/…/resolve/main/*.litertlm. Every entry is run through checkedModelUrl at module scope (:87), so a bad literal throws on import — before any UI exists — and catalogue.test.ts plus manifest.test.ts:63-67 pin that every entry’s origin is in connect-src. The one function that takes a string from outside, catalogueModel(id) (:69-71), returns an entry by id equality and has no callers outside its own test (grep). No user input, no stored value, no message and no query string feeds a URL anywhere on this path: the options page maps MODEL_CATALOGUE to rows (options/main.ts:332,349) and each row’s model.url goes straight into new ModelCache({ url: model.url, fetch: (url) => fetch(url), store }) (:159), where the constructor re-validates it (cache.ts:271). The live-API size check in catalogue.test.ts:78-91 is the only place a network request could originate in tests; it is gated on FERRULE_CHECK_MODEL_URLS=1, uses HEAD with redirect: 'manual', and was confirmed skipped by default.
2. The permission gate — user gesture, and nothing else asks. chrome.permissions.request is called in exactly one place, download.ts:91, reached only from startDownload (options/main.ts:260), reached only from the Download button’s click listener (:214), with nothing awaited above it (the comment at download.ts:67-73 records the bug that taught them this; download.test.ts:98 pins it). The request names both patterns together (MODEL_ORIGIN_PATTERNS, download.ts:28-31; pinned at download.test.ts:115). On anything but 'granted' the handler returns before the store is even opened (options/main.ts:261-274). Page load calls only permissions.contains (hasModelPermission, download.ts:57-62), which never prompts, and treats a rejection as “not granted”. Revocation: the Revoke button → dropModelPermission → chrome.permissions.remove on the same two patterns (download.ts:104-106); the row is hidden once contains answers false (options/main.ts:235-241). Outside the extension (inExtension() false) every download button is disabled (:349-353). What revocation does and does not enforce is A12.
3. The request — starts at huggingface.co, checked before anything is read. ModelCache.open (cache.ts:315-343) reads the store first and makes no request on a hit. On a miss, #startDownload (:519-554) calls the injected fetch with this.#url — the constructor-validated string — and then, before .ok, before .status, before headers and before .body, takes responseOrigin(response.url) (:620-627: an unparseable URL is returned raw so it cannot equal an allowed origin; only the empty string means “unreported”) and refuses anything isAllowedModelOrigin (:210-212) rejects with redirect-refused (:532-538). ModelResponse.url is a required field of the injected type (:100-117) so an adapter cannot switch the check off by omission. The connect-src covers the hops the code cannot see (:200-209 explains why redirect: 'manual' is not usable).
4. The bytes — bounded twice, counted, never buffered. #download (:388-516) refuses a content-encoding other than identity (:396-402), refuses a missing or non-positive content-length (:403-415, byteCount at :735-739), refuses a promised length over MAX_MODEL_BYTES = 8 GB (:416-421), then pipes response.body through countedStream with a hard limit equal to the promised length (:445-458): one byte past it errors the stream and cancels the source reader (:696-703), so the cap is on bytes written, not on a header. After the store resolves, loaded !== totalBytes is an incomplete-download and the entry is discarded (:506-512); a write that rejects is discarded too (:468-494). Progress reports carry only byte counts (downloadStatusText, download.ts:168-180; download.test.ts:270 pins “never puts anything but numbers in the status line”).
5. What is stored, under what key. cacheApiModelStore (cache.ts:779-801) writes into the extension origin’s CacheStorage bucket ferrule-model-v1 (:218), keyed by origin + pathname of the validated URL (storeKey, :636-639), as a constructed Response with content-type: application/octet-stream and Ferrule’s own x-ferrule-model-bytes header (:755,789-795). Cache.put consumes the stream; nothing holds the model in memory. This is local profile storage, not chrome.storage, and does not sync.
6. Can a partial download be served as complete? No — two independent checks. On the way in, the count check above discards a short write. On the way out, #readStored (:359-385) refuses any entry without a recorded length (:363-370, discarding it) and wraps the body in a countedStream whose onEnd compares bytes seen against the recorded length, discards the entry and errors the stream with corrupt-cache on mismatch (:371-383) — so even if a discard failed and a header-complete-but-short entry survived, the consumer (Engine.create) receives an error, not a truncated model. isCached() (:294-304) is deliberately an index check only, documented as such; a false positive there costs a failed load and a re-download, never a served partial. Chrome confirmed the incomplete-download guard fires for real (docs/ROADMAP.md:80-82).
7. The side panel — reads, cannot fetch. narrationRuntime (src/sidepanel/main.ts:128-160) prefers a user-picked File (:132-138, a Blob — no network) and otherwise calls cachedModel(store) (download.ts:139-144), which walks the catalogue with NO_FETCH (:123-125, a function that throws). The one ModelCache the panel builds is modelCache(downloaded, NO_FETCH, store) (main.ts:156); if the entry vanished between isCached() and open(), #startDownload’s await this.#fetch(...) catches the synchronous throw and surfaces network-error (cache.ts:521-529) — still no request. The panel imports nothing from download.ts that can request a permission (main.ts:28: NO_FETCH, cachedModel, modelCache only), and its globalThis.fetch reference (:373) flows only into runScan → GitHubClient (pipeline.ts:211). The roadmap’s claim that the panel “structurally cannot start a download itself” (ROADMAP.md:70) is verified: the only fetch implementation the panel ever hands to a ModelCache is one that throws. The stream then goes to liteRtEngineFactory(handle.body, chrome.runtime.getURL('wasm/')) (main.ts:157) — a ReadableStream source (no engine fetch) and an EXTENSION_URL WASM path (runtime.ts:527,536).
8. Error surfaces. ModelCacheError messages quote the model URL, byte counts, HTTP statuses and — in redirect-refused — the refused origin (cache.ts:535); none of those strings reaches a user: describeError (src/lib/errors.ts) reads only .kind, with .cause inspected solely for class and kind (:251-256), and every kind maps to static wording (:142-170). The options page renders only describeError(err).message/action (options/main.ts:298-300). Zero console.* in shipped src/.
1. Manifest surface — public/manifest.json, 41 lines, read in full. permissions exactly ["sidePanel", "storage"] (:11-14). host_permissions exactly ["https://api.github.com/*"] (:15-17). optional_host_permissions exactly ["https://huggingface.co/*", "https://*.cdn.hf.co/*"] (:18-21). CSP as quoted under A1 (:22-24); connect-src is exactly 'self' + the three origins. unlimitedStorage absent (the roadmap records the 10.7 GB measured quota, ROADMAP.md:75-77). New since the third audit and not permissions: icons (:6-10), action.default_icon (:35-39). Still absent: web_accessible_resources, externally_connectable, content_scripts, optional_permissions, webRequest, declarativeNetRequest, cookies, identity, nativeMessaging, debugger, tabs, downloads, history. dist/manifest.json byte-identical (diff: no output). src/manifest.test.ts reads the shipped public/manifest.json (:28-31) and pins all four lists as closed lists; 6/6 tests pass.
2. Code egress — complete inventory. Non-test fetch( call sites in the tree: three textual, two consumers — src/lib/github/client.ts:414 (this.#fetch(target, …), target from url(path) on the module constant GITHUB_API_ORIGIN, :18), src/lib/model/cache.ts:522 (this.#fetch(this.#url), constructor-validated, post-redirect-checked), and the adapter src/options/main.ts:159 (fetch: (url) => fetch(url)) that supplies the global fetch to the second. The global fetch is referenced in exactly two UI files: options/main.ts:159 and sidepanel/main.ts:373 (globalThis.fetch.bind(globalThis) → GitHub pipeline only). Test-file hits: catalogue.test.ts:82 (env-gated live HEAD), pipeline.test.ts:767 (wraps a fake), html.test.ts:347 (hostile string). XMLHttpRequest/WebSocket/EventSource/sendBeacon/connectNative/importScripts/new Worker/SharedWorker/postMessage/BroadcastChannel/eval(/new Function/new Image/<iframe/window.open/document.write/location.* in src, scripts, spike, public: zero (the word “WebSocket” at rules.ts:1107 is a comment). import(: one, runtime.ts:599, the bare @litert-lm/core specifier. new URL( in non-test shipped code: three, all in cache.ts (:590,623,637) parsing already-validated or response-reported URLs; two in node-only scripts on import.meta.url. chrome.* call sites in shipped code: background.ts:1,3; token.ts:178-180; history.ts:453-455; download.ts:46-48; sidepanel/main.ts:130,561; plus typeof chrome.storage.local guards at options/main.ts:34-36 and sidepanel/main.ts:60-62. navigator.* in shipped first-party code: none (the capability check injects a navigator-shaped env). URL literals in non-test src/ code (not comments): client.ts:18, cache.ts:177, catalogue.ts:47,54, download.ts:30, scanner/types.ts:77,86,92, options/index.html:59; in public/: the manifest’s four. Both extension pages reference only relative assets (sidepanel/index.html:7,77; options/index.html:7,81); neither has an inline style, a form action, an <img>, or an <iframe>. Export buttons: URL.createObjectURL + <a download> click, revoked immediately (sidepanel/main.ts:536-542,551-557).
3. Dependency egress. package-lock.json byte-unchanged since the first audit; package.json changed only in version, license, repository metadata and a new icons script (git diff 9b24777..HEAD). One runtime dependency, @litert-lm/core@0.14.0 (installed version confirmed 0.14.0; lockfile integrity sha512-JQhvU6o6…, package-lock.json:62-65), transitive @litertjs/wasm-utils@2.5.3. Re-grepped both installed dist/ trees: URL strings are 54 Apache licence headers plus the one DEFAULT_WASM_PATH; network primitives are engine.js:166 fetch(modelUrl) and the loader’s <script>/importScripts — unchanged from the first audit; no telemetry, update check or crash reporter. @litert-lm/core’s prebuild (download-wasm.js) is a publisher script outside the published files list; no postinstall in either package. All 104 lockfile resolved URLs are registry.npmjs.org; the only hasInstallScript is still fsevents (dev, optional, darwin). New script scripts/generate-icons.mjs uses node:zlib/node:fs/node:path/node:url only and writes public/icons/*.png.
4. Data handling — GitHub PAT. token.ts, client.ts, errors.ts byte-unchanged since the second audit. Single storage site chrome.storage.local (token.ts:178-180); header-only (client.ts:411-413); never in a URL (client.test.ts pins); never quoted by tokenProblem; never read as .message by errors.ts (:8-15); options page renders presence only (options/main.ts:57-74); side panel passes a provider closure (sidepanel/main.ts:374). Structurally absent from the local-scan and model-download flows (RunLocalScanOptions has no token member; download.ts/cache.ts import nothing from github/). Zero console.* in shipped src/; two in node-only scripts (generate-icons.mjs:110, generate-rules-doc.ts:94), two in tests.
5. Data handling — repo contents. Sinks unchanged: panel DOM via textContent; one prompt string to the on-device engine (now two prompt kinds — rank and narrate — pipeline.ts:361-382; rank.ts:9-15 documents that the model is shown a numbered list and its reply is parsed as positions only, so a hostile reply can reorder but not add, drop or re-grade a finding); chrome.storage.local history; the two local export files. ranking is rendered (panel :223-261, both exporters) but not persisted. collect.ts caps reply bytes (replyByteCap, :26-33). Nothing new serializes source content or repo paths into a URL, header, query string, remote destination or synced storage. Report history entries carry scope (owner/repo@ref or a local folder name), findings, suppression reasons and narrations — local-only, as ruled under A5.
6. Build evidence. dist/ built 2026-08-23 from this tree. URL strings per bundle: dist/background.js (108 B): none. dist/options.js: none. dist/sidepanel.js: the two registry.terraform.io/developer.hashicorp.com template prefixes (unchanged false positive). dist/assets/download-pDv30QYb.js (the shared chunk both pages modulepreload): https://api.github.com, https://huggingface.co ×3, the two catalogue URLs, and https://*.cdn.hf.co/* — all allowlisted. dist/assets/dist-YRBJJ1X_.js (the @litert-lm/core chunk): https://cdn.jsdelivr.net/npm/@litert-lm/core@0.14.0/wasm — DEFAULT_WASM_PATH, see false positives. dist/wasm/*.js: comment-only URLs (github.com, MDN, IETF, unicode.org, kripken.github.io, opengroup.org, wikipedia, server.com). dist/src/options/index.html:61 carries the A14 link. .github/workflows/ci.yml:59-79 adds two CI guards that fail on any host in the bundles outside a five-entry list and on any cdn./unpkg. reference in first-party source.
modulepreload polyfill in dist/assets/download-pDv30QYb.js contains fetch(e.href, n). It iterates <link rel="modulepreload"> elements in the document; both shipped pages have exactly one, /assets/download-pDv30QYb.js (dist/src/*/index.html:8), which resolves to chrome-extension://<id>/…. Same-origin, and connect-src 'self' would refuse anything else.DEFAULT_WASM_PATH (jsDelivr) in dist/assets/dist-YRBJJ1X_.js. Prior audits reported zero URL strings beyond terraform.io in the bundles; that was true when @litert-lm/core was not yet split into its own chunk. The constant is unreachable twice over: runtime.ts:602 pins the local path via getOrLoadGlobalLiteRtLm before Engine.create can consult the default (load_litertlm.js:84), and the manifest CSP forbids both script-src and connect-src to that origin. The CI host guard lists and explains it (ci.yml:51-56).dist/wasm/*.js: 6 XMLHttpRequest and 2 fetch( per file. readAsync (litertlm_wasm_internal.js:118-147) loads locateFile("…internal.wasm") = scriptDirectory + path where scriptDirectory is derived from the glue script’s own src (:10,105) — chrome-extension://<id>/wasm/. The XHR branches are worker-only, file://-only and createLazyFile (:3306) paths not exercised. The glue also binds console.log/console.error (:151,153) for the WASM runtime’s own stdout/stderr — local DevTools only.registry.terraform.io / developer.hashicorp.com in src/lib/scanner/types.ts:77,86,92 and dist/sidepanel.js: RuleReference.url built from hard-coded literals, rendered by no surface; unchanged ruling from all three prior audits. docs/rules.md renders them as links — a generated document.catalogue.test.ts:82 — a HEAD to the two catalogue URLs with redirect: 'manual', behind FERRULE_CHECK_MODEL_URLS=1. Verified skipped by default (2 skipped in the run). Dev-only, sanctioned origin, never follows the redirect.cache.test.ts, runtime.test.ts, html.test.ts, markdown.test.ts, download.test.ts (e.g. https://us.aws.cdn.hf.co.example.com/…, http://evil.example) — attack fixtures pinning refusals..github/workflows/ci.yml — actions/checkout@v4, actions/setup-node@v4, npm ci on GitHub-hosted runners: CI egress to GitHub and registry.npmjs.org, not extension or dev-runtime surface. The git diff --exit-code docs/rules.md step is local.package.json:8-11 repository URL, README.md links (github.com, developers.google.com, ai.google.dev, huggingface.co), spike/model-eval/index.html:34 rel="noreferrer" link — documentation.URL.createObjectURL blob exports (sidepanel/main.ts:537,552) — local file save, revoked immediately.chrome.runtime.openOptionsPage() (sidepanel/main.ts:561) — opens the extension’s own page.docs/screenshot.png — viewed; shows a scan of the public Erya-Labs/Ferrule repository’s samples/webapp fixtures and synthetic narration. Nothing private..gitignore now ignores .claude and loop/ — local machinery, which is why .claude/settings.json (which earlier audits cited) is no longer tracked.remove()/write() on the extension’s own CacheStorage/IndexedDB — not the user’s filesystem.public/manifest.json:4, package.json:3 and README.md:41 all read 0.1.0 at this commit. The bump touches the manifest; manifest.test.ts will keep the lists pinned, but whoever makes the release commit should diff the manifest against this audit’s quoted 41-line file so the audited artefact and the shipped one are the same.download.ts:10-11, docs/PRIVACY.md:43-46 and, optionally, README.md:148-149 — say what the grant enforces (consent; CORS bypass) rather than “the browser enforcing it”.client.ts, or soften PRIVACY.md:79-82.CONTRIBUTING.md:9-10 constraint-1 wording (“(reserved) huggingface.co”, no CDN).docs/PRIVACY.md items 2–4 above (date, “preferences”, “never stored on disk”).manifest.test.ts.style-src 'unsafe-inline') and A7 (delete or same-origin-guard the M0 spike) remain open and unchanged since the second audit.navigator.storage.persist() (ROADMAP.md:78-79) — costs no permission and has no privacy implication; noted only so nobody reaches for unlimitedStorage instead.*.cdn.hf.co added as sanctioned) · A2: FIXED · A3: MITIGATED · A4: STILL PRESENT (acceptable) · A5: FIXED (holding; ranking not persisted) · A6: FIXED (holding; fix-order surfaces text-only/escaped) · A7: STILL PRESENT (dev-only) · A8: STILL PRESENT (drop it) · A9: SUPERSEDED (entry now live, optional, and widened to the narrowest working CDN pattern — judged acceptable on the four grounds above) · A10: STILL PRESENT (acceptable; value corrected) · A11: HOLDING ('read' pin intact).noreferrer, user-click; no action), A15 (pin manifest absences in the test).https://huggingface.co/…/*.litertlm literals validated at import time and again at construction; the redirect target is checked against an anchored *.cdn.hf.co pattern before status, headers or body are read; the byte count is bounded on the header and on the wire; a partial entry is discarded on write and refused on read; the side panel’s only fetch implementation for the model is one that throws; the manifest is exactly the sanctioned surface and is test-pinned; the PAT remains header-only in chrome.storage.local; the lockfile is byte-identical to the first audit’s; and the tree still contains exactly two injected fetch consumers, both allowlisted.No file in the tree was modified by this audit.
The privacy-auditor agent was invoked a fifth time against the whole tracked tree,
from an interactive Claude Code session in this clone, after the 0.2.1 release and the
housekeeping that followed it: the A13 post-redirect origin check in the GitHub client,
the A12 wording and A15 manifest-absence pins from the fourth run, a dependency-free
release packager (scripts/package.mjs), the GitHub Pages landing page and config, the
refreshed README screenshot, and — at the audited commit — a release procedure and store
listing under docs/ that have since been moved out of the published tree into the
gitignored loop/ folder. No trigger from the release procedure’s privacy gate had
actually fired (the manifest, fetch sites and dependencies were unchanged since the
fourth audit); the run was made anyway while the procedure was being written down, and
it earned its keep: A16 below is a real hole in a control this project cites publicly.
b941889b65dfd3255806c781256a71eb61445cbaThe report below is the agent’s output verbatim — findings unedited, not summarised
and not softened. One thing about how it reached this file is worth recording: the
session that ran the audit verified the report’s claims by hand (client.ts, the
manifest, manifest.test.ts, the lockfile diff, the bundle’s host strings), wrote “All
checks pass. Now recording it.” — and then the API response stalled mid-stream before
the write happened. The report was recovered unmodified from the subagent’s transcript
(agent-acde95d51bdea805a, 168 events) and recorded here the next morning; git status
showing nothing to commit after that session was the symptom, not an error by the
operator.
The report’s load-bearing claims were verified by hand before recording it, per the same
protocol as the four prior runs: A16 reproduced — echo 'x = "https://*.evil.example/*";'
| grep -oE 'https?://[a-zA-Z0-9.-]+' prints nothing, so the CI guard at
.github/workflows/ci.yml:62 cannot see a wildcard host; the three local stores A20 names
exist at the cited lines (cache.ts:218 ferrule-model-v1, model/handle.ts:215
ferrule-model-handle, local/handle.ts:225 ferrule-scan-directory); the A13 fix sits
at client.ts:426-433 as ruled; and docs/index.md:15 did say “four full-tree audits”.
All hold as described.
No inaccuracy in the report was found while checking it. Note for a reader of the
report’s file paths: docs/RELEASING.md and docs/store/… existed at the audited commit
and were moved to loop/release/ (gitignored) at 46aa20c, so A17’s observations about
the store-image toolchain now describe files outside the published tree; the A17 fixes
(pinned package versions, the note that the capture harness runs without the extension
CSP) were applied there. Also acted on in the commit that follows this record: A16
(the guard’s character class now includes *, and https://*.cdn.hf.co is allowlisted
explicitly rather than by invisibility), A20 and A18 (one sentence each in
docs/PRIVACY.md), and item 7’s two wording drifts. A19 needs no action; A7 and A8
remain the user’s call, unchanged.
Commit audited: b941889b65dfd3255806c781256a71eb61445cba (branch main; working tree clean for tracked files — git status --porcelain --untracked-files=no prints nothing)
Date: 2026-08-24
Previous audit: 2026-08-23 against 5a59816d9fcebc0ac8f51eeeb90d0c18e78e84d2 (verdict: CLEAN — V1, V2 fixed; advisories A1–A15), recorded verbatim in /home/tomsa/git/ferrule/docs/privacy-audit.md
Auditor brief: whole-tree audit against CLAUDE.md constraint 1 as written now (required https://api.github.com; optional https://huggingface.co + https://*.cdn.hf.co for the in-app model download only; a download must start at huggingface.co with a .litertlm path, the CDN reachable only as a redirect target; connect-src as the enforcement), with a status ruling on every prior finding.
Method: whole tracked tree (120 files per git ls-files), not a diff. git diff --name-status 5a59816..HEAD returns 26 entries; every code entry was read in full or diffed in full — public/manifest.json, src/manifest.test.ts, src/lib/github/client.ts (+client.test.ts), src/lib/errors.ts, src/lib/model/download.ts, src/options/index.html, scripts/package.mjs, package.json, package-lock.json, .gitignore — plus every new document and tool: CHANGELOG.md, docs/RELEASING.md, docs/_config.yml, docs/index.md, docs/store/listing.md, docs/store/tools/{README.md,capture.py,compose.py}, docs/PRIVACY.md, CONTRIBUTING.md, README.md, CLAUDE.md, and the three new/changed PNGs (docs/screenshot.png and docs/store/screenshot-1280x800.png were opened and viewed, not merely listed). The unchanged remainder was re-swept by tree-wide greps for fetch(, XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, SharedWorker, postMessage, BroadcastChannel, eval(, new Function, new Image, import(, new URL(, https?://, chrome.*, navigator.*, storage.sync, console.*, innerHTML/outerHTML/insertAdjacentHTML/document.write, window.open, location.*, createObjectURL, indexedDB, caches., <iframe, <script, <link, <a , src=, href=, action=, style=/<style, unlimitedStorage, web_accessible_resources, externally_connectable, content_scripts, and for telemetry SDK names (sentry|posthog|mixpanel|amplitude|gtag|google-analytics|segment.io|datadog|bugsnag|rollbar). Credential sweeps for gh[pousr]_…, github_pat_…, AKIA[0-9A-Z]{16} were run across the whole tree. The installed node_modules/@litert-lm/core@0.14.0 and @litertjs/wasm-utils were re-grepped for network primitives and URL strings. dist/ (gitignored, built 2026-08-23 23:41, after the last source-touching commit 4db2052 of 23:35 and before the docs-only HEAD of 23:48) was inspected as build evidence, and the packaged artefact release/ferrule-0.2.1.zip was opened and compared entry-by-entry against it. npm test was run read-only: 32 files, 1825 passed, 2 skipped (the env-gated live catalogue check). Nothing was fetched from the network; no file was modified.
No egress path to a non-allowlisted origin exists anywhere in the tree — shipped, dev, build or release. The manifest is exactly the sanctioned surface and is now pinned by a test that checks absences as well as presences. The cycle’s material changes are the A13 fix (the GitHub client now refuses a response whose post-redirect origin is not api.github.com), the A12 and A15 fixes, a dependency-free release packager, and a Windows-only store-image toolchain that lives outside the extension. No dependency was added, removed or updated: package-lock.json is byte-identical to the first audit’s copy apart from two "version" strings. Five new advisories (A16–A20) are recorded; none is a violation of a stated constraint.
spike/ is byte-unchanged since 89354b8 (git diff --stat 89354b8..HEAD -- spike/: empty). Re-verified by reading the file rather than trusting that: /home/tomsa/git/ferrule/spike/model-eval/main.js:10 imports the bare specifier '@litert-lm/core', :20 sets WASM_PATH = '/node_modules/@litert-lm/core/wasm/', and :48 await getOrLoadGlobalLiteRtLm(WASM_PATH) runs before Engine.create at :49. Tree-wide grep for jsdelivr in tracked first-party source: three hits, all in .github/workflows/ci.yml:51,61 (the guard’s own allowlist and its explanation) and prose in docs/privacy-audit.md. The CI guard at ci.yml:70-79 still fails the build on any cdn./unpkg. reference in src/ or public/.
liteRtEngineFactory model-URL validation: FIXED (holding)/home/tomsa/git/ferrule/src/lib/model/runtime.ts:598 — const model = typeof source === 'string' ? checkedModelUrl(source) : source; — still runs before the dynamic import at :599, with the WASM guard at :591 and the pin at :602, all before Engine.create at :606. Both shipped callers pass bytes, never strings: src/sidepanel/main.ts:135 (a user-picked File) and :157 (handle.body, the ReadableStream from ModelCache). File byte-unchanged since 89354b8.
/home/tomsa/git/ferrule/public/manifest.json:23 — default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://api.github.com https://huggingface.co https://*.cdn.hf.co; img-src 'self' data:; style-src 'self' 'unsafe-inline'. connect-src is exactly 'self' plus the three sanctioned origins and nothing else. The only manifest change since the audited 5a59816 is "version": "0.1.0" → "0.2.1" at :4 (git diff 5a59816..HEAD -- public/manifest.json is one hunk). dist/manifest.json is byte-identical (diff: no output), and so is manifest.json inside release/ferrule-0.2.1.zip.
web_accessible_resources for the WASM: FIXED (holding, now test-pinned)Full read of the 41-line manifest: no WAR entry. Now enforced rather than hand-checked — src/manifest.test.ts:92. dist/wasm/ holds the same eight LiteRT files, loaded same-origin via chrome.runtime.getURL('wasm/') (src/sidepanel/main.ts:130).
runtime.ts and vite.config.ts untouched. Engine.create appears only at runtime.ts:606 and spike/model-eval/main.js:49 (grep). vite.config.ts:13-19 still copies the WASM as a plain asset with no dependency and no WAR entry.
spike/m2-verify handles a real PAT on a dev page: STILL PRESENT (unchanged, acceptable)Byte-unchanged since the first audit. Same posture: header-only token, never stored, never logged.
chrome.storage.local, never sync: FIXED (holding)src/lib/report/history.ts:453-455 is chrome.storage.local for get/set/remove — the only storage adapter besides the token’s. toStored (history.ts:187-208) persists scope, ref, truncated, fileCount, scannedAt, findings, suppressed, narrations and narration status only. The only two chrome.storage keys in the tree remain github.pat (src/lib/github/token.ts:25) and report.history (history.ts:39). storage.sync appears in non-test src/ only in the three comments forbidding it (token.ts:6, history.ts:10,443).
Zero innerHTML/outerHTML/insertAdjacentHTML/document.write anywhere in src/ (grep: no hits). No new render or export surface landed this cycle — the only changed UI file is src/options/index.html, and the change is one word of body copy at :65.
spike/model-eval/index.html:39 is still an unvalidated <input type="url"> whose value reaches Engine.create({ model: url }) at spike/model-eval/main.js:49. Still the only fetch destination in the tree that is not statically verifiable; still reachable only from npm run spike; still carries no repo data. Prior minimal fix (same-origin check, or delete the completed spike) still applies.
style-src 'unsafe-inline' unused: STILL PRESENT (premise re-verified)public/manifest.json:23 still allows inline styles; both extension pages still contain zero style= and zero <style (grep of src/sidepanel/index.html, src/options/index.html), and both stylesheets contain zero url(, @import, @font-face. Not an egress channel; still droppable.
huggingface.co allowlist entry: SUPERSEDED (ruling holds)Re-verified from code, not from the prior ruling. A download may only start at huggingface.co: checkedModelUrl (src/lib/model/cache.ts:583-610) requires parsed.origin === HUGGINGFACE_ORIGIN and a .litertlm pathname, and every catalogue URL is run through it at module scope (src/lib/model/catalogue.ts:95), so a bad literal throws on import. cache.test.ts:749-755 pins that checkedModelUrl('https://us.aws.cdn.hf.co/model.litertlm') throws. The CDN is reachable only as a redirect target, checked before status, headers or body: cache.ts:532-538 against the anchored MODEL_CDN_ORIGIN = /^https:\/\/(?:[a-z0-9-]+\.)+cdn\.hf\.co$/ (:198), with hostile fixtures pinned at cache.test.ts:670-687,735-747.
.github/FUNDING.yml Ko-fi pointer: STILL PRESENT (acceptable)/home/tomsa/git/ferrule/.github/FUNDING.yml:6 is ko_fi: d4ydr34m; every other platform line is an empty commented template. GitHub repository metadata, not bundled, read by no code path. Not egress. (The opencollective/tidelift strings a host grep turns up are the commented template line :5,7 and package funding metadata in package-lock.json — see false positives.)
src/lib/local/handle.ts byte-unchanged; the 'read' mode pin is intact in the interface types and at both request sites; the picker still passes { mode: 'read' } (src/sidepanel/main.ts:477). Zero File System Access write APIs anywhere in shipped code (createWritable, createSyncAccessHandle, removeEntry, getFileHandle, getDirectoryHandle, move( — grep: no hits). RememberedModelFile (src/lib/model/handle.ts) still has no importer outside its own test.
src/lib/model/download.ts:10-15 now says the CSP connect-src is static and permits the hosts whether or not the grant is held; what the grant gates is consent plus the CORS bypass. That is an accurate description of MV3 behaviour.docs/PRIVACY.md:45-51 — “Ferrule will not start one until you allow it… To be exact about what the grant enforces: Ferrule’s code does not begin a download without it, and without it the browser will not let a request bypass CORS; the connect-src policy below is what guarantees those hosts are the only ones reachable.”README.md:147-150 — same correction.src/options/index.html:65 — “Ferrule does not contact Hugging Face until you ask it to” (was “cannot”), carried into dist/src/options/index.html:67./home/tomsa/git/ferrule/src/lib/github/client.ts:426-433:
const from = responseOrigin(response.url);
if (from !== undefined && from !== GITHUB_API_ORIGIN) {
throw new GitHubError(`GitHub API response came from ${from}, not ${GITHUB_API_ORIGIN}`, target, {
kind: 'redirect-refused',
status: response.status,
});
}
Placed before if (!response.ok) at :434 and before response.json() at :436, mirroring cache.ts:532-538. responseOrigin (client.ts:455-462) returns the raw string for an unparseable URL — so it can never equal the allowed origin — and undefined only for a genuinely unreported (empty/absent) url. HttpResponse.url is optional with the reason documented at :62-66. Three tests pin it, including that the body is never read on refusal: client.test.ts:214-237 (refused, bodyRead === false), :239-246 (accepted when the reported URL is api.github.com), :248-256 (an unparseable reported URL is refused, not treated as unreported). New wording exists at src/lib/errors.ts:108-111, and GITHUB_ERROR_MESSAGES satisfies Record<GitHubErrorKind, …> makes a missing entry a type error. docs/PRIVACY.md:84-87 now describes the check accurately for both consumers.
src/options/index.html:59 — <a href="https://huggingface.co/litert-community" target="_blank" rel="noreferrer">, carried to dist/src/options/index.html:61. Navigation, not a fetch; allowlisted origin; user-initiated; rel="noreferrer" keeps the chrome-extension://<id> referrer off the wire; carries nothing repo-derived. It remains the only <a href> in either shipped page (grep of both files).
manifest.test.ts pinned no absences: FIXED/home/tomsa/git/ferrule/src/manifest.test.ts:86-100 now asserts not.toHaveProperty for web_accessible_resources, externally_connectable, content_scripts and optional_permissions, that permissions does not contain unlimitedStorage, and that the CSP contains default-src 'self' and object-src 'self'. Together with the four closed-list assertions (:44-49 connect-src, :73-77 host and optional-host permissions, :83 permissions) and the remote-code test (:102-110), the manifest surface this brief describes is now machine-checked in full. 7/7 tests in the file pass in the run above.
No new violations. Five new advisories:
A16 — The CI “no unexpected hosts” guard cannot see a wildcard host string, and one is in the bundle today. /home/tomsa/git/ferrule/.github/workflows/ci.yml:62 extracts hosts with grep -rhoE 'https?://[a-zA-Z0-9.-]+'. * is not in that character class, so a string of the form https://*.anything/ produces no match at all and is never compared against the five-entry allowlist at :61. Reproduced: echo 'x = "https://*.evil.example/*";' | grep -oE 'https?://[a-zA-Z0-9.-]+' returns nothing. The current bundle dist/assets/download-DdJahl9L.js contains https://*.cdn.hf.co/* (from src/lib/model/download.ts:34), and the guard’s own output for this tree lists only five hosts — the CDN pattern is invisible to it. This is not egress and nothing malicious is present; it is a hole in a control the project cites as a structural check, and the hole is exactly wildcard-shaped, which is the shape a widened allowlist takes. Minimal fix: add * to the class — grep -rhoE 'https?://[a-zA-Z0-9*.-]+' — and add https://\*\.cdn\.hf\.co to the allowed regex at :61 so the pattern that is legitimately there passes explicitly rather than by invisibility.
A17 — The store-image toolchain runs the real panel as a plain http://127.0.0.1 page, where the manifest CSP does not apply, and adds two unpinned third-party Python packages to the release procedure. /home/tomsa/git/ferrule/docs/store/tools/capture.py:14-21 serves dist/ with SimpleHTTPRequestHandler on 127.0.0.1:8765 and opens dist/src/sidepanel/index.html in headless Chrome; :35 opens a CDP WebSocket to 127.0.0.1:9335; :63-78 fills in Erya-Labs/Ferrule/main and drives a real, tokenless scan against api.github.com. Three observations, none a violation: (a) the page is served over plain HTTP with no CSP header, so connect-src — the mechanism CLAUDE.md constraint 1 names as the enforcement — is absent in this harness; what constrains the scan there is only the code path (GITHUB_API_ORIGIN at client.ts:18), which is why this is worth writing down rather than assuming the capture proves anything about confinement; (b) docs/store/tools/README.md:7 requires pip install pillow websocket-client on the release machine — the first dependencies in this project outside package-lock.json, unpinned and unhashed, one of which (websocket-client) exists to open WebSockets (here only to 127.0.0.1); (c) the destination of the capture is a committed image — docs/store/screenshot-1280x800.png and docs/screenshot.png, both opened and inspected: they show only the public Erya-Labs/Ferrule repository’s own samples/webapp fixtures, no token, no private path, no local filesystem path. Minimal fix: none required for privacy; say in docs/store/tools/README.md that the harness runs without the extension CSP (so an egress bug would not be caught there), and pin the two Python packages with versions in the same file.
A18 — The published privacy policy is now served by a GitHub Pages theme whose layout is not in this repository. /home/tomsa/git/ferrule/docs/_config.yml:3 selects theme: jekyll-theme-minimal, and docs/store/listing.md:100 gives the Chrome Web Store https://erya-labs.github.io/Ferrule/PRIVACY as the policy URL. docs/ contains no _layouts, _includes or assets (directory listing: only Markdown, _config.yml, images and store/), so what the rendered page loads — stylesheets, fonts, any analytics hook the theme’s layout supports — is determined by a remote theme and cannot be verified from this tree. Nothing in the repository configures analytics (google_analytics is not set; a tree-wide grep for gtag|google-analytics|analytics matches only prose in docs/PRIVACY.md, docs/m2-results.md and this audit record). This is a website, not the extension, and CLAUDE.md constraint 1 governs host permissions, fetches and dependencies — it is none of the three. It is recorded because docs/PRIVACY.md:66 claims “no third-party services of any kind” and that page is now itself served through third-party-templated infrastructure, and because a reader may reasonably ask. Minimal fix: none required; if the claim is to be airtight, vendor a minimal local layout under docs/_layouts/ so the served page’s asset list is in the tree, or note in PRIVACY.md that the claim is about the extension and that the site is hosted by GitHub Pages.
A19 — Hugging Face’s Xet migration means a real download may redirect to cas-bridge.xethub.hf.co, which this allowlist refuses; the fail-closed behaviour is correct and is a standing pressure point. /home/tomsa/git/ferrule/src/lib/model/cache.test.ts:676 already carries https://cas-bridge.xethub.hf.co/model.litertlm as a hostile fixture asserted to produce redirect-refused, alongside cdn-lfs.huggingface.co. That is the right answer under constraint 1 as written — the sanctioned CDN pattern is *.cdn.hf.co and nothing else — but it means a download that Hugging Face routes through Xet fails rather than completes, and the obvious “fix” is to widen the allowlist. The comment at cache.ts:181-186 acknowledges the regional CDN varies. Minimal fix: none now. Recorded so that if downloads start failing in the field, the next reader knows the refusal is deliberate, that the manual file-picker path (src/sidepanel/main.ts:132-138, bytes in hand, no network) still works with no grant at all, and that adding xethub.hf.co is a host-permission widening requiring a fresh audit and a CLAUDE.md change — not a bug fix.
A20 — docs/PRIVACY.md still describes only two of the four local stores. /home/tomsa/git/ferrule/docs/PRIVACY.md:16-19 now correctly says recent reports are kept, and :26-27,72 name chrome.storage.local. Not mentioned anywhere in the document: the downloaded model in the extension origin’s CacheStorage bucket ferrule-model-v1 (src/lib/model/cache.ts:218) — several gigabytes of profile data the settings page can delete — and the two IndexedDB databases ferrule-model-handle (src/lib/model/handle.ts:215, currently unwired) and ferrule-scan-directory (src/lib/local/handle.ts:225), the second of which holds a re-grantable read capability to a folder on the user’s disk (A11). All four are local, none syncs, and none is egress. Minimal fix: one sentence under “What Ferrule handles” listing the model cache and the remembered-folder handle, and noting that both are cleared with the browser profile.
1. The GitHub post-redirect check (A13’s fix) — what it does and does not buy. The check at client.ts:426-433 fires after the request has been made and any redirect followed, because a browser fetch follows redirects itself and redirect: 'manual' yields an opaque response with no readable Location (the same constraint cache.ts:200-209 documents). So it does not prevent a hop; it prevents reading an answer from a host that is not GitHub, before status, headers or body are touched. What confines the hops themselves is connect-src (manifest.json:23), which the browser applies to every hop of a chain. Chrome strips Authorization on a cross-origin redirect, so the token is not carried; the request path — which names owner/repo and blob shas — would be. The check makes the only CSP-permitted foreign hop (api.github.com → huggingface.co / *.cdn.hf.co) fail closed and unread. bodyRead === false is pinned at client.test.ts:214-237.
2. The release packager. /home/tomsa/git/ferrule/scripts/package.mjs imports only node:crypto, node:fs, node:path, node:url, node:zlib (:9-13) — no dependency, no zip binary, no exec/spawn, no network. It refuses to run when dist/manifest.json and package.json disagree on the version (:22-26), walks dist/ (:29-37), writes a ZIP by hand and verifies its own central directory (:90-99) including that manifest.json is at the root. Verified against the artefact: release/ferrule-0.2.1.zip holds 21 entries, every one byte-identical (sha256 per entry) to the corresponding file in the current dist/, with nothing in dist/ omitted, and its manifest.json parses equal to public/manifest.json. The shipped artefact and the audited manifest are the same bytes. release/ is gitignored (.gitignore:16).
3. The model download, re-verified end to end (unchanged code, checked again rather than carried forward). Only the catalogue can supply a URL: catalogue.ts:47,54 are two https://huggingface.co/litert-community/…/resolve/main/*.litertlm literals, validated at module scope (:95); catalogueModel(id) (:69-71) still has no caller outside its own test. options/main.ts:159 is the only place the global fetch is handed to a ModelCache, and the constructor re-validates the URL (cache.ts:271). The permission is requested in exactly one place (download.ts:91 via requestModelPermission), from inside the click with nothing awaited above it (options/main.ts:257-260), naming both patterns together (download.ts:32-35); a non-granted outcome returns before the store is opened (:261-274). The panel’s only ModelCache is built with NO_FETCH, a function that throws (download.ts:125-127, used at sidepanel/main.ts:156), so the panel structurally cannot start a download. Progress text carries byte counts only (download.ts:168-180).
4. The token’s full path. token.ts and the token half of client.ts are byte-unchanged since the second audit. Single storage site: chrome.storage.local at token.ts:178-180, one key github.pat (:25). Into the request as a header only (client.ts:419-421); never into a URL — url(path) (client.ts:533-535) concatenates only GITHUB_API_ORIGIN and a locally built path whose caller-supplied segments go through segment()/refPath (encoded, with ''/./.. rejected). Never into a log: zero console.* in shipped src/ (grep). Never into user-facing text: errors.ts reads only .kind, with .cause inspected solely for class and kind (:246-264). Structurally absent from the local-scan and download flows (RunLocalScanOptions has no token member; download.ts/cache.ts import nothing from github/). The new redirect-refused error names an origin and the target URL, never the token, and describeError maps it to static wording (errors.ts:108-111).
5. Repository-derived text — every place it is stored, rendered or exported. Unchanged this cycle and re-confirmed: (a) the panel DOM via textContent only — zero innerHTML/outerHTML/insertAdjacentHTML/document.write in src/; (b) one prompt string to the on-device engine; (c) chrome.storage.local under report.history (history.ts:39,187-208,453-455), carrying scope, findings, ferrule:ignore reason text and narrations, never the token and never ranking; (d) two export files the user saves locally via URL.createObjectURL + <a download>, revoked immediately (sidepanel/main.ts:537-542,552-557), with the filename sanitized to [A-Za-z0-9_-] (:540,555); (e) new this cycle and outside the extension — two committed PNGs produced by the store toolchain, inspected above, containing only public-repository fixture findings. Nothing serializes repository content or paths into a URL, header, query parameter, synced storage or remote destination.
1. Manifest surface — public/manifest.json, 41 lines, read in full. permissions exactly ["sidePanel","storage"] (:11-14). host_permissions exactly ["https://api.github.com/*"] (:15-17) — scheme-pinned, host-exact, no wildcard, nothing promoted from optional. optional_host_permissions exactly ["https://huggingface.co/*","https://*.cdn.hf.co/*"] (:18-21). CSP connect-src exactly 'self' https://api.github.com https://huggingface.co https://*.cdn.hf.co (:23). Absent and correct: web_accessible_resources, externally_connectable, content_scripts, optional_permissions, unlimitedStorage, webRequest, declarativeNetRequest, cookies, identity, nativeMessaging, debugger, tabs, downloads, history, scripting, management. Non-permission keys: icons, background, side_panel, options_page, action. dist/manifest.json byte-identical; the zip’s copy parses equal. src/manifest.test.ts reads the shipped file, not a copy (:28-31), and pins all four lists closed plus the absences (A15).
2. Code egress — complete inventory. Non-test fetch( in the whole tree: three textual, two consumers — src/lib/github/client.ts:422 (this.#fetch(target, …), target from the module constant at :18, now post-redirect-checked at :427), src/lib/model/cache.ts:522 (this.#fetch(this.#url), constructor-validated, post-redirect-checked at :532), and the adapter src/options/main.ts:159 that supplies the global fetch to the second. The global fetch is referenced in exactly two UI files: options/main.ts:159 and sidepanel/main.ts:373 (globalThis.fetch.bind(globalThis), flowing only into runScan → GitHubClient at pipeline.ts:211). Test hits: pipeline.test.ts:767, client.test.ts:222 (both wrap fakes), catalogue.test.ts:82 (env-gated live HEAD), html.test.ts:347 (hostile string). Zero hits anywhere in src, scripts, spike, public for XMLHttpRequest, WebSocket, EventSource, sendBeacon, connectNative, importScripts, new Worker, SharedWorker, postMessage, BroadcastChannel, eval(, new Function, new Image, window.open, document.write, location.*, chrome.runtime.connect, chrome.runtime.sendMessage, chrome.tabs, chrome.downloads, chrome.identity, chrome.cookies (the sole “WebSocket” hit is a comment at rules.ts:1107). One dynamic import(: runtime.ts:599, a bare specifier Vite bundles (the two in scanner/index.ts:23,34 are TypeScript type positions). chrome.* call sites in shipped code: background.ts:1,3; token.ts:178-180; history.ts:453-455; download.ts:50-52; sidepanel/main.ts:130,561; plus typeof guards at options/main.ts:34-36 and sidepanel/main.ts:60-62. navigator.* in shipped first-party code: none (the capability check injects a navigator-shaped env). Both extension pages reference only relative assets (sidepanel/index.html:7,77; options/index.html:7,81); the single <a href> is A14; no <img>, <iframe>, inline style or form action in either.
3. Dependency egress. package-lock.json is byte-identical to the first audited commit’s copy except two "version" strings (git diff cdd857f..HEAD -- package-lock.json: 2 insertions, 2 deletions, both 0.0.1 → 0.2.1). package.json changed only in version and a new "package": "node scripts/package.mjs" script. No dependency added, removed or updated. Installed runtime dependency confirmed @litert-lm/core@0.14.0 with transitive @litertjs/wasm-utils; re-grepping both installed dist/ trees, the only non-licence URL is https://cdn.jsdelivr.net/npm/@litert-lm/core@0.14.0/wasm (DEFAULT_WASM_PATH) and the only network primitives are engine.js:166 fetch(modelUrl) and the loader’s importScripts/<script> in @litertjs/wasm-utils/dist/{index,bundle}.js:3-4. No telemetry, analytics, update check or crash reporter; no postinstall in either package (@litert-lm/core’s prebuild/download-wasm.js is a publisher script outside the published files list). All lockfile resolved URLs are registry.npmjs.org. New this cycle and outside npm: the two Python packages in the store toolchain — see A17. All four scripts/*.mjs|ts import only node:* builtins plus Vite’s local runnerImport; none performs I/O beyond the repository.
4. Data handling — GitHub PAT. Traced above; clean. One new consumer-side detail: the redirect-refused GitHubError carries target in its url field, which names owner/repo — the same exposure every other GitHubError already had, and errors.ts never reads it.
5. Data handling — repository contents. Sinks unchanged and enumerated above. Local storage inventory in full: chrome.storage.local keys github.pat and report.history; CacheStorage bucket ferrule-model-v1; IndexedDB ferrule-model-handle (unwired) and ferrule-scan-directory. None syncs; none is named in a URL.
6. Build and release evidence. dist/ built 2026-08-23 23:41 from this tree (the last source-touching commit is 4db2052 at 23:35; HEAD b941889 at 23:48 touches only docs/RELEASING.md), and the current src/options/index.html:65 copy change is present in dist/src/options/index.html:67, so the build corresponds to the audited source. URL strings per bundle: dist/background.js (108 B) none; dist/options.js none; dist/sidepanel.js only the three registry.terraform.io/developer.hashicorp.com template prefixes; dist/assets/download-DdJahl9L.js https://api.github.com, https://huggingface.co, the two catalogue URLs and https://*.cdn.hf.co/* — all allowlisted; dist/assets/dist-YRBJJ1X_.js the single jsDelivr DEFAULT_WASM_PATH; dist/wasm/*.js comment-only URLs. redirect-refused appears twice in the shared chunk and the minified guard let s=await this.#e(a,{headers:o}),c=r(s.url);… is present, confirming the A13 fix shipped. release/ferrule-0.2.1.zip is entry-for-entry identical to dist/.
7. Credentials. No real credential anywhere in the tree. PAT-shaped strings are synthetic test fixtures (errors.test.ts:253, pipeline.e2e.test.ts:258, token.test.ts:20,212); AKIAIOSFODNN7EXAMPLE (spike/model-eval/fixtures.js:57, rules.test.ts:286,2624) is AWS’s own documented example key.
https://opencollective.com (×22) and https://tidelift.com in a host grep of tracked non-doc files: package-lock.json funding metadata for dev-dependency packages, plus the empty commented template lines .github/FUNDING.yml:5,7. Install-time metadata and repository metadata; nothing reads either at runtime.https://cas-bridge.xethub.hf.co at src/lib/model/cache.test.ts:676 — a hostile fixture asserted to be refused, not an allowlist entry. See A19 for why it is worth knowing about anyway.DEFAULT_WASM_PATH (jsDelivr) in dist/assets/dist-YRBJJ1X_.js — the vendored @litert-lm/core class constant, unreachable twice over (runtime.ts:602 pins the local path before Engine.create can consult the default; script-src/connect-src forbid the origin). Documented and permitted by ci.yml:51-56.modulepreload polyfill fetch(e.href, n) in dist/assets/download-DdJahl9L.js — iterates <link rel="modulepreload"> elements; both pages have exactly one, pointing at that same local chunk (dist/src/*/index.html:8). Same-origin; connect-src 'self' would refuse anything else.dist/wasm/*.js — XMLHttpRequest and fetch occurrences whose locateFile resolves against the glue script’s own src, i.e. chrome-extension://<id>/wasm/; the XHR branches are worker-only/file://-only/createLazyFile paths not exercised. Its console.log/console.error bindings are the WASM runtime’s stdout/stderr to local DevTools.registry.terraform.io / developer.hashicorp.com (src/lib/scanner/types.ts:77,86,92, bundled into dist/sidepanel.js) — RuleReference.url built from hard-coded resource-type literals, rendered by no surface; unchanged ruling from all four prior audits. docs/rules.md renders them as links in a generated document.catalogue.test.ts:82 — a HEAD with redirect: 'manual' to the two catalogue URLs, gated on FERRULE_CHECK_MODEL_URLS=1; confirmed skipped in the run (2 skipped).cache.test.ts, runtime.test.ts, html.test.ts, markdown.test.ts, download.test.ts, and now client.test.ts:219 (https://huggingface.co/tree as a fake redirect target) — attack fixtures that pin refusals.docs/RELEASING.md:197 — a documented curl https://api.github.com/repos/…/releases/latest for a human verifying a release, and :191 mentions git credential fill for the release token. Human procedure on an allowlisted host, not extension or dev-runtime surface..github/workflows/ci.yml — actions/checkout@v4, actions/setup-node@v4, npm ci on GitHub runners: CI egress to GitHub and registry.npmjs.org.docs/store/tools/capture.py’s websocket/HTTP server — 127.0.0.1 only (:11,15,19,21,35). Listed because a WebSocket grep hits it; see A17 for the part that is worth acting on.URL.createObjectURL blob exports (sidepanel/main.ts:537,552) and chrome.runtime.openOptionsPage() (:561) — local file save and the extension’s own page.remove()/write()/”Delete” on the extension’s own CacheStorage/IndexedDB — not the user’s filesystem; zero File System Access write APIs in the tree.docs/PRIVACY.md, docs/m2-results.md and this audit record — prose denying their existence.ci.yml:62’s character class plus one entry in :61’s allowlist. The cheapest fix in this report and it closes a wildcard-shaped blind spot in a guard the project cites publicly.docs/store/tools/README.md and state that the capture harness runs without the extension CSP.docs/PRIVACY.md naming the model cache and the remembered-folder handle.style-src 'unsafe-inline') remain open and unchanged since the second audit.docs/index.md:15 says “four full-tree audits” — five after this run. CHANGELOG.md:5-6 says 0.2.1 is “identical to 0.2.0 in every file”, which is true except the three version strings the re-release required.spike/model-eval/main.js:10,20,48-49) · V2: FIXED (holding) (src/lib/model/runtime.ts:591,598-602,606) — both fix sites byte-unchanged since verification; both shipped callers pass bytes, never strings.'read' pin intact) · A12: FIXED (download.ts:10-15, PRIVACY.md:45-51, README.md:147-150, options/index.html:65) · A13: FIXED (client.ts:426-433,455-462, pinned by client.test.ts:214-256) · A14: STILL PRESENT (acceptable) · A15: FIXED (manifest.test.ts:86-100).PRIVACY.md omits the model CacheStorage bucket and the two IndexedDB stores).host_permissions is exactly https://api.github.com/*, optional_host_permissions exactly the two Hugging Face patterns, connect-src exactly 'self' plus those three and nothing else — all four lists now closed-list-pinned by a test that also pins the dangerous absences; a download can begin only at one of two https://huggingface.co/…/*.litertlm literals validated at import and again at construction, with the CDN reachable only as a checked redirect target; the tree contains exactly two injected fetch consumers, both now refusing an off-origin response before status, headers or body; the PAT is header-only in chrome.storage.local; the lockfile is byte-identical to the first audit’s apart from a version string; and the packaged release/ferrule-0.2.1.zip is entry-for-entry the same bytes as the dist/ inspected here.No file in the tree was modified by this audit.