Skip to content

Phase 3: vault MCP server with execution-time evidence revalidation - #4

Merged
MachineLearning-Nerd merged 21 commits into
mainfrom
phase-3-vault-mcp
Aug 26, 2026
Merged

MachineLearning-Nerd merged 21 commits into
mainfrom
phase-3-vault-mcp

Conversation

@MachineLearning-Nerd

Copy link
Copy Markdown
Owner

Phase 3 — Vault MCP server

The vault's public surface over Streamable HTTP: exactly five tools, no raw-row query tool exists, and only release_result is destructive.

What this adds

  • MCP scaffold (src/server/mcp.ts): stateless Streamable HTTP over node:http, loopback-only bind, 1 MB body cap (SDK 1.30 dropped its own guard), chunked bodies refused. Verified end-to-end with the SDK client over a real socket.
  • describe_dataset: schema, sensitivity labels, policy constants, one aggregate count — no code path touches a row value (payload scanned for canary/fixture values in tests).
  • prepare_analysis: mission authorization strictly precedes data access (denied mission → zero SQLite queries, proven with an instrumented database); aggregation accepts only allowlisted identifiers; small cells are suppressed inside the vault; the candidate is stored under a vault-issued queryId.
  • validate_release: contract verdict + hashes over the vault-stored candidate only. A test smuggles a contract-violating candidate straight into the store to prove storage is not trust.
  • release_result: loads only vault-stored evidence, re-verifies the full contract and both hashes, then recomputes the aggregation from the live database — a candidate the vault cannot reproduce (fabricated group sizes, regrouped cells) is denied as evidence_mismatch. Every call writes an audit record; only success writes a receipt, at most one per queryId, enforced again at the write. This closes the PR Phase 2: deterministic ReleaseContract library #3 trust-boundary finding as planned.
  • render_safe_chart: renders released aggregates only (no receipt → refused), never group_size.

Fail-closed guarantees under test (116 tests total)

  • Denied/needs_review/thrown paths: audit record written, zero release side effects, no internal error text echoed.
  • Replay denied with the first receipt intact; duplicate receipt refused at the write itself.
  • The flagship case: the real size-2 cell claimed as group_size: 3 passes every contract rule and is caught only by execution-time evidence recomputation.
  • Every payload that crosses the wire is scanned for canary and raw identifiers.

AI usage

Built with AI assistance (Claude Code); every change reviewed and understood by the author. Internal quality-gate review ran before this PR; all six of its findings are fixed in the final commit.

Streamable HTTP transport in stateless mode over node:http — all
durable state will live in the vault store, so there is no MCP session
to manage. The public surface is exactly five tools (no raw-row query
tool exists), release_result alone is marked destructive, and
unimplemented handlers fail closed. Verified end-to-end with the SDK
client over a real socket. skipLibCheck added because the SDK's own
declaration files do not compile under exactOptionalPropertyTypes; our
sources stay fully checked.

AI-assisted (Claude Code); reviewed and understood by the author.
Returns column names with sensitivity labels, the fixed mission
constants, safe dimensions, allowed metrics, and one aggregate row
count — no code path touches a row value, and a test scans the full
payload for canary and fixture values to prove it.

AI-assisted (Claude Code); reviewed and understood by the author.
Authorization strictly precedes data access: a denied mission or a bad
plan leaves with zero SQLite queries executed, proven by an
instrumented database in tests. The aggregate query accepts only
allowlisted identifiers (never caller values), small cells are
suppressed inside the vault before any candidate exists, and the
prepared candidate is stored keyed by a vault-issued queryId — the
evidence release-time checks will load instead of trusting a caller
body. The vault surface-lock test grows by exactly the aggregate
accessor.

AI-assisted (Claude Code); reviewed and understood by the author.
The verdict, findings, and hashes always come from what
prepare_analysis stored under the queryId — there is no path that
validates a caller-supplied body. A test smuggles a contract-violating
candidate straight into the store to prove storage is not trust: the
contract still denies it.

AI-assisted (Claude Code); reviewed and understood by the author.
Release loads the vault-stored candidate by queryId — the caller
supplies only the id and the two approved hashes. Before any release
write, the full contract re-verifies and the vault recomputes the
aggregation from its own data: a candidate whose rows differ from what
the vault can reproduce right now (regrouped cells, fabricated group
sizes, smuggled rows) is denied as evidence_mismatch. Every call
leaves an audit record; only success creates a receipt, at most one
per queryId, enforced again at the write. The flagship test smuggles
the real size-2 cell claimed as group_size 3 — it passes every
contract rule and is caught only by the evidence recomputation.

AI-assisted (Claude Code); reviewed and understood by the author.
Charts render released data only: without a receipt the aggregate is
still a candidate and the tool refuses. The end-to-end test drives the
real handlers through the MCP client over a live socket — denied
mission, prepare, validate, release, denied replay, chart — and scans
every payload that crossed the wire for raw values.

AI-assisted (Claude Code); reviewed and understood by the author.
Six findings from the internal quality gate, all fixed: request bodies
are capped at 1 MB and chunked bodies refused (SDK 1.30 dropped its own
size guard); audited query ids are clipped at the write so a hostile id
cannot grow the enforcement record; a thrown release error now audits
as denied and returns a generic internal_error instead of echoing the
exception; the server binds to loopback only; stored candidates are
deep-frozen copies rather than aliases of caller arrays; and the
duplicate-receipt guard plus the needs_review audit branch gained the
tests they were missing.

AI-assisted (Claude Code); reviewed and understood by the author.
Copilot AI lite review requested due to automatic review settings August 25, 2026 13:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add vault MCP server with execution-time evidence revalidation

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Exposes five loopback-only MCP tools without any raw-row query surface.
• Authorizes, aggregates, suppresses, validates, and revalidates evidence before one-time release.
• Adds fail-closed auditing, receipts, safe charts, and end-to-end security tests.
Diagram

graph TD
  C["MCP Client"] -->|tool call| M["HTTP MCP"] -->|dispatch| H["Tool Handlers"]
  H -->|aggregate and recompute| D[("Live SQLite")]
  H -->|prepared evidence| S[("Vault Store")]
  H -->|candidate| V["Release Contract"] -->|verdict and hashes| H
  S -->|candidate and receipt| H -->|released aggregate| O["Safe Output"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist vault state in SQLite
  • ➕ Survives process restarts and supports multiple server instances.
  • ➕ Unique constraints and transactions can atomically enforce receipts and audit writes.
  • ➖ Requires schema migrations and transaction design beyond the current in-memory phase.
  • ➖ Adds operational complexity to a bounded demonstration server.
2. Use signed opaque release tokens
  • ➕ Reduces server-side prepared-candidate state.
  • ➕ Can simplify horizontal scaling of stateless HTTP workers.
  • ➖ Moves evidence across the trust boundary and increases key-management complexity.
  • ➖ Still requires live recomputation and replay protection at release time.

Recommendation: Keep the vault-owned candidate store and live recomputation approach for this phase because it minimizes caller-controlled evidence and directly closes the trust-boundary gap. If durability or multi-process deployment becomes a requirement, retain the same handler contract but replace the in-memory store with transactional SQLite storage and a unique queryId receipt constraint.

Files changed (11) +2414 / -19

Enhancement (5) +590 / -3
findings.tsAdd execution-evidence mismatch finding +4/-1

Add execution-evidence mismatch finding

• Extends contract findings with evidence_mismatch so release-time recomputation failures have a specific fail-closed reason.

src/contract/findings.ts

handlers.tsImplement the five vault tool handlers +222/-0

Implement the five vault tool handlers

• Implements metadata description, authorized aggregate preparation, stored-candidate validation, one-time release, and released-only chart rendering. Release rechecks the full contract and hashes, recomputes the live aggregation, audits every outcome, and strips group_size from output.

src/server/handlers.ts

mcp.tsExpose a hardened stateless MCP endpoint +183/-0

Expose a hardened stateless MCP endpoint

• Registers exactly five MCP tools with Zod input schemas and marks only release_result destructive. Hosts stateless Streamable HTTP on loopback, restricts routing to /mcp, caps request bodies at 1 MB, rejects chunked POST bodies, and sanitizes transport failures.

src/server/mcp.ts

store.tsAdd immutable vault evidence and receipt storage +119/-0

Add immutable vault evidence and receipt storage

• Introduces vault-issued query and receipt identifiers, deep-frozen prepared candidates, bounded audit records, and one receipt per queryId. The store keeps release evidence vault-owned rather than accepting caller-supplied candidates.

src/server/store.ts

database.tsAdd allowlisted aggregate query execution +62/-2

Add allowlisted aggregate query execution

• Extends the vault database with grouped counts and rounded averages over safe dimensions only. SQL identifiers come from closed allowlists, and prepared statements are cached by bounded query shape without exposing raw rows.

src/vault/database.ts

Tests (3) +617 / -1
handlers.test.tsCover vault tool policy and release guarantees +445/-0

Cover vault tool policy and release guarantees

• Adds adversarial tests for authorization-before-query, suppression, stored-candidate validation, hash verification, live evidence recomputation, replay denial, auditing, immutable state, and safe chart rendering. Tests also verify that sensitive values and internal errors never cross the boundary.

src/server/handlers.test.ts

mcp.test.tsVerify MCP transport and end-to-end release flow +163/-0

Verify MCP transport and end-to-end release flow

• Exercises the five-tool registry over a real Streamable HTTP socket, including annotations, fail-closed stubs, route isolation, body limits, and chunked-body rejection. Adds an end-to-end prepare, validate, release, replay-denial, and chart flow with payload leak checks.

src/server/mcp.test.ts

vault.test.tsPin the aggregate-only database surface +9/-1

Pin the aggregate-only database surface

• Updates the database API lock test to allow the new aggregate method while continuing to reject raw-row accessors and additional module exports.

src/vault/vault.test.ts

Other (3) +1207 / -15
package-lock.jsonLock MCP SDK and validation dependency graph +1202/-15

Lock MCP SDK and validation dependency graph

• Regenerates the lockfile for @modelcontextprotocol/sdk 1.30.0, Zod 4.4.3, and their transitive HTTP, schema, and transport packages.

package-lock.json

package.jsonAdd MCP SDK and Zod runtime dependencies +4/-0

Add MCP SDK and Zod runtime dependencies

• Adds the MCP server/client SDK and Zod schema validation as pinned production dependencies.

package.json

tsconfig.jsonSkip incompatible dependency declaration checks +1/-0

Skip incompatible dependency declaration checks

• Enables skipLibCheck to avoid MCP SDK declaration incompatibilities with exactOptionalPropertyTypes while preserving full checking for project sources.

tsconfig.json

@qodo-code-review

qodo-code-review Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Audit log enables memory exhaustion ✗ Dismissed 🐞 Bug ☼ Reliability ⭐ New
Description
The store deliberately retains every audit record in an uncapped process-lifetime array, and
unauthenticated denial paths append records on every call. Repeated invalid tool calls therefore
cause permanent heap growth until the vault is degraded or terminated.
Code

src/server/store.ts[R93-95]

+      // Bounded: a caller can drive prepare_analysis in a loop and every entry
+      // retains its rows. The audit log deliberately stays uncapped — dropping
+      // enforcement records to save memory would be fail-open.
Evidence
auditLog is an array owned for the store lifetime and recordAudit unconditionally pushes into it
without eviction. Mission, dimension, metric, release, and pre-release chart denial paths are
remotely triggerable and repeatedly call recordAudit.

src/server/store.ts[58-61]
src/server/store.ts[103-118]
src/server/handlers.ts[173-197]
src/server/handlers.ts[246-265]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Every audited denial permanently grows the in-memory audit array, allowing repeated requests to exhaust process memory.

## Issue Context
Audit integrity does not require retaining an unlimited number of records in heap. Records should be written to a durable append-only sink with bounded buffering, or retained under an explicit bounded/rotated policy that preserves required evidence.

## Fix Focus Areas
- src/server/store.ts[58-118]
- src/server/handlers.ts[170-208]
- src/server/handlers.ts[246-265]

Replace unbounded in-memory retention with bounded buffering plus durable/rotated storage, and add a stress test proving repeated denials do not grow resident in-memory records without limit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Origin validation is missing ✓ Resolved 🐞 Bug ⛨ Security
Description
The plain node:http MCP endpoint accepts requests solely by path and never validates Origin or
Host, allowing a malicious website to reach the loopback vault through DNS rebinding and invoke
its tools. Loopback binding alone does not prevent this attack, and the MCP Streamable HTTP
specification requires rejecting invalid origins.
Code

src/server/mcp.ts[R125-126]

+  const httpServer = createServer((request, response) => {
+    if (request.url !== "/mcp") {
Evidence
The request handler immediately routes by URL and then processes the body without checking either
header. The MCP transport specification explicitly requires Origin validation and warns that
localhost binding does not stop DNS rebinding.

src/server/mcp.ts[125-159]
src/server/mcp.ts[168-180]
🌐 The Streamable HTTP security requirements state that servers MUST validate the Origin header and reject invalid origins with 403 to prevent DNS rebinding; localhost binding is only an additional SHOULD.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loopback MCP endpoint does not validate `Host` or `Origin`, so DNS rebinding can expose vault tools to a malicious webpage.

## Issue Context
Streamable HTTP requires Origin validation. Add fail-closed localhost Host/Origin guards before routing or reading request bodies, returning 403 for invalid values while preserving non-browser SDK clients that omit `Origin`.

## Fix Focus Areas
- src/server/mcp.ts[125-145]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Released chart data gets evicted ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
savePrepared evicts the oldest prepared entry even when that query already has a receipt, while
renderSafeChart requires both the prepared entry and receipt. After 500 later preparations, a
successfully released query therefore becomes unknown_query_id and can no longer be rendered.
Code

src/server/store.ts[R96-98]

+      if (prepared.size > MAX_PREPARED_ENTRIES) {
+        const oldest = prepared.keys().next();
+        if (!oldest.done) prepared.delete(oldest.value);
Evidence
The cap deletes the oldest prepared entry without checking release state. The chart handler first
requires getPrepared() and returns unknown_query_id if it was evicted, even though
getReceipt() retains the successful release record.

src/server/store.ts[59-61]
src/server/store.ts[92-99]
src/server/store.ts[119-133]
src/server/handlers.ts[256-275]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The prepared-entry cap evicts released candidates, but `render_safe_chart` still needs the candidate after release. This makes a valid released chart disappear after enough subsequent preparations.

## Issue Context
Receipts are retained separately, so eviction leaves an orphan receipt and causes `renderSafeChart` to return `unknown_query_id`.

## Fix Focus Areas
- src/server/store.ts[56-99]
- src/server/handlers.ts[256-275]

Retain a bounded immutable released projection alongside each receipt, or otherwise ensure eviction cannot remove data still required by the released-chart API. Add a test that releases the oldest query, prepares past the cap, and verifies its chart remains available.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Preparation falsely marked non-destructive ✗ Dismissed 🐞 Bug ≡ Correctness ⭐ New
Description
prepare_analysis is annotated destructiveHint: false even though reaching the store cap deletes
the oldest candidate and invalidates its query ID. MCP clients may therefore skip
destructive-operation confirmation for a call that removes prior vault state.
Code

src/server/mcp.ts[R75-78]

+      // Not read-only: preparing persists the candidate under a new queryId.
+      // Not destructive to the dataset: it adds a candidate, and evicts the
+      // oldest one once the store cap is reached.
+      annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
Evidence
The tool metadata explicitly declares preparation non-destructive while its own comment acknowledges
that it evicts the oldest entry. The store confirms this is a deletion, and MCP documentation
defines a destructive modification as one that is not merely additive.

src/server/mcp.ts[64-80]
src/server/store.ts[92-99]
🌐 MCP describes destructiveHint as indicating whether a modifying tool's change is destructive rather than additive, and notes clients may use it to decide whether to request confirmation.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The MCP annotation says preparation is non-destructive, but preparation can evict and delete an existing candidate.

## Issue Context
MCP defines `destructiveHint` as whether an environment-modifying tool performs destructive rather than additive changes; clients may use it to decide whether confirmation is needed.

## Fix Focus Areas
- src/server/mcp.ts[64-80]
- src/server/store.ts[92-99]
- src/server/mcp.test.ts[60-75]

Set `destructiveHint: true` while preparation can evict prior state, or redesign storage so preparation is strictly additive and expiration is handled independently with an explicit lifecycle.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Suppression count leaks cells ✗ Dismissed 🐞 Bug ⛨ Security
Description
prepare_analysis returns the stored entry including the exact suppressedCells count, so callers
can determine whether requested dimension combinations contain populations below the minimum group
size. In the seeded case, the response explicitly reveals that one otherwise absent group exists but
was suppressed.
Code

src/server/handlers.ts[176]

+      return jsonResult(entry);
Evidence
Suppression is calculated from cells whose group size is below three, persisted on the prepared
entry, and the entire entry is serialized to the caller; the test confirms a public value of one for
the known size-two APAC cell.

src/server/handlers.ts[19-35]
src/server/handlers.ts[151-176]
src/server/store.ts[65-84]
src/server/handlers.test.ts[96-117]
src/vault/seed.ts[27-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The public preparation response reveals the exact number of cells removed by the small-cell policy, creating an inference channel about protected groups.

## Issue Context
The count may remain internal for observability, but the MCP response should expose only the query identifier and releasable candidate data needed by the workflow.

## Fix Focus Areas
- src/server/handlers.ts[151-176]
- src/server/store.ts[4-8]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (1)
6. Audit findings remain mutable ✓ Resolved 🐞 Bug ◔ Observability
Description
recordAudit freezes only a copied findings array while retaining each mutable finding object, and
audits() exposes those same objects to consumers. Any in-process consumer can therefore alter
historical finding codes or details after the audit write, undermining the audit trail's integrity.
Code

src/server/store.ts[97]

+        findings: Object.freeze([...findings]),
Evidence
The store copies and freezes the findings array but does not clone or freeze its elements, then
returns shallow copies of the audit-log array containing the original records. This contrasts with
prepared rows, which are explicitly copied and frozen at the write boundary.

src/server/store.ts[89-102]
src/server/store.ts[62-84]
src/contract/findings.ts[1-1]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Audit records retain mutable references to finding objects even though the containing array and record are frozen.

## Issue Context
Create frozen copies of every finding at the write boundary so values returned by `audits()` cannot mutate historical enforcement evidence. Add a test that attempts mutation through a retrieved audit record.

## Fix Focus Areas
- src/server/store.ts[89-102]
- src/server/handlers.test.ts[314-410]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Web pages:
  +2 more
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 2e82444

Results up to commit d5513d4 ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Origin validation is missing ✓ Resolved 🐞 Bug ⛨ Security
Description
The plain node:http MCP endpoint accepts requests solely by path and never validates Origin or
Host, allowing a malicious website to reach the loopback vault through DNS rebinding and invoke
its tools. Loopback binding alone does not prevent this attack, and the MCP Streamable HTTP
specification requires rejecting invalid origins.
Code

src/server/mcp.ts[R125-126]

+  const httpServer = createServer((request, response) => {
+    if (request.url !== "/mcp") {
Evidence
The request handler immediately routes by URL and then processes the body without checking either
header. The MCP transport specification explicitly requires Origin validation and warns that
localhost binding does not stop DNS rebinding.

src/server/mcp.ts[125-159]
src/server/mcp.ts[168-180]
🌐 The Streamable HTTP security requirements state that servers MUST validate the Origin header and reject invalid origins with 403 to prevent DNS rebinding; localhost binding is only an additional SHOULD.

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loopback MCP endpoint does not validate `Host` or `Origin`, so DNS rebinding can expose vault tools to a malicious webpage.

## Issue Context
Streamable HTTP requires Origin validation. Add fail-closed localhost Host/Origin guards before routing or reading request bodies, returning 403 for invalid values while preserving non-browser SDK clients that omit `Origin`.

## Fix Focus Areas
- src/server/mcp.ts[125-145]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Suppression count leaks cells ✗ Dismissed 🐞 Bug ⛨ Security
Description
prepare_analysis returns the stored entry including the exact suppressedCells count, so callers
can determine whether requested dimension combinations contain populations below the minimum group
size. In the seeded case, the response explicitly reveals that one otherwise absent group exists but
was suppressed.
Code

src/server/handlers.ts[176]

+      return jsonResult(entry);
Evidence
Suppression is calculated from cells whose group size is below three, persisted on the prepared
entry, and the entire entry is serialized to the caller; the test confirms a public value of one for
the known size-two APAC cell.

src/server/handlers.ts[19-35]
src/server/handlers.ts[151-176]
src/server/store.ts[65-84]
src/server/handlers.test.ts[96-117]
src/vault/seed.ts[27-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The public preparation response reveals the exact number of cells removed by the small-cell policy, creating an inference channel about protected groups.

## Issue Context
The count may remain internal for observability, but the MCP response should expose only the query identifier and releasable candidate data needed by the workflow.

## Fix Focus Areas
- src/server/handlers.ts[151-176]
- src/server/store.ts[4-8]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Audit findings remain mutable ✓ Resolved 🐞 Bug ◔ Observability
Description
recordAudit freezes only a copied findings array while retaining each mutable finding object, and
audits() exposes those same objects to consumers. Any in-process consumer can therefore alter
historical finding codes or details after the audit write, undermining the audit trail's integrity.
Code

src/server/store.ts[97]

+        findings: Object.freeze([...findings]),
Evidence
The store copies and freezes the findings array but does not clone or freeze its elements, then
returns shallow copies of the audit-log array containing the original records. This contrasts with
prepared rows, which are explicitly copied and frozen at the write boundary.

src/server/store.ts[89-102]
src/server/store.ts[62-84]
src/contract/findings.ts[1-1]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Audit records retain mutable references to finding objects even though the containing array and record are frozen.

## Issue Context
Create frozen copies of every finding at the write boundary so values returned by `audits()` cannot mutate historical enforcement evidence. Add a test that attempts mutation through a retrieved audit record.

## Fix Focus Areas
- src/server/store.ts[89-102]
- src/server/handlers.test.ts[314-410]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/server/mcp.ts
Comment thread src/server/handlers.ts
Comment thread src/server/store.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a security-critical trust boundary with dynamically constructed SQL and a new network-facing surface handling sensitive data, which warrants final human review despite the thorough tests.

Review details
  • Files reviewed: 9/11 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/server/handlers.ts Outdated
…ction

DNS rebinding reaches a loopback-bound server with same-origin requests,
so the endpoint now requires a loopback Host and, when an Origin header
is present, a loopback Origin (per the MCP Streamable HTTP spec).
Audit records keep frozen copies of each finding rather than references
the producer can mutate afterwards. The released-row projection reached
its third use and is now one shared helper for release and chart; the
contract library's copy stays independent on purpose — the hash
definition must not depend on server code.

AI-assisted (Claude Code); reviewed and understood by the author.
prepare_analysis persists a candidate under a new queryId, so it is not
read-only; the annotation test now pins the full hint matrix for all
five tools. Receipt hashes carry the branded Sha256Hex type end to end,
so only values that came out of the contract's hasher can be stored.

AI-assisted (Claude Code); reviewed and understood by the author.
Suppressing per request at the requested granularity was reversible:
the vault published the same totals at several granularities, and a
coarse total still contained the rows a finer query hid, so subtracting
one from the other reconstructed the suppressed cell. A sweep over
parent/child dimension pairs recovered 11 suppressed cells, four of
them a single person, including individual resolution times.
Suppression now runs once at the finest granularity and every coarser
aggregate is rolled up from the survivors, so all granularities stay
mutually consistent; the same sweep now yields zero residuals. Coarse
totals are correspondingly lower because they no longer include
withheld rows.

Also from the same review: the prepared-candidate cache is bounded at
500 with FIFO eviction (the audit log stays uncapped on purpose —
dropping enforcement records to save memory would be fail-open); the
sensitive-dimension and pre-release-chart denials now write audit
records; every tool call is wrapped so a thrown error becomes a generic
internal_error instead of the SDK echoing the exception message; and
the database header comment no longer claims something groupSize
contradicts.

AI-assisted (Claude Code); reviewed and understood by the author.
The grouping-key separator was a literal NUL byte, which made
handlers.ts a binary blob to git: the whole release and aggregation
path showed as "Binary files differ" and could not be diff-reviewed,
and the rendered source looked like an ordinary space so the control
character was invisible to a reader. A pipe cannot occur in the closed
week/region/category value domains either, so grouping is unchanged;
the key is internal and never hashed or released.

AI-assisted (Claude Code); reviewed and understood by the author.
GET /mcp reached the SDK's standalone SSE branch, and because a fresh
transport is built per request the SDK's one-stream-per-session guard
never fired: 400 GETs held 400 open streams and 65 MB, and a single one
kept close() pending forever. The vault never pushes server-initiated
messages, so non-POST is now refused with 405 (the SDK client treats
that as "no SSE offered" and continues). Probed after the fix: 0 of 200
GETs held a stream, close() returns in 0 ms.

An unauthorized mission left no audit record while a mistyped dimension
did — the more significant enforcement event was the invisible one.
Both now audit.

db.aggregate no longer takes a caller-chosen granularity: it always
groups by SAFE_DIMENSIONS, with both statements prepared once at open.
Suppression irreversibility rested on the single caller happening to
pass the finest granularity, and the layer holding the raw rows now
enforces it structurally instead. Deletes the per-shape statement cache
and its allowlist loop.

Tests: the any-granularity claim now iterates all eight dimension
subsets, the differencing check covers the average metric as well as
counts, and the same-origin assertion pins the response body rather
than a status code that a 500 would also satisfy.

AI-assisted (Claude Code); reviewed and understood by the author.
Publishing the raw total alongside suppressedCells lets a caller solve
how many withheld cells are size 1 and how many size 2. It does not say
which cells, so no individual is isolated, and that a missing cell is
under k is inherent to any suppression scheme. Documented rather than
removed: the approver needs the real dataset size.

AI-assisted (Claude Code); reviewed and understood by the author.
close() waits for every open connection, so a socket that connected and
never completed a request kept the vault running indefinitely — probed
with both a silent socket and a slowloris partial-header write, each
left close() pending past 8 seconds. closeAllConnections() brings both
to 1ms and 0ms. The next phase needs a deterministic shutdown to wire
TrueForge, and a caller must not be able to deny it with one idle TCP
connection.

AI-assisted (Claude Code); reviewed and understood by the author.
Grouping by a sensitive column audited; naming the same column as the
metric did not, so a caller could enumerate email, phone, customer_id
and free_text through the adjacent field and leave the enforcement
record untouched. Both reaches now audit.

The malformed-plan denials next to it — too many dimensions, duplicate
dimension — stay unaudited deliberately: they are client bugs rather
than reaches for a sensitive column, and auditing them would grow the
deliberately uncapped log for nothing.

AI-assisted (Claude Code); reviewed and understood by the author.
The sensitive-column audits ran after the length and duplicate checks,
so one padding element swallowed them: {dimensions:["week","week"],
metric:"email"} denied as duplicate_dimension and wrote no record, and
twelve probes naming every sensitive column in either slot left the
audit log empty. Allowlists now run first, so a malformed-plan denial
can only be reached once every named column is allowlisted and cannot
carry a sensitive column name — which is what the comment beside it
already claimed.

The shutdown test was vacuous: it closed in the same tick as the socket
write, before the server tracked the connection, so it passed with
closeAllConnections removed. A 10ms settle makes it fail without the
guard, verified by counterfactual.

Also corrects the prepare_analysis annotation comment, which said the
tool never transitions state while savePrepared evicts the oldest
candidate past the store cap.

AI-assisted (Claude Code); reviewed and understood by the author.
The VaultDatabase comment said groupSize() "returns a count the
aggregate surface already exposes." It does not: it is a raw
COUNT(*) with no suppression filter, while the public surface only
ever publishes rollups of survivors at k>=3. Where every category
under a (week, region) pair is suppressed, groupSize returns that
withheld cell's exact size — vault.test.ts already asserts precisely
that. Nothing calls it from a tool today, so this is not a live
disclosure, but that sentence was the written justification for
keeping the method on the exported type and pointed the next author
at the wrong conclusion.

Scopes the prepare_analysis audit claim to what the handler can see.
A request the tool schema rejects (metric: 7 after a sensitive
dimensions array) never reaches the handler and leaves no audit
record. Not a disclosure — no data is touched and the schema error is
identical whichever column was named, so there is no oracle — but the
comment read as an absolute guarantee. Widening the schema so the
handler could audit these was considered and declined: dimensions and
metric would become unknown across the handler boundary and every
downstream string assumption would need guarding, for an event that
carries no signal.

Pins the third of the three deep-frozen copies. The columns freeze
was the one claim in that comment no test covered; verified by
counterfactual that the new assertion fails without it.

AI-assisted (Claude Code); reviewed and understood by the author.
The previous commit corrected this comment but named the wrong
condition, and got it wrong in both directions. It said groupSize()
returns a withheld cell's precise size where every category under a
(week, region) pair is suppressed. Measured against the seed:

  pair            raw  published  residual  suppressed cells
  2026-W33/APAC     2          0         2  [1, 1]   <- all suppressed
  2026-W33/NA       4          3         1  [1]
  2026-W32/EU       9          7         2  [2]

The one pair matching the stated condition returns a sum across two
suppressed cells, and no cell there has size 2. The pairs where the
residual really is one cell's precise size — including a cell of size
1, a single individual — both fail that condition, so the comment
steered away from the sharpest case. The rule is the number of
suppressed cells under the pair, not whether all of them are
suppressed. Also drops the previous commit's claim that vault.test.ts
asserts a cell's precise size: line 110 asserts a (week, region) group
size, which is a different quantity.

Pins the audit log's copy-on-read. audits() freezing its return value
is cosmetic; returning a copy is the guard, and without it a consumer
holds the live array and can splice enforcement records away. It was
the one immutability guard in the store whose removal left the whole
suite green.

AI-assisted (Claude Code); reviewed and understood by the author.
render_safe_chart re-serves entry.candidate.rows after release with no
recompute and no revalidation — it only checks that a receipt exists.
The freeze on the candidate object is the only thing stopping a
post-release in-process swap of that array, which store.ts:69-71
already claimed. No test asserted it. Verified end to end: with the
freeze removed, a legitimate prepare/validate/release followed by
entry.candidate.rows = [{region:"NA",ticket_count:1,group_size:1}]
makes the chart serve [{"region":"NA","ticket_count":1}] — a cell
covering one person, out through a public tool, which is the exact
claim this PR exists to make. The test whose name promises deep-frozen
candidates now asserts the candidate and entry objects too.

Cuts the safety line the groupSize comment drew in the wrong place.
The previous wording was arithmetically true but told a reader that
pairs with more than one suppressed cell are safe. From the caller's
actual position — published rollups plus groupSize probes, cell sizes
known to be 1 or 2 — the residual and the set of missing categories
usually solve the exact size of every suppressed cell under a pair,
including at pairs with two of them; one of the two pairs the old rule
named is in fact the one case that stays ambiguous. The comment now
says what groupSize really adds, which is localization: that size-1
cells exist is already public from rowCount and suppressedCells, but
this narrows one to a single week, region, and two categories.

Corrects two over-claims of my own. The audits() comment called
splicing "the one fail-open the audit trail cannot survive" — the copy
is shallow, so rewriting an outcome on a shared record is a second
route, guarded by the per-record freeze. And 859cc12's message said
the audits() copy was the only store immutability guard whose removal
left the suite green; six others do too, five of which stay
deliberately unpinned as defence in depth with no reachable defect.

AI-assisted (Claude Code); reviewed and understood by the author.
@MachineLearning-Nerd
MachineLearning-Nerd requested a balanced review from Copilot August 26, 2026 01:29
@MachineLearning-Nerd

Copy link
Copy Markdown
Owner Author

/review

Comment thread src/server/store.ts Outdated
Comment thread src/server/store.ts Outdated
Comment thread src/server/mcp.ts
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 81ed3fd

The prepared-entry cap evicted the oldest entry unconditionally, so a
successfully released query lost its candidate after 500 later
preparations and render_safe_chart answered unknown_query_id while the
receipt still existed — an orphan receipt promising rows the vault had
discarded. Eviction now skips entries that hold a receipt: released
entries are pinned for the process lifetime, the same deliberate trade
already made for the receipts themselves, and the cap keeps bounding
what an unreleased prepare loop can retain. Regression test releases a
query, prepares 500 more, and asserts the chart still serves it;
verified failing before the fix.

Found by Qodo review on 81ed3fd.

AI-assisted (Claude Code); reviewed and understood by the author.
@MachineLearning-Nerd

Copy link
Copy Markdown
Owner Author

Dispositions for the three new findings (review of 81ed3fd):

2. Released chart data gets evicted — confirmed and fixed in 01f1866. Reproduced exactly as described: release a query, prepare 500 more, and render_safe_chart answered unknown_query_id while the receipt survived — an orphan receipt promising rows the vault had discarded. Eviction now skips entries holding a receipt, so it only ever drops unreleased candidates; released entries are pinned for the process lifetime, the same deliberate trade already made for receipts. Regression test added and verified failing before the fix.

1. Audit log enables memory exhaustion — declined. The uncapped audit log is a deliberate, documented fail-closed decision, not an oversight: dropping enforcement records to reclaim memory is precisely the fail-open a release-clearance vault cannot make. The exposure is bounded in practice — the surface is loopback-only, every caller-controlled field in a record is clipped at the write (queryId to 64 chars, details to 120), and a record is a few hundred bytes, so degrading the process takes millions of completed HTTP round trips from the same local machine that could more easily just kill the process. A durable append-only sink is the right production answer and the wrong scope for this phase; the in-memory log is the phase's audit evidence.

4. Preparation falsely marked non-destructive — declined. With 01f1866 in place the premise no longer holds: eviction can never remove a released entry, so prepare_analysis cannot invalidate any state a client has a durable claim on. What eviction drops is an unreleased candidate — ephemeral working state, reconstructible by re-preparing, closer to cache expiry than to a destructive update. Marking prepare destructive would also make clients confirm every routine prepare, training users to click through the hint and diluting it on release_result, the one tool where it must mean something. The annotation comment states the eviction behaviour explicitly so the hint is an informed judgement, not an omission.

AI-assisted (Claude Code); reviewed and understood by the author.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a network-facing, security- and privacy-critical vault boundary (PII suppression, evidence revalidation, DNS-rebinding defenses) whose correctness warrants final human verification despite no defects being found.

Review details
  • Files reviewed: 9/11 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@MachineLearning-Nerd
MachineLearning-Nerd merged commit 40f5122 into main Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants