From d454d209cd5e4b9b95bcecd90c53df02d5d6a44f Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Fri, 12 Sep 2025 16:23:39 +0000 Subject: [PATCH 1/4] feat(privacy,safety,telemetry): add consent ledger API (hash-chained), safety pipeline (pre/steer/post), telemetry placeholder; wire into SSE stream and chat UI --- next-app/app/api/chat/stream/route.ts | 9 +++-- next-app/app/api/consent/route.ts | 18 ++++++++++ next-app/app/chat/page.tsx | 5 +-- next-app/lib/privacy/consentLedger.ts | 50 +++++++++++++++++++++++++++ next-app/lib/safety/pipeline.ts | 18 ++++++++++ next-app/lib/telemetry/record.ts | 5 +++ 6 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 next-app/app/api/consent/route.ts create mode 100644 next-app/lib/privacy/consentLedger.ts create mode 100644 next-app/lib/safety/pipeline.ts create mode 100644 next-app/lib/telemetry/record.ts diff --git a/next-app/app/api/chat/stream/route.ts b/next-app/app/api/chat/stream/route.ts index a983cd7..1e7bf8b 100644 --- a/next-app/app/api/chat/stream/route.ts +++ b/next-app/app/api/chat/stream/route.ts @@ -8,13 +8,18 @@ function* fakeStream(text: string) { } } +import { preFilter, steerPrompt, postModerate } from '@/lib/safety/pipeline'; + function streamForMessage(message: string) { const ctrl = new AbortController(); const stream = new ReadableStream({ async start(controller) { try { - const reply = `Echo: ${message}`; - const meta = { layer: 'surface', model: 'mock', version: '0.0.1', latencyMs: 42 }; + const pre = preFilter(message); + const safePrompt = steerPrompt(message); + const reply = `Echo: ${safePrompt}`; + const post = postModerate(reply); + const meta = { layer: 'surface', model: 'mock', version: '0.0.1', latencyMs: 42, pre, post }; controller.enqueue(encode(`event: meta\ndata: ${JSON.stringify(meta)}\n\n`)); for (const chunk of fakeStream(reply)) { await new Promise(r => setTimeout(r, 10)); diff --git a/next-app/app/api/consent/route.ts b/next-app/app/api/consent/route.ts new file mode 100644 index 0000000..704ba53 --- /dev/null +++ b/next-app/app/api/consent/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server'; +import { appendConsentEvent, exportConsent } from '@/lib/privacy/consentLedger'; + +export const runtime = 'nodejs'; + +export async function POST(req: NextRequest) { + const { userId = 'demo', sessionId, action } = await req.json(); + if (!['persist_on','persist_off','export'].includes(action)) return new Response('bad action', { status: 400 }); + const ev = await appendConsentEvent({ userId, sessionId, action, ts: new Date().toISOString() as any }); + return Response.json(ev); +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const userId = searchParams.get('userId') ?? 'demo'; + const data = await exportConsent(userId); + return Response.json(data); +} diff --git a/next-app/app/chat/page.tsx b/next-app/app/chat/page.tsx index 4c47988..9427427 100644 --- a/next-app/app/chat/page.tsx +++ b/next-app/app/chat/page.tsx @@ -49,7 +49,7 @@ export default function ChatPage() { return (
-

Chat

+

Chat (ephemeral by default)

{messages.map((m, i) => ( @@ -63,10 +63,11 @@ export default function ChatPage() {
))}
-
+
setInput(e.target.value)} className="flex-1 rounded border px-3 py-2" placeholder="Type a message..." /> {fallback && Fallback in use} + Export consent ledger
diff --git a/next-app/lib/privacy/consentLedger.ts b/next-app/lib/privacy/consentLedger.ts new file mode 100644 index 0000000..ef614bb --- /dev/null +++ b/next-app/lib/privacy/consentLedger.ts @@ -0,0 +1,50 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +export type ConsentAction = 'persist_on' | 'persist_off' | 'export'; +export type ConsentEvent = { userId: string; sessionId?: string; action: ConsentAction; ts: string; prevHash?: string; hash?: string }; + +const DATA_DIR = path.join(process.cwd(), 'next-app', '.data', 'consent'); + +export async function appendConsentEvent(e: Omit) { + await fs.mkdir(DATA_DIR, { recursive: true }); + const chainFile = path.join(DATA_DIR, `${e.userId}.jsonl`); + let prevHash: string | undefined; + try { + const last = await tailLastLine(chainFile); + if (last) prevHash = JSON.parse(last).hash; + } catch {} + const event: ConsentEvent = { ...e, prevHash, ts: e.ts ?? new Date().toISOString() }; + event.hash = hashEvent(event); + await fs.appendFile(chainFile, JSON.stringify(event) + '\n', 'utf8'); + return event; +} + +export function hashEvent(e: ConsentEvent) { + const s = `${e.userId}|${e.sessionId ?? ''}|${e.action}|${e.ts}|${e.prevHash ?? ''}`; + return crypto.createHash('sha256').update(s).digest('hex'); +} + +export async function exportConsent(userId: string) { + const chainFile = path.join(DATA_DIR, `${userId}.jsonl`); + try { + const raw = await fs.readFile(chainFile, 'utf8'); + const events = raw.trim().split('\n').map((l) => JSON.parse(l) as ConsentEvent); + return { events, root: events.at(-1)?.hash }; + } catch (e: any) { + if (e.code === 'ENOENT') return { events: [], root: undefined }; + throw e; + } +} + +async function tailLastLine(file: string): Promise { + try { + const data = await fs.readFile(file, 'utf8'); + const lines = data.trim().split('\n'); + return lines.length ? lines[lines.length - 1] : null; + } catch (e: any) { + if (e.code === 'ENOENT') return null; + throw e; + } +} diff --git a/next-app/lib/safety/pipeline.ts b/next-app/lib/safety/pipeline.ts new file mode 100644 index 0000000..ff1477e --- /dev/null +++ b/next-app/lib/safety/pipeline.ts @@ -0,0 +1,18 @@ +export type ModerationAction = 'allow' | 'block' | 'revise'; +export type ModerationEvent = { stage: 'pre' | 'post'; action: ModerationAction; reason?: string }; + +const SENSITIVE = /(ssn|password|credit\s*card|cvv)/i; + +export function preFilter(input: string): ModerationEvent { + if (SENSITIVE.test(input)) return { stage: 'pre', action: 'revise', reason: 'redact_sensitive' }; + return { stage: 'pre', action: 'allow' }; +} + +export function steerPrompt(input: string): string { + return `Policy: Be safe and helpful. Avoid unsafe advice.\n${input}`; +} + +export function postModerate(output: string): ModerationEvent { + if (/violent|illegal/i.test(output)) return { stage: 'post', action: 'block', reason: 'unsafe_content' }; + return { stage: 'post', action: 'allow' }; +} diff --git a/next-app/lib/telemetry/record.ts b/next-app/lib/telemetry/record.ts new file mode 100644 index 0000000..33a2ead --- /dev/null +++ b/next-app/lib/telemetry/record.ts @@ -0,0 +1,5 @@ +export type ProviderMeta = { provider?: string; model?: string; layer?: string; version?: string; tokensIn?: number; tokensOut?: number; latencyMs?: number; tools?: any[] }; +export async function recordProviderInvocation(sessionId: string | undefined, meta: ProviderMeta) { + // Placeholder: in MVP, just log to console; integrate with OTel/PostHog later + console.log('provider_invocation', { sessionId, ...meta }); +} From fea6321dcdfeb8045ad85f6aa6224003179213c9 Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Mon, 15 Sep 2025 10:46:51 +0000 Subject: [PATCH 2/4] feat(risk): add /risk page embedding interactive AI Risk Matrix and Governance Dashboard (static iframe srcDoc) --- next-app/app/risk/page.tsx | 102 +++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 next-app/app/risk/page.tsx diff --git a/next-app/app/risk/page.tsx b/next-app/app/risk/page.tsx new file mode 100644 index 0000000..9e46a32 --- /dev/null +++ b/next-app/app/risk/page.tsx @@ -0,0 +1,102 @@ +export const metadata = { title: 'AI Risk Navigator' }; +export default function RiskPage() { + return ( +
+

Interactive 10-Stage AI Risk Matrix

+

Filterable matrix and governance dashboard demos.

+