Skip to content

Fix LLM audit prompt fingerprints - #1843

Merged
laboef1900 merged 5 commits into
devfrom
feature/1790-prompt-content-fingerprints
Nov 5, 2026
Merged

laboef1900 merged 5 commits into
devfrom
feature/1790-prompt-content-fingerprints

Conversation

@laboef1900

Copy link
Copy Markdown
Contributor

Fixes #1790
Part of #1827

Change

  • Fingerprint ordered role/content tuples from the actual provider prompt, retaining structured text/image content and framing boundaries. Pass the SHA-256 digest alongside summaries to the audit hook; the CE writer stores it unchanged without prompt plaintext. Update all affected inference producers and the hook contract.
  • Add framing, privacy and real Generate/loopback provider/PostgreSQL coverage. Historical role/length-summary hashes are not comparable with new fingerprints.

Evidence and checks

  • Sanitized real HTTP smoke: the changed app listened on 127.0.0.1:31891 and the controlled chat provider on 127.0.0.1:31890, with isolated PostgreSQL and Redis. Three uncached Generate calls produced actual provider messages and real audit rows: equal-length different prompts had different hashes; identical framed messages had the same hash. Audit rows contained no prompt plaintext. Full synthetic messages and rows: local epic-1827-evidence/1790/http-provider-rows.log.
  • npm test -w backend, npm test -w frontend, npx vitest run --config e2e/vitest.config.ts passed on the final precommit source (global-r2 manifest SHA-256 4d92a43c2288a5d622941b52aa0b2ca110eb809cadfeadc5e58db52f389e9c29). npm run lint and npm run typecheck passed on the same manifest (global-r3). Node v24.22.1.
  • npm run build, contracts tests within the initial root npm test, and npm audit --omit=dev --audit-level=high passed on global-r1; reused because subsequent change was test-only. The initial root test run failed three SSE success cases because its closed audit-hook mock omitted the new export. The mock now keeps actual exports; the focused SSE suite passed 30/30 before removal of an additional assertion, and the final backend suite passed after that removal. The initial failure is retained in global-r1/all-tests.log.
  • Focused audit hook/writer and route regressions passed; sanitized loopback integration passed. Full command/provenance records are under local epic-1827-evidence/1790/.

Limits and rollback

  • The digest is an audit identity, not a cryptographic-attack claim or proof of model execution. A digest may still be susceptible to guessing for low-entropy prompts; it is not encryption. Existing hashes cannot be reconstructed from old length summaries.
  • Roll back by reverting this commit, which restores the former hook and length-summary fingerprint behavior; new hashes already stored remain as historical records.

@laboef1900 laboef1900 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Independent Astra-high review of published 9317f54: correct, no actionable findings. Traced seven inference producers, provider serialization, SSE forwarding, hook dispatch and CE persistence: actual content fingerprints preserve ordered role/content framing and structured text/image distinctions, while hooks/persistence carry only digest and summaries. Inspected sanitized actual HTTP Generate/provider/audit-row evidence: different equal-length content differs, identical framed content is stable, no prompt plaintext in audit rows. Focused12/routes146; corrected backend7168/frontend5799/root unit30; final lint/typecheck and unchanged build/contracts/audit reuse. Initial closed-mock export failures retained and corrected. Published-source attestation matches. Read-only reviewer did not execute checks; no approval claimed. CI completion remains separately tracked.

@laboef1900 laboef1900 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Independent review: PR #1843

Issue: #1790 (bug; no NO AI label). Target: dev.

Head: 9317f544b22874712c2cfcc9f873a916892b3c6d
Base: 5edff9e08dad25e9db1c5668ff211c794fc9c2fd

Verdict

Correct; no confirmed patch-introduced actionable findings. Confidence: 0.97. Review disposition: COMMENT (read-only source/evidence assessment, not newly executed verification or GitHub approval).

Scope and boundary review

Full seventeen-file diff and surrounding code/tests. Traced Generate, Improve, Ask, Diagram, Quality, Summarize and inline completion through provider serialization, shared SSE audit forwarding, inference/admin dispatcher and CE SQL writer. Cache-hit exits precede audit inference paths. No other inference emitters found in CE production source.

Acceptance criteria

  • Deterministic content fingerprint with unambiguous ordered framing — FULLY ADDRESSED. llm-audit-hook.ts:12-18 hashes ordered role/content JSON tuples; structured text/image tags and part order retained. All seven inference producers pass actual provider messages (inline FIM wraps actual prompt as one user tuple).

  • Different equal-length contents differ, identical contents stable — FULLY ADDRESSED. Hook framing tests and listening Generate integration compare independently computed expected hashes with captured provider messages and real audit rows.

  • No prompt plaintext in summary-only hooks or CE persistence — FULLY ADDRESSED. Hook payload replaces inputText with required promptHash; shared SSE helper spreads audit metadata unchanged; emitLlmAudit routes inference to _hook; default writer binds entry.promptHash to prompt_hash. Image regression checks content and data URL absent from emitted entry.

Findings

None.

Evidence inspected (reused, not executed here)

  • pr-1843-metadata.json: applicable final-head CI SUCCESS
  • epic-1827-evidence/1790/http-provider-rows.log: three actual Generate requests; alpha/bravo equal lengths have different hashes, repeated alpha same hash; rows contain no prompt plaintext
  • epic-1827-evidence/1790/published-source-attestation.json: final-head tested source linkage

Evidence limits

No checks run here; private EE implementation not included in CE review. Hashes are audit identities, not encryption, egress attestation or proof of model execution. Old length-summary hashes remain historically incomparable. Initial omitted-export mock failures and correction retained in PR evidence.

Suggested targeted verification

Parent only in isolated PostgreSQL/Redis: npx vitest run src/domains/llm/services/llm-audit-hook.test.ts src/domains/llm/services/llm-audit-default-writer.test.ts src/routes/llm/generate-audit.integration.test.ts src/routes/llm/generate-with-image.test.ts src/routes/llm/sse-stream-limit-gate.test.ts. Include EE writer contract suite if reviewing EE separately.

Cross-PR interaction

Compatible by source reasoning with 1842: changed source-aware context feeds the messages fingerprinted by this PR. No merge or combined execution performed.

No source edits, branch changes, services, GitHub reviews, pushes or merges performed.

Parent validation and publication

No additional actionable findings from parent triage.

Current final-head GitHub CI is passing. This is an independent source/evidence review, not a newly rerun full Docker/browser matrix or a GitHub approval. Source-only findings are identified as such. No code modifications or merges performed.

Reviewed by PR Reviewer Agent (eight independent review contexts; parent-validated publication).

@laboef1900 laboef1900 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated PR Review

PR: #1843 -- Fix LLM audit prompt fingerprints
Branch: feature/1790-prompt-content-fingerprints -> dev (reviewed at 9317f544)
Diff size: 569 lines (+196/-45)
Reviewed at: 2026-11-01 10:43 UTC


Static Checks

Check Status Details
TypeScript PASS npm run typecheck
Lint PASS npm run lint
Backend Tests PASS* 1 failed
Frontend Tests PASS 5799 passed (5799)
Production Build PASS npm run build
npm audit FAIL pre-existing: 11 advisories (10 moderate, 1 high systeminformation) in the shared lockfile; package-lock.json not changed by this PR
Branch Target PASS feature/* → dev

Docker App Test

Built from source (docker-compose.yml + docker-compose.build.yml, native arch) on an isolated project pr-review-1843; API reached through the nginx proxy (the backend publishes no host port by design).

Check Status
Containers Healthy (postgres, redis, backend, frontend) PASS
Migrations (fresh DB → Migrations complete, through 129) PASS
Backend /api/health + /api/health/ready PASS
Fresh-instance setup (POST /api/setup/admin → 201) + Login PASS
API /api/pages, /api/search?q=…, /api/analytics/trending, /api/settings PASS (200)
API /api/admin/settings, /api/admin/llm-providers, /api/notifications/count PASS (200)
Frontend loads (GET / → 200) PASS
No error/fatal logs (level ≥ 50) PASS

E2E Browser Tests

CI recipe (E2E_CI=1 npx playwright test: built backend + vite preview, fresh kb_e2e database, provisioning via e2e/helpers/ci-setup.ts).

Project Status
e2e helper unit tests (vitest --config e2e/vitest.config.ts) PASS
chromium + collab PASS (68 passed (3.4m))

Code Review Findings

# Severity File:Line Finding
1 INFO backend/src/domains/llm/services/llm-audit-hook.ts:13-18 fingerprintPrompt builds one JSON.stringify string of the whole framed prompt before hashing. For image-bearing Generate/Improve/Ask requests the image_url parts are full base64 data URLs (up to base64Length(MAX_IMAGE_BYTES) ≈ 6.8 MB each, plus the /llm/ask retrieved-image budget), so each audit emit makes an extra multi-MB string copy synchronously on the event loop. Correct, but avoidable: feed the hasher incrementally per framed element (hash.update(...) with explicit separators), or reuse the byte digests the cache key already has (imageHash, retrieved images' sha256) as CLAUDE.md:108-109 does for the cache key.
2 INFO backend/src/domains/llm/services/llm-audit-hook.ts:21-29 LlmAuditEntry — named as the EE audit-hook contract in CLAUDE.md:736 and documented (llm-audit-hook.ts:57-59 area) as "adding optional fields is compatible; renaming or requiring one is not" — gains the required promptHash and loses inputText?. Adding a required field is safe for a consumer-only EE writer, and git grep of origin/dev shows no CE producer ever populated inputText (only the deleted writer helper read it), so the CE cutover is clean. An EE writer that references entry.inputText would stop compiling against the new type; verify in compendiq-ee before merge.
3 INFO docs/ADMIN-GUIDE.md:1313-1325 The auditor-facing compliance notes explain pivoting on prompt_hash and the pre-073 NULL-hash caveat, but not the new discontinuity introduced here (rows written before #1790 hold role/length-summary digests that are not comparable to content fingerprints). CHANGELOG.md:10-14, CLAUDE.md:114-119 and docs/architecture/10-flow-enterprise-license.md:195-198 carry the note; one sentence in the ADMIN-GUIDE caveat would keep the operator view consistent.

Acceptance Criteria (Issue #1790)

AC Criterion Status Evidence
AC-1 Compute a deterministic content fingerprint with unambiguous message framing at a boundary that has the prompt PASS fingerprintPrompt (backend/src/domains/llm/services/llm-audit-hook.ts:12-19) SHA-256s JSON.stringify of ordered [role, content] tuples, with structured parts framed as ['text', text] / ['image_url', url]; no salt, no per-process state, so it is stable across restarts. Called by every producer that holds the exact array passed to streamChat: llm-ask.ts:1142,1176 (messages), llm-generate.ts:197,213 (generateMessages), llm-improve.ts:289,305 (improveMessages), llm-diagram.ts:107, llm-quality.ts:87, llm-summarize.ts:130 (via streamSSE audit → _helpers.ts:363-372), inline-completion-client.ts:227 (inputMessages). openai-compatible-client.ts:507-511,565 sends messages unchanged, so the hashed array is the provider prompt. Framing tests: llm-audit-hook.test.ts:49-62 (boundary shift 'a'/'bcd' vs 'ab'/'cd', order swap, role swap, 'x' vs [{type:'text',text:'x'}], distinct image URLs).
AC-2 Keep plaintext out of persisted audit data and out of hooks that currently receive summaries only PASS LlmAuditEntry carries only inputMessages[].contentLength + promptHash; inputText? removed (llm-audit-hook.ts:21-34). CE writer stores entry.promptHash verbatim and no longer hashes anything itself (llm-audit-default-writer.ts:34-62; node:crypto import removed). Tests: llm-audit-default-writer.test.ts:64-71 (to_jsonb(row) does not contain the secret, real Postgres), generate-audit.integration.test.ts:109 (rows do not contain 'private prompt'), generate-with-image.test.ts:302-308 (serialized entry contains neither the user text nor the image data URL), llm-audit-hook.test.ts:61.
AC-3 Different equal-length prompt contents must produce distinct fingerprints PASS Unit: llm-audit-hook.test.ts:53 (system:'ab'→'ac', same lengths, different hash). Integration against a real loopback OpenAI-compatible provider + real Postgres + Redis: generate-audit.integration.test.ts:87-110 sends 'alpha private prompt' / 'bravo private prompt' (both 20 chars) through POST /api/llm/generate, asserts the provider-received [role, length] sequences are equal (line 99) while contents differ (100), and that the three persisted prompt_hash values equal an independently computed SHA-256 of the received messages with hash[0] !== hash[1] and hash[0] === hash[2] (103-108). Cache replay is excluded by deleting the prior key before each call (84-86; buildLlmCacheKey without opts equals the route's key since thinking/imageHash are undefined, llm-cache.ts:39-55).

Story Validation (Issue #1790)

Issue intent: Audit prompt_hash for message-based LLM calls was a digest of role:length summaries, so distinct prompts of equal lengths collided; the issue asks for a deterministic content fingerprint computed where the prompt exists, without letting plaintext reach the hook or the persisted row.
Story completion: FULLY ADDRESSED
User journey: Before: two uncached Generate calls with different prompts of equal role/length shape wrote identical prompt_hash rows, so exported audit records could not distinguish them -> After: each producer hashes the exact ordered role/content tuples it hands to the provider (text and image parts framed), passes only the digest through LlmAuditEntry.promptHash, and the CE writer persists it unchanged; equal-length distinct prompts now yield distinct hashes and identical prompts repeat the same hash, verified end-to-end against a real provider and Postgres.
Gaps: None blocking. Non-blocking: fingerprint serializes full base64 image payloads before hashing (perf, finding 1); inputText? removed from the EE-facing contract — confirm the EE writer does not reference it (finding 2); ADMIN-GUIDE auditor caveat does not mention the hash discontinuity (finding 3).
Evidence: backend/src/domains/llm/services/llm-audit-hook.ts:8-34; backend/src/domains/llm/services/llm-audit-default-writer.ts:21-62; backend/src/routes/llm/llm-ask.ts:1131-1147,1165-1181; llm-generate.ts:189-220; llm-improve.ts:281-312; llm-diagram.ts:100-110; llm-quality.ts:80-90; llm-summarize.ts:123-133; backend/src/domains/llm/services/inline-completion-client.ts:221-233; backend/src/routes/llm/_helpers.ts:271,363-372; tests llm-audit-hook.test.ts:49-62, llm-audit-default-writer.test.ts:31-71, generate-audit.integration.test.ts:1-111, generate-with-image.test.ts:299-309, sse-stream-limit-gate.test.ts:145-148 (mock now spreads actual exports so fingerprintPrompt resolves); docs CHANGELOG.md:10-14, CLAUDE.md:114-119, docs/architecture/10-flow-enterprise-license.md:184,195-198. Grep of the worktree finds no inputText references and no inference emitLlmAudit/audit: producer lacking promptHash; the remaining closed vi.mock stubs of the hook module (llm-providers-shadow-guard, llm-providers-test-connection, llm-usecases-shadow-guard) exercise only admin-event routes that import just emitLlmAudit.

Suggested Actions

  1. [CONSIDER] backend/src/domains/llm/services/llm-audit-hook.ts:13-18 — hash incrementally per framed element (or reuse existing image byte digests) instead of JSON.stringify-ing multi-MB base64 data URLs into a temporary string on every audit emit.
  2. [CONSIDER] backend/src/domains/llm/services/llm-audit-hook.ts:21-29 — confirm the compendiq-ee audit writer does not reference the removed inputText field before merging the contract change.
  3. [OPTIONAL] docs/ADMIN-GUIDE.md:1313-1325 — add the "pre-#1790 hashes are length-summary digests, not comparable" caveat next to the existing pre-073 NULL-hash note.

Verdict: APPROVE

Reason: All static checks, the Docker smoke run and the full Playwright suite pass; no CRITICAL or WARNING findings; issue #1790 is fully addressed — content fingerprints are SHA-256 over framed role/content tuples, plaintext stays out of LlmAuditEntry and the persisted row, every producer is covered, and an end-to-end test proves equal-length distinct prompts hash differently. INFO items (incremental hashing of image payloads, EE inputText consumer check, ADMIN-GUIDE caveat) are optional.

Posted as a comment: GitHub rejects APPROVE/REQUEST_CHANGES from the PR author's own account, so the verdict above is advisory.


Reviewed by PR Reviewer Agent | 2026-11-01

@laboef1900
laboef1900 merged commit 8383e43 into dev Nov 5, 2026
8 checks passed
@laboef1900
laboef1900 deleted the feature/1790-prompt-content-fingerprints branch November 5, 2026 18:52
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.

[P3] Compute LLM audit prompt fingerprints from prompt content rather than message lengths

1 participant