Skip to content

BF-08 — AI-native client layer: typed SDK + reactive useClinicalQuery (LISTEN/NOTIFY) + propose-only local MCP server - #33

Merged
AJ112103 merged 4 commits into
mainfrom
bonfire-BF-08
Jul 10, 2026
Merged

BF-08 — AI-native client layer: typed SDK + reactive useClinicalQuery (LISTEN/NOTIFY) + propose-only local MCP server#33
AJ112103 merged 4 commits into
mainfrom
bonfire-BF-08

Conversation

@AJ112103

Copy link
Copy Markdown
Contributor

BF-08 — the AI-native client layer

Ships the typed SDK, the reactive useClinicalQuery store over Postgres LISTEN/NOTIFY, and a local propose-only MCP server. This is the first place an untrusted agent enters the system, so the slice makes two things structural rather than hoped-for:

  1. subject comes from the BF-13-verified membership row, never from client input. The last two slices' panels kept flagging that buildCcp/searchClinical trust an in-process subject. BF-08 closes it: generated method inputs are Omit<CoreInput, "subject"> and the client merges { ...callerInput, subject } session-last, so a subject smuggled in at runtime is always overwritten by the membership-derived one. This is the real control — subject.practiceId already failed closed against the GUC, but subject.role and subject.id are trusted straight into decide() and the audit row.
  2. The MCP tool surface is a static, frozen, three-tool allowlist with no raw SQL/FHIRPath/shell/filesystem tool and no approve/commit tool. Handlers close over the session-bound SDK client only — no db/sql handle ever enters handler scope.

Commit chain

commit author contents
4af2037 operator prep (gate base) migration 0011 NOTIFY substrate, sdk+mcp scaffolds + frozen deps, Dockerfile COPYs, depcruise mcp → sdk reconcile, sgrules, contract-drift reconcile, ledger
d45ce01 maker (Fable) packages/sdk/** + packages/mcp/** only — 30 files, 30 tests
97a741c harden wave (Opus probes) one strengthened test oracle
403aec3 panel wave canonical snapshot digest, MCP import allowlist, Object.freeze

Allowed-paths attribution verified: git diff --name-only 4af2037..d45ce01 lists only packages/sdk/** and packages/mcp/**. Every off-floor file (drizzle, sgrules, depcruise, knip, docker, loop) lives in the disclosed operator commits.

Design decisions (two independent Opus design reviews, pre-implementation)

  • mcp → @bonfire/sdk, not mcp → core. Both reviewers converged: making the MCP re-implement verifyToken → resolveMembership → withTenant would create a second authz boundary that drifts — the classic fail-open. The depcruise rule was reconciled in prep (disclosed); sdk → mcp stays forbidden.
  • get_context chains search → buildCcp entirely server-side. The SearchResponse never round-trips through the model, which removes the forged-receipt surface and the unbounded-results DoS lever (ccpInputSchema.response.results has no .max).
  • The write tool is honest. writeScribeResource runs no decide() and appends no audit row — it is a live, ungoverned commit. The tool says so, returns a minimal confirmation, and fabricates no policy receipt. Contract acceptance H4 — memory spine: STATE ledger + bug-patterns KB + loop ratchet + VERDICT schema #8's receipt requirement is scoped to "the underlying read", which the two read tools satisfy.
  • LISTEN before snapshot. The initial RLS-scoped snapshot runs inside onListen, so one idempotent path covers both first load and every reconnect; snapshot-then-listen would leave a missed-commit window.
  • The NOTIFY payload is a constant table name. Channels have no ACLs and any role can pg_notify any channel, so tenant scoping of data never rides the wire: a spoofed NOTIFY is a harmless re-query, and emission requires the session's own RLS-scoped content digest to change.

De-risk (proven live before any code)

  • Event-trigger stamping of the notify triggers survives projections:rebuild (8 triggers before and after); delivery is at-commit only (rollback → silence); listener latency ~2 ms against a 2 s budget.
  • @modelcontextprotocol/sdk@1.29.0 + zod 4 + bun: unknown tool and off-schema argument are rejected before the handler runs, returned as isError results rather than JSON-RPC rejections (tests assert result.isError, not rejected promises).
  • @microsoft/api-extractor@7.58.9 against TS 6.0.3: CI mode exits 0 on an identical report and 1 on drift; needs newlineKind: "lf" and customConditions: [] (BP-031's @bonfire/source condition would otherwise resolve deps to src).

Review: 5-agent panel + 6-lens adversarial harden pass

Zero HIGH/BLOCKING code defects. All three danger-class refuters (propose-only-broken, cross-tenant-leak, fail-open+injection) broke nothing at high confidence; the security auditor returned PASS. Attacks attempted and blocked: subject smuggling via spread order / __proto__ / getters (defeated by Omit + session-last merge + zod strip), forged receipt into get_context (server-side chain + receiptMatches), spoofed NOTIFY (constant payload + content-diff), off-schema and unlisted-tool calls (double validation, zero side effects), error-text injection (fixed code→static-string table; error.message never forwarded).

Findings that were real and are fixed here:

  • U2 forensic gap (97a741c): session.test.ts asserted only the deny code, so the mutation "stop calling auditAuthFailure" left the whole suite green while failed-auth forensics vanished. Now oracled against the SYSTEM hash chain, keyed on a unique actor_id — deliberately not a count(*) delta, which races under turbo's parallel suites.
  • U3 digest (403aec3): the snapshot hash used JSON.stringify(rows), leaning on postgres.js key order staying accidentally stable. Now sha256Hex(canonicalizeJson(JSON.parse(JSON.stringify(rows)))). The JSON round-trip is load-bearing and comes first: canonicalizeJson over driver rows would serialize a Date as {} (no own enumerable keys), collapsing every timestamp so a last_updated-only change would emit nothing. The round-trip normalizes Dates to ISO strings, then RFC-8785 sorts keys.
  • MCP import allowlist (403aec3): no-egress-in-mcp was a denylist, evadable by anything it forgot. packages/mcp/src may now import only @bonfire/sdk, @bonfire/core, @modelcontextprotocol/sdk, zod, and its own modules; everything else fails closed. Inversion-proven: clean tree → ast-grep scan exit 0; a real import { readFileSync } from "node:fs" → exit 1; restored → exit 0. The same control written as a dependency-cruiser rule was rejected as inert — the config's includeOnly filters node builtins out of the graph, so it could never fire (proven by injection). Global-reachable vectors an import allowlist structurally cannot see (Bun.$, Bun.file, Bun.write, Bun.connect) were added to the denylist.

Verification status — please read before merging

Green at d45ce01: strict slice gate 14/14 (loop gate --slice BF-08 --base 4af2037 --strict), sdk 17/0, mcp 13/0, turbo gate 33/33 including both api-extractor CI gates.

Green at 403aec3 (this HEAD), DB-free: tsc -b, full-program eslint ., biome ci, ast-grep 13/13 + scan, check:comments, knip, jscpd, depcruise, semgrep, no inline suppressions.

Not re-run locally at this HEAD: the DB-backed suites and the strict gate. The dev machine's disk filled and the Docker daemon will not start, so 97a741c (test-only) and 403aec3 (which touches the reactive digest) have not been exercised against a live Postgres locally. CI is the live proof for this PR — it boots its own stack and runs the suites, bun run loop eval, conformance, and the round-trip/PHI gates. Do not merge on red CI.

Still owed after CI is green (operator, next session): the 7 danger inversions (mutate → RED → restore) against a live stack, a clean-room fresh-volume boot, and the panel's extra probe of the live tools/list inputSchema shape.

Deferred / accepted residuals (disclosed)

  • The write path runs no decide() and appends no audit row — a real intra-tenant authz/audit gap, RLS-scoped and honestly described. BF-09 owns propose → approve → commit governance; a biller-session-proposes-must-be-denied eval belongs to that slice.
  • packages/core/src/ccp/serialize.ts header fields are not JSON-encoded, contradicting that file's own docblock. Out of this slice's floor; non-exploitable today (each such field is a z.uuid(), a fixed FHIR literal, a DB-generated timestamp, or a static leaf-path entry — none can carry a newline). Flagged to the BF-07 owner.
  • Per-statement pg_notify takes a cluster-wide commit lock: a tenant spamming writes is a throughput ceiling. Deliberate v0 posture; upgrade path is one coalesced NOTIFY per transaction.
  • authenticate() has no outer try/catch (it trusts each dependency's Result contract). Proven still fail-closed: a throwing verifier rejects main(), the process exits non-zero, and the server is never constructed.
  • propose_resource connotes staging that does not exist yet; the emphatic description and minimal confirmation output are the mitigation. Revisit the name when BF-09 lands real governance.
  • Permanently-silent postgres.js reconnect gives no staleness guarantee — refresh() is the escape hatch, polling is the upgrade path. Only db.end() reclaims the listen socket.
  • bf08-* Stage-2 evals + the no-raw-response-string-in-mcp-serialize sgrule are the post-merge close-out wave (loop/** is maker-forbidden). Ratchet candidates: BP-038 (mcp egress denylist), BP-039 (mcp import allowlist).
  • Greptile waived — not wired to this repo.

🤖 Generated with Claude Code

AJ112103 and others added 4 commits July 10, 2026 09:20
…rkspace scaffolds + gate reconciles

Operator prep, all off the maker floor (drizzle/**, docker/**, gate configs,
loop/**) or pre-landed floor scaffolds (BP-023 new-workspace convention):

- drizzle/0011_projection_notify: statement-level AFTER I/U/D triggers emitting
  pg_notify('bonfire_projection_change', TG_TABLE_NAME) — constant, tenant-free,
  PHI-free payload (channels have no ACLs and payloads are spoofable; subscribers
  re-query through their own RLS-scoped tx, so tenant scoping of data never rides
  the wire). Stamped by a ddl_command_end event trigger (0004/0005 ratchet
  pattern) so triggers survive projections:rebuild's DROP+CREATE — proven live
  post-migration (8 triggers before AND after a full rebuild).
- packages/sdk + packages/mcp minimal scaffolds: exports map with @bonfire/source
  first (BP-031), NodeNext, deps frozen into bun.lock (@modelcontextprotocol/
  sdk@1.29.0, @microsoft/api-extractor@7.58.9 exact pins). Maker owns everything
  inside src/ from here; dependency blocks + bun.lock stay operator-owned.
- docker/api.Dockerfile: COPY both new workspace manifests (BP-023 guard).
- .dependency-cruiser.cjs: mcp-depends-only-on-core -> mcp-depends-on-sdk-and-core.
  Two independent design reviews converged: the MCP must consume the product
  through @bonfire/sdk's session-bound client (ONE identity/tenant boundary);
  re-implementing verifyToken->resolveMembership->withTenant inside mcp is a
  second authz boundary that drifts. sdk -> mcp stays forbidden; deep src/
  imports stay blocked.
- sgrules/no-egress-in-mcp (+tests): no shell/fs/network/hosted-SDK imports and
  no fetch/WebSocket/Bun.spawn under packages/mcp/src (ratchet candidate BP-038).
- sgrules/no-authz-attr-from-request widened: args|input|toolArgs|toolArguments
  join the untrusted-source list (MCP tool-argument objects), + fixtures.
- knip.json / root tsconfig references for both workspaces.
- tasks.ts BF-08 contract-drift reconcile (disclosed): drizzle/migrations/** and
  loop/evals/bf-08/** were DEAD paths; depcruise topology prose updated to the
  reviewed mcp->sdk edge. Ledger: BF-08 active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…calQuery) + @bonfire/mcp (propose-only 3-tool allowlist)

Maker diff, floor = packages/sdk/**, packages/mcp/** (root tsconfig untouched —
prep wired refs). Strict slice gate PASS 14/14 vs base 4af2037; sdk 17/0,
mcp 13/0, turbo gate 33/33 (incl. both api-extractor CI gates).

@bonfire/sdk:
- auth/session.ts (U2): authenticate = verifyToken -> resolveMembership ->
  BF-13 audit -> BonfireSession (#private-branded, type-only export, no
  deserializer); createSessionVerifier REQUIRES maxTokenAgeSeconds.
- run-op.ts + ops.ts: ONE executor (withTenant + session-derived subject +
  Result flatten + SDK_UNEXPECTED catch); method inputs Omit<'subject'>,
  effective input = { ...callerInput, subject } session-LAST (U2 control).
- ir.ts + gen/main.ts + generated/client.gen.ts: deterministic byte-idempotent
  codegen (sorted, no timestamps); createBonfireClient(db, session).
- reactive/: 8-view whitelist (compile+zod), LISTEN-first with snapshot inside
  onListen (no missed-commit window; reconnect resync same path), 150ms
  trailing debounce, single-flight, sha256 content-diff emission (a foreign
  practice's activity can't surface even as timing), sql(view) only after
  whitelist; structural ProjectionListener (no raw postgres import in src).
- api-extractor surface pin (etc/sdk.api.md, CI mode; flip-check proven).

@bonfire/mcp:
- tools.ts (U1/U4): static 3-tool ALLOWLIST (search_clinical, get_context,
  propose_resource), strict schemas with NO identity/scope field, purpose
  pinned to core's TREAT (literal-typed against enum reorder), fixed
  code->static-text error table (error.message never forwarded), search text
  JSON-wraps every stored-data field (D3), get_context chains search->buildCcp
  entirely server-side (D4 — response never round-trips through the model)
  and emits CcpDocument.text VERBATIM, propose_resource description is HONEST
  (live ungoverned write; BF-09 owns governance) with minimal confirmation.
- server.ts: per-session McpServer (CVE-2026-25536 posture), single
  registerTool callsite iterating ALLOWLIST, handlers close over the
  session-bound client only (D2); sole @modelcontextprotocol/sdk importer.
- main.ts (D5): env-only identity, fail-closed non-zero exit + zero tools on
  auth failure, db.end() on shutdown; api-extractor pin (etc/mcp.api.md).
- Package-level turbo.json (both): test dependsOn @bonfire/sql-on-fhir#test —
  joins the serialized DB lane (BP-036) since both suites touch fhir_resources
  and read vd_* under turbo concurrency.

Tests pin the danger checks: propose-only (exact tools/list + unlisted-tool
deny with zero side effects + api.md has no approve/commit), cross-tenant
(B-session search of A's term = 0 hits + receipt(B); reactive B-update
suppressed for A with re-query PROVEN ran), fail-open (off-schema pre-handler
deny + handler-ran spy; deny-audit oracle), injection (instruction-in-arg
stays data; forged-span leaf stays one JSON-escaped token), U2 inversion
(smuggled clinician subject overwritten by session's biller).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(U2)

Adversarial harden pass (6 probe lenses x independent Opus refuters over the
security modules U1-U4). One finding survived refutation; five were killed as
non-defects (allowlist array not frozen = no public mutation path; lookup-fault
audited as no-membership = fail-closed, not attacker-inducible; etc.).

FINDING (LOW, test gap — product code was already correct): session.test.ts
asserted only the deny CODE on every authenticate() failure path, so the
smallest mutation "denyAudited -> skip the auditAuthFailure call" left the whole
suite GREEN while failed-auth forensics silently disappeared (BF-13 acceptance
#7: every failed auth appends a deny row to the SYSTEM hash chain).

FIX: the no-membership deny test now keys a SYSTEM-chain oracle off a unique
actor_id (iss#sub with a random sub) and asserts exactly one row, decision=deny.
Keyed on actor_id, NOT a global count(*) — core's own auth-audit test warns the
SYSTEM chain is shared across suites, so a count-delta would be racy under
parallel/turbo runs. The verify-deny path is not oracled this way (its actor is
the constant "unverified", so it cannot be isolated per-test).

Empirically re-verified by the operator what three blocked probe lenses could
not (their shell probes were refused by a safety check):
- core DOES enforce maxTokenAgeSeconds (verify-token.ts passes jose maxTokenAge
  and adds "iat" to requiredClaims) — the SDK's required field is real, not
  decorative.
- the MCP off-schema test smuggles a practiceId extra key and asserts isError
  PLUS zero audit rows, i.e. a true pre-handler oracle (strict rejection), not
  an isError-only tautology; tools/list pins additionalProperties:false, and no
  tool schema exposes an identity/scope key.
- main.ts env coercion is fail-closed in the directions that matter:
  BONFIRE_MAX_TOKEN_AGE_SECONDS="" -> reject (exit 1); BONFIRE_ALGORITHMS="" ->
  reject; BONFIRE_CLOCK_TOLERANCE_SECONDS="" -> 0, which is STRICTER than the
  30s default, never looser.

DB-free CI parity green at this commit: tsc -b, eslint (full program), biome ci,
ast-grep test 12/12 + scan, check:comments, knip, jscpd, depcruise, semgrep scan
+ rule-tests 4/4, no inline suppressions. The DB-backed suites (sdk 17, mcp 13)
and the strict slice gate were green at d45ce01; they must be RE-RUN against
this commit once the local Docker stack is available (the host ran out of disk
and the daemon will not start).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st (positive control)

5-agent panel (contract verifier + Opus security auditor + 3 Opus danger-class
refuters + synthesis). Zero HIGH/BLOCKING code defects: all three refuters broke
nothing at high confidence, the auditor returned PASS, and the verifier's "FAIL"
is explicitly a NEEDS-LIVE-PROOF placeholder (the local Docker stack is down;
its two BLOCKING items are missing proof, not broken code). Fixes applied:

R1 [LOW, real deviation from the U3 delta] packages/sdk/src/reactive/use-clinical-query.ts
  The snapshot digest hashed JSON.stringify(rows), not the mandated canonical
  form, so the "practice B's write must not emit for practice A" suppression
  leaned on postgres.js key order staying accidentally stable. Now:
  sha256Hex(canonicalizeJson(JSON.parse(JSON.stringify(rows)))).
  The JSON round-trip is LOAD-BEARING and comes FIRST: a timestamptz arrives as
  a JS Date, and canonicalizeJson(Date) would take the object branch, find no
  own enumerable keys, and emit "{}" — collapsing every timestamp to identical
  bytes, so a last_updated-only change would emit NOTHING. The panel's literal
  suggestion (canonicalizeJson over driver rows) would have introduced exactly
  that missed-emission bug; the round-trip normalizes Dates to ISO strings via
  toJSON first, then RFC-8785 sorts keys so the digest cannot drift on column
  order. Verified against packages/core/src/db/canonical-json.ts:16-42.

L4 [LOW] packages/sdk/src/reactive/use-clinical-query.test.ts
  loadRows orders EVERY whitelisted view by (id, row_index) but only
  vd_patient_demographics is exercised end-to-end. A view missing either column
  errors -> loadRows returns null -> the store keeps a stale snapshot forever
  (fail-safe, never cross-tenant, but silently non-reactive). New catalog test
  pins the column convention across all eight CLINICAL_VIEWS.

M2 [MED, auditor] sgrules/mcp-import-allowlist.yml (+ fixtures) — POSITIVE CONTROL
  no-egress-in-mcp is a DENYLIST and therefore evadable by anything it forgot.
  The agent-facing import surface is now an ALLOWLIST: packages/mcp/src may
  import only @bonfire/sdk, @bonfire/core, @modelcontextprotocol/sdk, zod, and
  its own modules; ANY other specifier (every node builtin included) fails the
  gate closed. Covers `export ... from` too. Tests exempt (they seed/spy to
  prove the invariants). Ratchet candidate BP-039.
  INVERSION-PROVEN: clean tree -> ast-grep scan exit 0; a real `import
  { readFileSync } from "node:fs"` added to server.ts -> exit 1 with BOTH rules
  firing; restored -> exit 0. 21 rule fixtures (10 valid / 11 invalid) pass.
  REJECTED FIRST: the same control as a dependency-cruiser rule is INERT — the
  config's `includeOnly: "^(apps|packages|loop|...)"` filters node builtins and
  node_modules out of the graph, so a node:fs edge is never a candidate and the
  rule cannot fire. Proven by injecting the import: depcruise still reported
  "no dependency violations". A guard that cannot fire is worse than none, so it
  was reverted in favour of the ast-grep rule above.

M2b sgrules/no-egress-in-mcp.yml — closed the GLOBAL-reachable bypasses an import
  allowlist structurally cannot see: Bun.$`...`, Bun.file, Bun.write, Bun.connect,
  Bun.listen (joining fetch/globalThis.fetch/WebSocket/EventSource/Bun.spawn).

N1 [NIT] packages/mcp/src/tools.ts — ALLOWLIST is Object.freeze()d, so the
  three-tool surface is immutable at runtime, not merely `readonly` to tsc.

Panel findings deliberately NOT fixed here (disclosed in the PR body):
- The write path runs no decide() and appends no audit row (core/write-resource.ts).
  Real intra-tenant authz/audit gap, RLS-scoped and honestly described; BF-09 owns
  propose->approve->commit governance. No fabricated receipt is returned.
- core/src/ccp/serialize.ts header fields are not JSON-encoded (BF-07 floor, out of
  this slice). Non-exploitable today: every such field is a z.uuid(), a fixed FHIR
  literal, a DB-generated timestamp, or a static leaf-path entry — none can carry a
  newline. Flagged to the BF-07 owner.
- Per-statement pg_notify takes a cluster-wide commit lock: a single tenant spamming
  writes is a throughput ceiling. Deliberate v0 posture; upgrade path is one
  coalesced NOTIFY per tx.
- authenticate() has no outer try/catch (it trusts each dependency's Result
  contract). Refuter-3 proved this still fails CLOSED: a throwing verifier rejects
  main(), the process exits non-zero, and the server is never constructed.
- Tool name propose_resource connotes staging that does not exist yet; the emphatic
  description and the minimal confirmation output are the mitigation (delta D1).

DB-free CI parity green at this commit: tsc -b, eslint (full program), biome ci,
ast-grep test 13/13 + scan, check:comments, knip, jscpd, depcruise, semgrep,
no inline suppressions. The DB-backed suites (sdk 17, mcp 13) and the strict slice
gate were green at d45ce01 and MUST be re-run once the local Docker stack is back
(host disk exhausted; daemon will not start). Remaining live probes tracked in the
PR: 7 danger inversions, clean-room fresh-volume boot, strict gate --strict, plus
the panel's two extra probes (live tools/list inputSchema shape; commit-boundary
attribution — the latter already settled: d45ce01 touches only packages/sdk|mcp/**).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AJ112103

Copy link
Copy Markdown
Contributor Author

CI green at 403aec3 — 12/12

The DB-backed build-test / test job is the live proof the local stack could not give (dev-host disk exhausted, Docker daemon down). It booted its own Postgres and ran the suites at this HEAD, so the two commits that were only DB-free-verified locally (97a741c, 403aec3) are now exercised against a real database:

  • @bonfire/sdk:test18 tests (17 at d45ce01 + the new all-views (id, row_index) catalog assertion)
  • @bonfire/mcp:test13 tests
  • @bonfire/core:test 251, @bonfire/sql-on-fhir:test 56, @bonfire/api:test 18, seed 2, loop 199
  • bun run loop eval✓ 29 passed, 0 failed (the pre-existing Stage-2 corpus still green with the 0011 NOTIFY trigger installed, which is acceptance ci: boot the compose stack for DB-backed product tests #10's regression clause)

This matters most for 403aec3: the reactive suite that passed includes the same-practice <2s emission test and the cross-tenant no-emission test (which proves practice A's re-query ran and the content digest suppressed the emit). That is the path the canonicalized snapshot digest changed, and it holds on a real database.

Plus the full static/quality set: typecheck, lint, format, structural (ast-grep 13/13 + scan), boundaries (depcruise), suppressions, semgrep, gitleaks (working tree + history), knip, jscpd, build.

Still owed before this is fully closed out (operator, once the dev host has disk)

CI proves the acceptance criteria; it does not prove the tests are load-bearing. These remain:

  1. The 7 danger inversions (mutate → RED → restore): a 4th tool in ALLOWLIST; caller-last subject merge; delete the no-membership deny branch; stub auditAuthFailure; raw-interpolate a field in renderSearchText; remove the content-diff early return; main.ts continue-on-auth-failure.
  2. Clean-room fresh-volume boot + loop gate --slice BF-08 --base 4af2037 --strict (was 14/14 at d45ce01).
  3. The panel's extra probe: inspect the live tools/list inputSchema JSON. registerTool receives a full z.strictObject rather than a ZodRawShape; security is unaffected either way because tools.ts:91 re-parses with safeParse before the handler runs (and the shipped test asserts additionalProperties: false plus an isError + zero-audit-row oracle on a smuggled practiceId key), but the emitted schema shape deserves a direct look.

Two of the three sgrule guards added here were inversion-proven locally (ast-grep scan exit 0 clean → exit 1 with a real node:fs import → exit 0 restored). The dependency-cruiser version of that same rule was rejected as inert and reverted: the config's includeOnly filters node builtins out of the graph, so it could never fire — proven by injection rather than assumed.

@AJ112103
AJ112103 merged commit 5efa4c2 into main Jul 10, 2026
12 checks passed
AJ112103 added a commit that referenced this pull request Jul 10, 2026
…ard + a coin-flip CI flake), BP-038/BP-039 guarded, ledger done (#34)

* BF-08 close-out (part 1): BP-038 + BP-039 guarded, ledger BF-08 done

Operator wave on merged main (5efa4c2). Harness-only diff (loop/**, docs/loop/**).

RATCHET 36 -> 38 guarded / 1 open (BP-003 greptile, deferred — not wired):

- BP-038 (mcp-egress) GUARDED by sgrules/no-egress-in-mcp.yml. The MCP server is
  the first place an untrusted agent enters the system; nothing structurally kept
  a handler from importing node:child_process/node:fs/a network client and turning
  a tool call into shell execution or PHI egress. The rule bans the import vectors
  AND the global-reachable ones an import allowlist structurally cannot see
  (fetch, globalThis.fetch, WebSocket, EventSource, Bun.spawn/spawnSync/$/file/
  write/connect/listen), plus require()/dynamic import() of the banned modules.

- BP-039 (mcp-import-surface) GUARDED by sgrules/mcp-import-allowlist.yml. A
  denylist is only as good as its enumeration — a client it never heard of, a
  transitive re-export, or a package added next quarter fails OPEN. The import
  surface of packages/mcp/src is now an ALLOWLIST: its own modules, @bonfire/sdk,
  @bonfire/core, @modelcontextprotocol/sdk, zod. Everything else — every node
  builtin included — fails the structural gate.
  Recorded in the KB entry because it will save the next reader a day: the SAME
  control written as a dependency-cruiser rule is INERT. `.dependency-cruiser.cjs`
  sets `options.includeOnly` to the source dirs, which filters node builtins and
  node_modules out of the graph, so a `from packages/mcp/src, to node:fs` edge is
  never a candidate and the rule can never fire. Proven by injecting the import:
  depcruise still reported "no dependency violations". Inversion-proven in its
  ast-grep form instead — clean tree `ast-grep scan` exit 0; a real
  `import { readFileSync } from "node:fs"` in server.ts exit 1 (both rules fire);
  restored exit 0.

Ledger: BF-08 done. `loop state list` folds BF-01..08 + BF-13 all done.

Gates green (DB-free): tsc -b, eslint (full program), biome ci, ast-grep test
13/13 + scan, knip, jscpd, depcruise, semgrep, loop ratchet (38 guarded/1 open).

DEFERRED to close-out part 2 (needs a live PG18+pgvector stack; the dev host's
Docker is currently unavailable): the bf08-* Stage-2 execution evals with their
mutation canaries, the 7 danger inversions, the clean-room fresh-volume boot, and
the strict-gate re-run. The slice's acceptance criteria were proven by CI on
PR #33 (sdk 18 + mcp 13 + loop eval 29/0 against a real Postgres); what remains
is proving the tests are load-bearing, which no CI run can establish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* BF-08 close-out (part 2): 7 danger inversions run — one caught a decorative test, one caught a CI flake

The 7 adversarial inversions (mutate product code -> the owning test must go RED
-> restore) finally ran. They found two real problems that every green gate,
every CI run, and a 5-agent panel had missed.

INVERSION RESULTS (all now RED under mutation, GREEN on restore):
  I1 propose-only-broken  4th tool (approve_resource) appended to ALLOWLIST -> RED
  I2 cross-tenant/U2      subject merge flipped to caller-last in ops.ts    -> RED
  I3 fail-open-authz      no-membership deny branch deleted in session.ts   -> RED
  I4 audit-bypass         auditAuthFailure stubbed out in denyAudited       -> RED
  I5 injection (D3)       raw-interpolate a rendered field                  -> was GREEN, now RED
  I6 U3 timing-oracle     content-diff early return removed                 -> RED
  I7 D5 fail-closed boot  main.ts continues past an authenticate() deny     -> RED

FINDING 1 — the D3 injection guard was DECORATIVE (I5 left every test green).
renderSearchText JSON-encodes every field it emits so an injected newline cannot
forge an authentic-looking `[N] type=... id=...` hit line or a second `receipt:`
line in the text an LLM reads. But NO field it emits can carry a newline with
today's fixtures: ids are uuids, `citation.path` comes from the indexer's fixed
leaf-path table, `resourceType` is a FHIR literal. So the end-to-end injection
test cannot distinguish the hardened renderer from a raw-interpolating one, and
the mandated guard was untested.
FIX: packages/mcp/src/render.test.ts drives the pure renderers with hostile
values directly (a value carrying `\n[9] type="Patient" id=...` and a forged
`receipt:` line). Asserts the document keeps its exact line count, that no line
BEGINS with the forged structure, and that exactly one receipt line exists.
`renderSearchText`/`renderWriteText` are exported from tools.ts for the canary
but NOT re-exported from index.ts, so the package's public surface is unchanged
(api-extractor CI mode still exits 0). Proven load-bearing against three
independent mutations: raw `citation.path`, raw `resourceType`, and raw
`record.type` in the write renderer each turn it RED.

FINDING 2 — BF-08's tests made a pre-existing eval a COIN-FLIP CI FLAKE.
`bf04-rls-cross-tenant` picked practice A as `practices[0]`, the lowest
practice_id in vd_patient_demographics, then asserted it sees its own spidx rows.
BF-08's hermetic sdk/mcp suites seed practices with randomUUID(), so roughly 6
times in 16 a projection-only practice sorts below the seeded `1111...` one,
practice A owns zero spidx rows, and the eval dies as "vacuous" — a failure that
says nothing about RLS. CI on PR #33 passed by luck; locally it reproduced on the
first dirty run. (This fragility was a known BF-04 eval-hardening candidate; BF-08
is what made it fire.)
FIX: practice A is now selected from the practices that provably carry rows on
BOTH surfaces the eval probes (vd_patient_demographics JOIN spidx), ordered for
determinism. Non-vacuity becomes a property of the SELECT rather than of luck.
CANARIED: still goes RED when bonfire_app is granted BYPASSRLS, and passes on two
consecutive dirty runs (evals immediately after the sdk+mcp suites).

VERIFICATION ENVIRONMENT (disclosed): the dev host's Docker is unavailable (it
launches a GUI error dialog after a disk exhaustion + VM-disk reset), so this
work ran against a native Postgres 17.10 + pgvector 0.8.5 cluster provisioned to
mirror docker/initdb/010-roles.sh exactly (vector extension, bonfire_app LOGIN
NOSUPERUSER NOBYPASSRLS, BP-018 append-only default privileges). All 12 migrations
apply; sdk 18/0 + mcp 13/0 + core/api/sql-on-fhir/seed/loop suites pass; `loop
eval` 29/0. The inversions do not depend on the server version.
`loop gate` therefore reports 12 passed / 1 failed, and the ONE failure is the
repo's own catalog guard asserting "Postgres major version is 18+" — the guard
working as designed against my substitute stack. The strict slice gate on PG18
remains owed and is what CI provides on this PR.

Gates green here: turbo gate, eslint (full program), biome ci, ast-grep test
13/13 + scan, knip, jscpd, depcruise, semgrep, api-extractor CI mode (both
packages), loop eval 29/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant