DocsUnderstand

Security and privacy reviewers

Threat Model

Review the assets, trust boundaries, intended controls, and known limitations.

This guide describes the reference implementation as it exists today. Keep the stated support boundary in view as you evaluate or extend it.

This threat model documents the core boundaries so contributors and operators do not mistake the approval card for a full security system.

Assets

  • FHIR access token.
  • Chart data returned by the backend.
  • Proposed write payloads.
  • Model provider API key.
  • Public demo model-spend budget.

Trust boundaries

  • Browser to Next.js route handlers.

  • Next.js route handlers to FHIR backend.

  • Next.js route handlers to model provider.

  • Demo shared credential to per-browser session visibility filter.

  • Web approval card to backend write execution.

  • Browser-supplied demo backend name to server allowlist validation: the client sends only a name (x-demo-backend), never a URL or credential. The name is honored only for demo sessions, only when it survives the code-level eligibility gate plus the operator allowlist plus a server config-completeness check, and anything else falls back to the deployment default silently, so probing yields no signal. The rejected-proposal audit trail is always written to the deployment default, never the picked backend.

  • Chart free text into the model's instructions. Every free-text value the agent reads is wrapped in a <chart_text> boundary that the system prompt declares to be data, never instructions. The value is sanitized before wrapping: a literal chart_text tag inside it, in any case or spacing, is replaced with a visible marker, because a value that closed the boundary early would leave everything after it reading as content from outside the chart. Reading document bodies made that a realistic delivery route rather than a theoretical one, since an outside-records note is long, arbitrary, and written by someone else. The replacement is visible rather than silent so a targeted attempt shows up in the transcript the reviewer reads.

  • FHIR server response to the agent's chart view. The configured server is trusted to answer FHIR, not to steer the process that asked. Every FHIR fetch therefore refuses redirects (redirect: "manual"; a 3xx fails the request) and bounds both time and response bytes. Without that, a compromised, impersonated, or merely misconfigured server could redirect an ordinary search to any host the app process can reach, and the body that host returned would enter the chart — and model context — as if the FHIR server had returned it. Cross-origin redirects drop authorization but not custom auth headers, and nothing protects the response direction at all.

Intended controls

  • Medplum token stored in an HttpOnly, Secure, SameSite cookie.
  • No FHIR fetch follows a redirect, and none reads an unbounded body. The control is duplicated across three publish boundaries (lib/fhir/rest.ts, @lastehr/mcp, @lastehr/agent-write-conformance, which depend on nothing in lib/ by design); a source-level guard in lib/fhir/rest.test.ts fails if any copy stops applying it.
  • Every URL the transport fetches is built from the configured base URL plus a path derived from a ResourceType union. No server-supplied URL is ever dereferenced — which is also why Bundle.link[next] paging is not implemented; see FHIR coverage.
  • Backend AccessPolicy controls what the signed-in user can read or write.
  • Write tools in the web app use needsApproval: true.
  • Demo writes are tagged per browser session.
  • Public demo has per-IP and global rate limits.
  • The published MCP package is read-only by default; the only write opt-in (LASTEHR_MCP_WRITES=proposal) is elicitation-gated per-action human approval, and write tools are hidden from clients that cannot render the approval.

Dev output (synthetic demo only)

NEXT_PUBLIC_DEMO_DEV_OUTPUT is a deliberate, bounded carve-out from the "keep backend detail out of the browser" posture, for the demo's under-the-hood panel. The boundary:

  • Off by default; even when on, events stream only to demo sessions (demo_session_id present). SMART and signed-in sessions never receive FHIR detail.
  • Events are structured operation summaries: op, method, relative path, ok/err, duration, match counts, created ids (synthetic data the demo already renders). They NEVER contain access tokens or auth headers, base URLs or hosts, error or OperationOutcome diagnostic text, raw bodies, or the demo session id (redacted from _tag filters — it is an HttpOnly capability token).
  • One acknowledged signal: with the flag on, the stream echoes the resolved backend name, revealing the deployment default. The operator accepts this by enabling the flag.
  • Keep the flag off on any deployment heading toward real data.

Every new field added to FhirDevEvent is a potential leak vector: extend the negative assertions in lib/fhir/observed.test.ts and the dev-panel e2e first, and treat them as safety-boundary tests.

Known limitations

  • Reads are not approval-gated. Chart context goes to the configured model provider.
  • The approval card is a human review boundary, not a clinical correctness proof.
  • Local HAPI mode has no auth by default.
  • The checkout-only MCP Local Lab is therefore hard-wired to loopback HAPI and fixture identifiers; Compose binds the HAPI port to loopback by default. It is synthetic-only; it is not an authenticated or PHI-ready MCP deployment.
  • Session filtering on the shared public demo is not a security boundary for real data.
  • MCP clients do not render Last EHR's approval card; the MCP write profile substitutes MCP elicitation as the reviewable confirmation, and read-only remains the default for hosts and operators that opt out.

Contributor rules

  • Treat free text from chart resources as data, not instructions. Wrap it with asChartText, which is the only place the boundary is applied and the only place it is sanitized. A new field that renders free text without it is outside the boundary the system prompt describes.
  • Keep raw backend errors out of broad user-facing copy where possible.
  • Use structured FHIR query params.
  • Cap model-controlled search inputs.
  • Do not add destructive agent tools casually.
  • Add tests for any safety boundary that can regress.

Want a concrete starting point?

Run the limited synthetic HAPI walkthrough before connecting a real backend.

Run locally