Assurance case

August 3, 2026 · View on GitHub

Last reviewed: 2026-07-28, against commit f563f72.

This document argues why cortex-viz's security requirements are met, and states where the argument is currently incomplete. It is written for a reader who wants to disagree with it: every claim points at a file, a workflow, or an alert count that can be checked.

The structure of the system is in ARCHITECTURE.md. The reporting process is in SECURITY.md.

1. What is being protected

cortex-viz stores nothing and mints no credentials. The assets are therefore other people's:

AssetWhy it matters here
The user's local filesystemThe server reads ~/.claude artifacts and serves files by request-derived path. An unconstrained read is arbitrary local file disclosure.
The Cortex PostgreSQL storeRead-only by contract. Memory content is the user's private reasoning history.
The browser execution context98 JS files, ~26k lines, execute in the user's browser. Code that runs there reaches whatever that page reaches.
The shipped artifact's integrityThe install path is a marketplace pin over a git tree. A tampered ui/ file is a tampered product.
The user's DATABASE_URLUser-supplied configuration that may embed a credential.
cortex-viz's own tables in that databaseIt writes five derived-cache tables of its own: workflow_graph_snapshot, workflow_graph_snapshot_scoped, workflow_graph_layout, workflow_graph_layout_lod, and session_activity.

A precision the older docs got wrong. cortex-viz is read-only with respect to Cortex's memory tables: it never writes a memory, entity, or relationship, and that is the property the read contract is about. It is not a read-only database client. It creates and writes the five tables listed above in the same database, and session_activity in particular persists a record of tool calls, file accesses, and skill invocations so the graph can stream live. SECURITY.md and PRIVACY.md previously stated a blanket "does not write" / "read-only"; both were corrected in the same change that added this document.

2. Threat model

The deployment shapes the model: the server binds 127.0.0.1 only and is launched by the user's own MCP host. There is no multi-tenancy, no remote authentication, and no network listener beyond loopback.

#AdversaryReachSTRIDECountered by
A1A web page the user visitsCan issue cross-origin requests to 127.0.0.1:<port> from the user's browser, including via DNS rebindingInformation disclosure, Tampering§3.1
A2A crafted request path (from A1, or any local process)Reaches static serving, wiki reads, git diff pathsInformation disclosure (arbitrary file read)§3.2
A3Untrusted content rendered in a viewMemory text, session transcripts, wiki pages, file paths, all rendered into the DOMElevation (XSS in the page's context)§3.3, open findings
A4A tampered release artifact or dependencySubstituted wheel, altered ui/ asset, malicious transitive packageTampering§3.4
A5Another local process on the machineCan connect to the loopback portInformation disclosure§3.5, accepted
A6A malicious or compromised Cortex storeSupplies the graph data the views renderTampering, Elevation (feeds A3)§3.3

Explicitly out of scope. A user who already has code execution as the running account: they can read ~/.claude and the database directly, with no need of this software. cortex-viz does not defend against its own operator.

3. The argument

3.1 Browser-to-server boundary (A1): three independent controls

cortex_viz/server/http_security.py implements three checks with different failure modes, so no single bug opens the boundary:

  1. Host-header allowlist (validate_host_header): the request's Host must name a loopback host. Counters DNS rebinding (CWE-346, CWE-350), which binding to 127.0.0.1 alone does not stop. A browser cannot forge Host, so checking it here is sound.
  2. Origin allowlist with control-character filtering (resolve_allowed_origin, _is_safe_header_value): counters permissive CORS (CWE-942) and response-header splitting (CWE-113). Python's send_header does not filter CR/LF, so the filter is not redundant with the standard library.
  3. Same-origin check on writes (enforce_same_origin_write): counters CSRF (CWE-352).

Independence argument: control 1 fails if hostname parsing is wrong, control 2 if origin comparison is wrong, control 3 if the write path skips the check. A defeat of one does not imply a defeat of the others, and controls 1 and 3 must both fail for a cross-site write to land.

3.2 Request-derived filesystem paths (A2): contained

Every path built from a request crosses one guard, cortex_viz/shared/path_containment.resolve_under: ~-expand, resolve symlinks, then require the RESOLVED path to sit under a separator-terminated prefix of the resolved base. It returns the proven path or None, never a boolean, so a caller cannot use a value the guard did not sanction. Two properties are deliberate — resolving before comparing (a symlink planted inside the base otherwise passes a textual check and dereferences outside it), and the trailing separator (/srv/wiki-backup is not inside /srv/wiki). Both are pinned by tests in tests/test_path_containment.py, and the module carries 0 surviving mutants.

The four readers on top of it:

ReaderBase
serve_static (/js/, /css/)directory-listing whitelist, then the served directory
serve_shared_asset (/shared/)the design-system foundation directory
wiki_read._safe_pathWIKI_ROOT, plus a .md/.bib suffix gate
git_diff_engine._sandboxed_abs_pathinfrastructure.file_sandbox.readable_roots

The last one closed a real defect, not a false positive. /api/file-diff and /api/trace/file take a filesystem path from the request; the absolute-path branch passed it to the diff engine with no boundary at all, so any file inside any git repository anywhere on the machine came back in full as a diff_type: "untracked" patch. Reproduced 2026-07-28 against a throwaway repository outside every configured root, and pinned by a paired regression test (same repo, same file, sandbox root listed vs unlisted) in tests/test_file_sandbox.py. Exposure was bounded by §3.1 — a page on the public internet cannot read the response, because CORS reflects only loopback origins — but a page served from any other loopback port could.

The readable roots are the places the graph's file nodes actually come from: the configured development roots, ~/.claude, and the temp roots agent scratchpads use. Measured against the live activity spine on 2026-07-28, all 1069 graphed absolute paths fall inside them and none outside, so the boundary costs no reachable functionality.

The 10 py/path-injection alerts (high) first raised 2026-07-25 are all resolved: one root-cause fix plus three guards rewritten from correct-but- unmodelled forms (os.path.commonpath, base in target.parents) into the str.startswith form CodeQL models as a Path::SafeAccessCheck. Verified by running the query locally on the tree before and after — 10 alerts to 0, with a paired full python-security-and-quality run confirming no other rule changed count (147 to 137).

3.3 Untrusted content rendered into the DOM (A3, A6)

Every string a view renders comes from a source the project does not control: memory content, session transcripts, wiki pages, file paths, commit messages. The views are vanilla DOM code with no framework escaping by default, so this is a real class here rather than a theoretical one.

The 2 js/remote-property-injection (high) findings in ui/brain/js/search_worker.js and ui/brain/js/trigram.js are closed. Triage result: not exploitable as rated — both sinks wrote into Object.create(null) maps, which absorb __proto__/constructor as own properties, and a reproduction confirmed Object.prototype unchanged across every hostile key the tokenizer can emit. The null prototype was nonetheless load-bearing, and what it prevented was a search-correctness defect rather than a pollution one: backed by a plain object, seen['constructor'] is truthy before any write, so a node labelled constructor would be dropped from the index and become unfindable. Both maps are now Sets. The claim rests on tests/js/trigram.test.mjs "trigram dedup does not collide with inherited property names" — 5 tests, verified to fail against the object-backed implementation, so the property is pinned rather than merely currently-true.

The 4 js/incomplete-html-attribute-sanitization (medium) findings were real quote-escaping defects. Both local escapers now encode the complete attribute set, and parsed-DOM regressions prove hostile values round-trip as data without creating event-handler attributes. CodeQL reports zero open findings after #97, completing the argument for this boundary.

3.4 Artifact and dependency integrity (A4)

  • One build path. .github/workflows/Release.yaml is the only way a release is produced. Before it existed, 2.7.1 was cut by hand, so there was no build to trace an artifact to.
  • Build provenance. The wheel, sdist, SBOM, and UI manifest each carry a Sigstore-backed attestation binding the artifact digest to this repository, workflow, and commit. Verify with gh attestation verify <file> --repo cdeust/cortex-viz.
  • UI fingerprint. cortex-viz-ui-manifest.sha256 pins every byte under ui/ to the tagged commit, and is itself attested. This is the control that matches the asset in §1: the browser-executed half. Scope limit: it covers first-party assets only, not the CDN-loaded libraries (§6, item 3).
  • SBOM. CycloneDX generated from uv.lock, covering the Python graph including the heavy viz-tile stack.
  • Pinned actions. Every GitHub Action is pinned by commit SHA, so a re-pointed tag cannot change what runs.
  • Continuous analysis. CodeQL (security-and-quality, both languages) on every push and pull request plus a weekly cron, because a new query pack lands against unchanged code and inaction never opens a pull request. OpenSSF Scorecard weekly.

Release v2.8.0 exercised the entire path. On 2026-08-03 its wheel, sdist, CycloneDX SBOM, and UI manifest all matched their published checksums and all four passed gh attestation verify against cdeust/cortex-viz.

Dependabot monitors both package ecosystems. npm audit --package-lock-only reported zero vulnerabilities on 2026-08-03 after updating the test-only brace-expansion dependency from affected 5.0.8 to fixed 5.0.9 for GHSA-rgw5-rvv9-x895 / CVE-2026-69152. package.json declares no runtime dependencies, so this JavaScript toolchain is not shipped in the Python wheel; the project remediates it anyway.

3.5 Local process reach (A5): accepted risk

Any process running as the user can connect to the loopback port while the server is running. This is inherent to a local developer tool with no authentication, and it is not a privilege gain: such a process can already read ~/.claude and the database directly. Accepted, with two limits: the server is short-lived (an idle watchdog stops it) and it is read-only with respect to the Cortex store. Documented for the user in PRIVACY.md.

4. Secure design principles applied

PrincipleHow it shows up here
Least privilegeCortex's memory tables are read only: cortex-viz never writes a memory, entity, or relationship, and the ban on import mcp_server.* keeps the separation structural. It is not a read-only database user: it creates and writes five tables of its own derived caches in the same database (see §1). Workflow tokens are permissions: read-all by default, widened per job only where a job must write (SARIF upload, release assets, attestations).
Economy of mechanismNo bundler, no framework, no plugin system, no authentication subsystem. There is less to get wrong because there is less.
Fail safe defaultsBinds 127.0.0.1, never 0.0.0.0. Unreachable database degrades to a named no-DB mode rather than erroring or inventing data. Unknown static path returns 403 or 404, never a guess.
Complete mediationHost, Origin, and same-origin checks run per request in the handler path, not once at startup.
Defence in depth§3.1's three independent controls; §3.4's provenance plus fingerprint plus SBOM plus checksums.
Open designPublic repository, MIT, public issue history. No security property depends on the source being secret.
Explicit degraded modesA view that cannot show everything says so, in the coverage indicator, rather than rendering a thinner picture silently.

5. Common implementation weaknesses

WeaknessStatus
Injection (SQL)Countered. All store access is parameterised read-only queries through psycopg; no string-built SQL.
Injection (command)git is invoked with argument lists, never a shell string.
Path traversal (CWE-22)Countered. One containment guard behind every request-derived path (§3.2); the unbounded diff-endpoint read it uncovered is fixed and regression-tested; 10 py/path-injection alerts closed.
XSS (CWE-79)Countered at the known sinks. Complete attribute escaping plus parsed-DOM regressions closed the four real findings; CodeQL has zero open alerts (§3.3).
CSRF (CWE-352)Countered, enforce_same_origin_write.
DNS rebinding (CWE-346/350)Countered, validate_host_header.
Permissive CORS (CWE-942)Countered, origin allowlist.
Header injection (CWE-113)Countered, control-character filter.
Hardcoded credentials (CWE-798)None. DATABASE_URL is user configuration; no secret is committed. Scorecard and CodeQL run continuously against the tree.
Broken cryptographyNot applicable: cortex-viz implements no cryptography, stores no passwords, and mints no keys or tokens. Signing is delegated to Sigstore through GitHub's attestation action.
Supply-chain tamperingCountered for first-party artifacts, §3.4. Not countered for the CDN-loaded libraries, #50.

6. What this case does not cover

Stated so the boundary of the argument is legible:

  1. Cortex itself. cortex-viz reads Cortex's store. The integrity and confidentiality of that store are Cortex's assurance case, not this one.
  2. The optional Claude Code marketplace delivery channel. It is one of several host paths for this cross-platform MCP server; neither that host nor its marketplace is in scope here.
  3. The unpkg CDN fetch. Four pages (brain-viz.html, atom-viz.html, methodology-viz.html) load three.js, OrbitControls, and 3d-force-graph from the public unpkg CDN at page load, disclosed in PRIVACY.md. They are version-pinned but carry no Subresource Integrity hash, so a compromised CDN could serve different bytes into the browser context and no control here would notice. HTTPS counters a network attacker, not the CDN itself. This is a real hole in the §3.4 fingerprint argument, not a theoretical one, and it is why that argument is scoped to first-party assets. Tracked in #50; the other views are offline-safe.
  4. Test-suite adequacy as a security control. Python statement coverage is 81% on merged main, and the independent JS suite exercises the browser surface. Coverage remains a functional measurement, not a security argument.
  5. Availability. Denial of service against a local, user-launched, short-lived developer tool is not modelled.

7. Verification status

ControlVerified howResult
Loopback binding, Host/Origin/CSRF guardsSource review, cortex_viz/server/http_security.pyImplemented
Path containmentCodeQL plus traversal regressionsShared symlink-aware guard used repo-wide; 10 findings closed
DOM sanitisationCodeQL plus parsed-DOM regressionsReal quote-escaping defects fixed; zero open CodeQL alerts
Provenance, SBOM, fingerprintv2.8.0 checksums plus gh attestation verifyAll four primary release artifacts verified on 2026-08-03
Action pinningWorkflow reviewAll actions SHA-pinned
Python test suiteRequired CI on merged main, 2026-08-03988 passed, 10 skipped
Python statement coveragecoverage run -m pytest plus coverage report81%: 11,674 statements, 2,272 missed; CI fails below 80%
JS test suiteRequired CI on merged main, 2026-08-03259 passed
JS test strengthStryker, scopedSurvivors triaged in tests/js/MUTATION_NOTES.md
Static analysisCodeQL, both languages, per push and weeklyRunning, zero open CodeQL alerts
Repository postureOpenSSF Scorecard7.4 as of 2026-08-03; OpenSSF Best Practices Silver

8. Conclusion, and how far it goes

The boundary that faces the widest adversary (a web page reaching the loopback server, A1) carries three independent controls with distinct failure modes, and the supply-chain boundary (A4) carries provenance, fingerprinting, an SBOM, and pinned actions. Those two arguments stand.

Both boundaries that handle untrusted data now stand. A2 (request-derived paths) routes every site through one containment guard; its real defect is fixed and regression-tested (§3.2). A3 (rendered content) has complete attribute escaping at the known sinks, parsed-DOM regressions, and zero open CodeQL alerts (§3.3). cortex-viz should still be read as a local, single-user developer tool, not as a remotely exposed hardened service; the CDN scope limit in §6 remains explicit.

This case is revisited when a trust boundary moves, when a finding is triaged, or at each release, whichever comes first.